diff --git a/web/base-path.js b/web/base-path.js new file mode 100644 index 0000000..f494cd9 --- /dev/null +++ b/web/base-path.js @@ -0,0 +1,34 @@ +/** + * SvelteKit `paths.base` resolution, shared between `svelte.config.js` + * (page routing / prerendering) and `vite.config.js` (PWA precache + * manifest). Both consumers MUST agree on the base or the service + * worker's `additionalManifestEntries` end up origin-rooted while the + * app itself serves under a sub-path (e.g. GitHub Pages `/loto`), + * causing every precached URL to 404 and the SW install to fail. + * + * Resolution order: + * 1. NEXT_BASE_PATH — explicit override (escape hatch) + * 2. codeserver dev → /absproxy/{port} + * 3. BUILD_PROFILE=gh → /loto + * 4. default (local dev / generic static host) → "" + * + * @module base-path + */ +import { loadEnv } from "vite"; + +/** @returns {string} */ +export function resolveBase() { + if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH; + if (process.env.VITE_DEV_PROFILE === "codeserver") { + // .env.local lives outside process.env at config-eval time; loadEnv reads it. + const env = loadEnv( + process.env.NODE_ENV ?? "development", + process.cwd(), + "", + ); + const port = env.CODESERVER_PORT ?? "3000"; + return `/absproxy/${port}`; + } + if (process.env.BUILD_PROFILE === "gh") return "/loto"; + return ""; +} diff --git a/web/jsconfig.json b/web/jsconfig.json index a8ef6e9..e1f6d9c 100644 --- a/web/jsconfig.json +++ b/web/jsconfig.json @@ -2,6 +2,6 @@ "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, - "checkJs": false + "checkJs": true } } diff --git a/web/package-lock.json b/web/package-lock.json index f8689bf..eabcbd4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,6 +20,7 @@ "globals": "^17", "happy-dom": "20.11.0", "svelte": "^5", + "svelte-check": "^4.7.6", "tailwindcss": "^4", "vite": "^8.1.5", "vitest": "4.1.10" @@ -1903,9 +1904,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2025,9 +2023,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2045,9 +2040,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2065,9 +2057,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2085,9 +2074,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2105,9 +2091,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2125,9 +2108,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2414,9 +2394,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2431,9 +2408,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2448,9 +2422,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2465,9 +2436,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2482,9 +2450,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2499,9 +2464,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2516,9 +2478,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2533,9 +2492,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2550,9 +2506,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2567,9 +2520,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2584,9 +2534,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2601,9 +2548,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2618,9 +2562,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2780,6 +2721,16 @@ } } }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", @@ -2943,9 +2894,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2963,9 +2911,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2983,9 +2928,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3003,9 +2945,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3712,6 +3651,22 @@ "node": ">=18" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5822,9 +5777,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5846,9 +5798,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5869,9 +5818,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5893,9 +5839,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5959,9 +5902,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6071,6 +6011,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6519,6 +6469,20 @@ "node": ">=6" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6732,6 +6696,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -7233,6 +7210,31 @@ "node": ">=18" } }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, "node_modules/svelte-eslint-parser": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.1.tgz", @@ -7539,6 +7541,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -7943,9 +7960,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7967,9 +7981,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7991,9 +8002,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8591,22 +8599,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index 88fa8ec..b47a598 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,8 @@ "build:gh": "BUILD_PROFILE=gh vite build", "preview": "vite preview", "lint": "eslint .", + "check": "svelte-check --tsconfig ./jsconfig.json", + "verify:pwa": "node scripts/verify-pwa-build.mjs", "test": "vitest run", "test:watch": "vitest" }, @@ -34,6 +36,7 @@ "globals": "^17", "happy-dom": "20.11.0", "svelte": "^5", + "svelte-check": "^4.7.6", "tailwindcss": "^4", "vite": "^8.1.5", "vitest": "4.1.10" diff --git a/web/scripts/verify-pwa-build.mjs b/web/scripts/verify-pwa-build.mjs new file mode 100644 index 0000000..1813670 --- /dev/null +++ b/web/scripts/verify-pwa-build.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * Build-time regression guard for the PWA layer. + * + * `adapter-static` renders page HTML during `adapt()`, which runs AFTER the + * PWA plugin's `closeBundle` — so `@vite-pwa/sveltekit` never gets to inject + * its own registration tag into the built HTML, and a plain grep on + * `build/index.html` proves nothing either way. `+layout.svelte` registers + * the service worker itself via a dynamically-imported `virtual:pwa-register` + * chunk instead; this script asserts that wiring survived the build rather + * than trusting a future refactor not to quietly drop it. + * + * Also asserts the audio precache manifest in `build/sw.js` is prefixed + * with the SAME base path SvelteKit itself resolved for this build — a + * mismatch here 404s every precached clip and fails the SW `install` event + * on that origin (this exact bug shipped once already). + * + * Usage: run AFTER `npm run build` / `npm run build:gh` (see package.json's + * `verify:pwa` script), with the same `BUILD_PROFILE`/`NEXT_BASE_PATH` env + * the build used. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { resolveBase } from "../base-path.js"; + +const BUILD_DIR = "build"; +let ok = true; + +/** @param {string} msg */ +function fail(msg) { + console.error(`✗ ${msg}`); + ok = false; +} + +/** @param {string} dir */ +function walkJsFiles(dir) { + /** @type {string[]} */ + const files = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) files.push(...walkJsFiles(full)); + else if (entry.endsWith(".js")) files.push(full); + } + return files; +} + +// --- sw.js was actually generated --------------------------------------- +const swPath = join(BUILD_DIR, "sw.js"); +let swSrc = ""; +try { + swSrc = readFileSync(swPath, "utf8"); +} catch { + fail(`${swPath} not found — did the PWA plugin run?`); + process.exit(1); +} + +// --- C1: some built client chunk actually registers it ------------------- +const appDir = join(BUILD_DIR, "_app"); +const jsFiles = walkJsFiles(appDir); +const registersSW = jsFiles.some((f) => { + const src = readFileSync(f, "utf8"); + return src.includes("serviceWorker") && src.includes("sw.js"); +}); +if (!registersSW) { + fail( + `No built JS chunk under ${appDir} registers the service worker ` + + '(expected a "serviceWorker" + "sw.js" reference reachable from the ' + + "virtual:pwa-register import in +layout.svelte).", + ); +} + +// --- H1: precached audio entries carry the resolved base path ----------- +const base = resolveBase(); +const audioUrls = [...swSrc.matchAll(/"([^"]*\/audio\/[^"]*\.mp3)"/g)].map( + (m) => m[1], +); +if (audioUrls.length === 0) { + fail(`${swPath} has no precached audio entries — expected the default voice's clips.`); +} else { + const badUrls = audioUrls.filter((u) => !u.startsWith(`${base}/audio/`)); + if (badUrls.length > 0) { + fail( + `${badUrls.length}/${audioUrls.length} precached audio URL(s) don't ` + + `start with "${base}/audio/" (resolved base "${base}") — e.g. "${badUrls[0]}". ` + + "additionalManifestEntries in vite.config.js and svelte.config.js's " + + "paths.base have drifted apart again.", + ); + } +} + +if (!ok) process.exit(1); +console.log( + `✓ PWA build check passed — base "${base}", ${jsFiles.length} client JS files, ` + + `${audioUrls.length} precached audio entries.`, +); diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..b450547 --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,17 @@ +/// + +// See https://svelte.dev/docs/kit/types#app.d.ts for what these interfaces +// are for. None are used yet — this file's real job right now is the +// triple-slash reference above, which supplies the `virtual:pwa-register` +// module types that `+layout.svelte` imports. +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html index a93df73..e1ea3ee 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -9,6 +9,44 @@ content="width=device-width, initial-scale=1, viewport-fit=cover" /> + + Lô tô — Hội chợ TN1 { - // Subscribe to both arrays so any mutation re-saves. + // Subscribe to both arrays so any mutation re-saves. Gated on + // `hydrated` so a mount can never persist stale in-memory state before + // `loadMaster()` has (re-)run — see `claimActiveTab()` in + // active-tab.svelte.js for the reclaim-a-frozen-tab case this guards. masterState.called; masterState.remaining; - saveMaster(); + if (masterState.hydrated) saveMaster(); }); // Map number -> 1-based draw order for fast Kinh! verification. @@ -150,6 +153,11 @@ } function handleDrawNext() { + // "Xổ số" is a real click — prime the shared