build(web): upgrade to SvelteKit 3 and adapter-static 4, move the PWA to vite-plugin-pwa

This commit is contained in:
tiennm99 committed 2026-10-12 11:44:09 +07:00
1 parent 28825b78e4
commit 124d65f800
22 files changed
+310 -217

No files matched your search

+9 -8
View File
@@ -71,6 +71,15 @@ hand if any is wanted in the closed test. See
### PWA install verification (manual, post-deploy)
**Priority after the SvelteKit 3 upgrade (2026-10-12).** Before the
upgrade, the Pages build's `sw.js` precached `/` and bound navigations to
`/`. Both point at the origin root, which returns 404 on Pages, so the
service worker most likely never installed there. The new build (plain
`vite-plugin-pwa`, replacing `@vite-pwa/sveltekit`, which breaks under
Kit 3) uses `/loto/`. On Pages, confirm that the service worker installs,
that the app works offline, and that the update banner appears after the
next deploy.
Needs production deploy on GitHub Pages + physical Android Chrome +
iOS Safari. No code; verification only.
@@ -153,14 +162,6 @@ iOS Safari. No code; verification only.
so every draw re-runs all of them. If profiling shows jank, the cheap
fix is a `MasterCell(num, order, isLast)` composable so only the two
changed cells recompose per draw.
- **`web/package.json` overrides** (re-checked 2026-10-12).
`cookie`: still needed — Kit 2.70.x still declares `cookie ^0.6.0`,
below the 0.7.0 fix. Kit 3.0.1 (now `latest`) declares `^2.0.1`, so
the override goes away with a Kit 3 migration (a major upgrade, not
done). `serialize-javascript`: dropped 2026-10-12 — redundant — the consumer
(`@rollup/plugin-terser`, via `workbox-build`) declares `^7.0.3`, which
already resolves to 7.1.x; the `^7.0.5` override only guards a lockfile
that pins 7.0.3/7.0.4.
- **Voice list growth.** If we add voices > 2 (esp. > 10), revisit
the precache strategy — currently we precache only the default
voice. The 7d runtime cache covers the rest, but cold-start cost
+33
View File
@@ -56,6 +56,8 @@ npm run dev # dev server at http://localhost:5173
| `npm run preview` | Preview production build locally |
| `npm test` | Run unit tests |
| `npm run lint` | ESLint check |
| `npm run check` | svelte-check type check (JSDoc) |
| `npm run verify:pwa` | Check the service worker in `build/` (after a build, same `BUILD_PROFILE`) |
---
@@ -97,6 +99,37 @@ scripts/
```
Game state lives entirely in Svelte 5 rune stores; no server round-trips.
### Build config
- **No `svelte.config.js`.** SvelteKit 3 takes its options (adapter-static,
`paths.base` from `base-path.js`) as arguments to the `sveltekit()` plugin
in `vite.config.js`.
- **`#lib/*` imports.** `src/lib` is reached through the `imports` field in
`package.json` (`import { settings } from "#lib/settings-store.svelte.js"`),
always with the file extension. `jsconfig.json` extends `$app/tsconfig`.
- **Base-path-safe URLs.** Static files go through `asset()` from
`$app/paths` (no leading `/`: `asset("audio/…")`), so they resolve under
both `""` and `/loto`.
### PWA
The service worker comes from
[vite-plugin-pwa](https://vite-pwa-org.netlify.app/) (`generateSW`,
`registerType: "prompt"`); the manifest is the hand-written
`static/manifest.webmanifest`. `+layout.svelte` registers it through
`virtual:pwa-register` and wires `onNeedRefresh` to the update banner in
`src/lib/update-prompt.svelte.js`. It precaches the app shell plus the default
voice; other voices are cached on first play.
`@vite-pwa/sveltekit` is not used: it generates `sw.js` when Vite's SSR build
closes, which assumed SvelteKit 2's build order. SvelteKit 3 builds SSR first,
then the client, then prerenders, all inside its `buildApp` hook, so
`vite.config.js` drops vite-plugin-pwa's own build step and calls its
`generateSW()` from a `buildApp` hook that runs after SvelteKit's and before
the adapter copies `.svelte-kit/output` into `build/`.
`npm run verify:pwa` checks the result after a build (run it with the same
`BUILD_PROFILE`).
Audio manifest is generated once at build prep time — the app reads a JSON manifest
on load and plays the appropriate clip for each drawn number.
+12 -7
View File
@@ -1,9 +1,9 @@
/**
* 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`),
* SvelteKit `paths.base` resolution, shared between the `sveltekit()`
* plugin options (page routing / prerendering) and the PWA precache
* manifest, both in `vite.config.js`. 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:
@@ -16,9 +16,14 @@
*/
import { loadEnv } from "vite";
/** @returns {string} */
/**
* SvelteKit validates the shape (leading `/`, no trailing `/`) when it loads
* its config, so a malformed NEXT_BASE_PATH fails the build there.
* @returns {"" | `/${string}`}
*/
export function resolveBase() {
if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH;
if (process.env.NEXT_BASE_PATH != null)
return /** @type {"" | `/${string}`} */ (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(
+6 -6
View File
@@ -127,19 +127,19 @@ function randomANumberInRow(weights) {
## Internal Navigation
- Use `import { base } from '$app/paths'` for internal links/asset URLs to preserve basePath across deployments.
- Example: `<img src="{base}/icon.svg">` resolves on both root (`""`) and subpath (`/loto`) builds.
- Use `asset()` (static files) and `resolve()` (routes) from `$app/paths` to preserve basePath across deployments. Their arguments don't start with `/` (route IDs passed to `resolve()` are the exception).
- Example: `<img src={asset("icons/icon-192.png")}>` resolves on both root (`""`) and subpath (`/loto`) builds.
## Import Organization
1. SvelteKit imports (`$app/paths`, `$lib/...`)
1. SvelteKit and app-library imports (`$app/paths`, `#lib/...`, always with the file extension)
2. Third-party imports
3. Local component/utility imports
```js
import { base } from "$app/paths";
import { generateGrid, isRowComplete } from "$lib/game-logic.js";
import PlayerBoard from "$lib/PlayerBoard.svelte";
import { asset } from "$app/paths";
import { generateGrid, isRowComplete } from "#lib/game-logic.js";
import PlayerBoard from "#lib/PlayerBoard.svelte";
```
## Component Patterns
+6 -7
View File
@@ -26,7 +26,7 @@
| `src/lib/active-tab.svelte.js` | Single-active-tab coordinator via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `activeTab.inactive = true`. `+layout.svelte` mounts `watchActiveTab()` on boot and renders a fullscreen overlay banner ("Phiên Lô tô đang chạy ở tab khác. Nhấn để tiếp tục tại đây.") when inactive — nhấn calls `claimActiveTab()` which broadcasts back, inactivating the other tab in turn. Soft coordination only (cooperating tabs); no-op in browsers without `BroadcastChannel` (legacy iOS Safari ≤15.4). Prevents double auto-call intervals, double localStorage writers, and overlapping audio across tabs. |
| `src/lib/player-auto-cross.js` | Pure `applyMasterCalls({grid, crossed, called, lastHandledIndex, manualUnticks, mode})`. Cursor-by-index dedup (vs the retired `at`-timestamp model), so any caller can pass `lastHandledIndex: 0` to replay master's full history (used by player regen + "Xoá đánh dấu" in both mode). Manual unticks suppress re-cross on replay. |
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `import { base } from "$app/paths"` for basePath safety. |
| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `<audio>` cache, cancel-then-play, token-based cancel ensures stale promises can't resume after a new event. Reads active voice from `settings.voice`; URLs go through `asset()` from `$app/paths` for basePath safety. |
| `src/lib/audio-manifest.js` | Re-exports `static/audio/manifest.json` as `VOICES` (array) + `VOICE_IDS` (Set) + `DEFAULT_VOICE`. Manifest is generated by `scripts/generate-audio.py`. |
| `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. Stores 9 keys: `theme` (enum: "auto" / "light" / "dark"), `mode` (enum: "player" / "master" / "both"; replaces legacy `masterMode`), `autoCallEnabled` (bool), `autoCallSpeed` (1–10), `emptyCellColor` (hex), `voiceEnabledMaster` (bool), `voiceEnabledPlayer` (bool), `voiceWaitingNumber` (bool), `voice` (string id matching audio manifest). Persisted to localStorage `loto_settings`. Pushes values to CSS vars and `<html class="dark">` on `:root`. Per-key validators preserve old data. One-shot migration: `masterMode: true` → `mode: "both"`. |
@@ -47,21 +47,20 @@
### Configuration & PWA
| File | Purpose |
|------|---------|
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env, SvelteKit PWA plugin config. |
| `vite.config.js` | Tailwind + SvelteKit + PWA plugins. codeserver HMR config (port, allowedHosts, hmr). |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch. |
| `vite.config.js` | Tailwind + SvelteKit + PWA plugins. SvelteKit options (adapter-static, dual basePath via `base-path.js`) go to the `sveltekit()` plugin; there is no `svelte.config.js`. vite-plugin-pwa generates `sw.js` from a `buildApp` hook. codeserver HMR config (port, allowedHosts, hmr). |
| `package.json` | SvelteKit 3, Svelte 5 (runes), Tailwind 4, Vite, vite-plugin-pwa. `imports` maps `#lib/*` to `src/lib/*`. Scripts: dev, dev:codeserver, build, build:gh, lint, check, verify:pwa, test, test:watch. |
| `.github/workflows/ci.yml` | Repo-root pipeline for push/PR. `test` (`npm test`) gates a two-profile `build` matrix; the artifacts feed `deploy-pages` (canonical, `https://tiennm99.github.io/loto/`), `deploy-firebase`, `preview-firebase`, and `android-debug`. |
| `.github/actions/setup-web/` | Composite action: Node 24 + `npm ci` in `web/`. Shared by `ci.yml` and `android-release.yml`. |
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals. |
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
| `jsconfig.json` | Extends `$app/tsconfig`; checkJs on for `src/` and `vite.config.js`. |
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
| `static/manifest.webmanifest` | PWA manifest: name, short_name, icons (192/512px), theme colors, display: standalone, scope, start_url. |
| `static/icons/{192,512}.png` | PWA icons (auto-generated by @vite-pwa/sveltekit from source). |
| `static/icons/{192,512}.png` | PWA icons, rendered from `static/icons/source*.svg` (see README). |
| `static/audio/{voiceId}/*.mp3` | Pre-generated Vietnamese voice clips (1–90 + cho + kinh per voice). Generated by `scripts/generate-audio.py`. SW caches at runtime (CacheFirst). |
| `static/audio/manifest.json` | Voice list `{ id, edgeName, label, gender }[]`. |
| `scripts/generate-audio.py` | One-shot Python script: generates 92 clips per voice, writes manifest. |
| Service Worker (auto) | @vite-pwa/sveltekit generates `/pwa-manifest.json`, `/sw.js`. App shell precache ~353 KB. Auto-update mode with reload toast. |
| Service Worker (auto) | vite-plugin-pwa generates `/sw.js` (the manifest is the hand-written `static/manifest.webmanifest`). Precaches the app shell and the default voice. Prompt mode with an update banner. |
## Key Data Structures
+5 -4
View File
@@ -7,11 +7,12 @@
| `npm run build` | `""` (root) | Local preview / generic static host |
| `npm run build:gh` | `/loto` | GitHub Pages → `https://tiennm99.github.io/loto` (canonical) |
Implementation: `svelte.config.js` reads `BUILD_PROFILE` env. Default is empty
Implementation: `base-path.js` reads `BUILD_PROFILE` env and `vite.config.js`
passes the result to the `sveltekit()` plugin's `paths.base`. Default is empty
basePath; `BUILD_PROFILE=gh npm run build` switches to `/loto`.
Internal links use `import { base } from '$app/paths'` so they survive
either profile without code changes.
Static-file URLs go through `asset()` and route links through `resolve()`
from `$app/paths`, so they survive either profile without code changes.
## Production Deployment — GitHub Pages
@@ -98,7 +99,7 @@ Generates:
- `.svelte-kit/` — Build cache (not needed for deployment)
### Export Settings
- `adapter-static` in `svelte.config.js`
- `adapter-static`, passed to the `sveltekit()` plugin in `vite.config.js`
- No server-side rendering (SSR disabled via `ssr: false`)
- All pages pre-rendered to HTML + JS bundles
+1 -1
View File
@@ -68,7 +68,7 @@ That format is intentionally out of scope.
## Tech Stack
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
- **Framework**: SvelteKit 3 with Svelte 5 (runes mode)
- **Runtime**: Svelte 5 runes ($state, $derived, $effect, $props)
- **Styling**: Tailwind CSS 4 (utility-first, animations)
- **Persistence**: localStorage (no backend)
+1 -1
View File
@@ -174,7 +174,7 @@ Each draw creates a new object (even repeat numbers) to ensure reactive re-fire.
**Static Storage**: All game state in localStorage. No API calls. Fully functional offline.
**Service Worker**: @vite-pwa/sveltekit auto-generates `/sw.js` with:
**Service Worker**: vite-plugin-pwa generates `/sw.js` (wired into the SvelteKit build in `vite.config.js`) with:
- App shell precache (~353 KB): HTML, CSS, JS bundles for instant load
- Audio runtime caching: Voice clips cached on-demand (CacheFirst strategy)
- Navigation: NetworkFirst (try network, fallback to cache)
+3 -2
View File
@@ -1,7 +1,8 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"extends": "$app/tsconfig",
"compilerOptions": {
"allowJs": true,
"checkJs": true
}
},
"include": ["src", "vite.config.js"]
}
+57 -106
View File
@@ -10,19 +10,19 @@
"devDependencies": {
"@eslint/js": "^10",
"@fontsource/roboto-condensed": "^5.3.0",
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/adapter-static": "^4",
"@sveltejs/kit": "^3",
"@sveltejs/vite-plugin-svelte": "^7",
"@tailwindcss/vite": "^4",
"@vite-pwa/sveltekit": "^1.1.0",
"eslint": "^10",
"eslint-plugin-svelte": "^3",
"globals": "^17",
"happy-dom": "20.11.0",
"svelte": "^5",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"tailwindcss": "^4",
"vite": "^8.1.5",
"vite-plugin-pwa": "^1",
"vitest": "^4.1.11"
},
"optionalDependencies": {
@@ -1879,9 +1879,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -2670,47 +2670,43 @@
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-4.0.0.tgz",
"integrity": "sha512-aF1J23R1ebKqUxQ4/qLr/Lxu63NFmLAtg8UZl3HFLxaoA1D/nhTVGcD9lTqx65iUbjNR2iOL0TgOxSX82x6Paw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
"@sveltejs/kit": "^3.0.0-next.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "2.70.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
"integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-3.0.1.tgz",
"integrity": "sha512-8JMCCq7ZUJELi3hbhb0+c9Rs8qnbHW90vhOfM+t1FSF4fhIpG9/FOsH4IYgcITMhbdkSrrn+Kvd9G+q8A+UO6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9",
"@types/cookie": "^0.6.0",
"acorn": "^8.16.0",
"cookie": "^0.6.0",
"devalue": "^5.8.1",
"@rolldown/pluginutils": "^1.0.1",
"@standard-schema/spec": "^1.1.0",
"cookie": "^2.0.1",
"devalue": "^5.9.4",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
"magic-string": "^1.2.3",
"mrmime": "^2.0.1",
"sirv": "^3.0.2"
},
"bin": {
"svelte-kit": "svelte-kit.js"
},
"engines": {
"node": ">=18.13"
"node": ">=22.17"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.57.1",
"typescript": "^6.0.0",
"vite": "^8.0.12"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
@@ -2721,6 +2717,16 @@
}
}
},
"node_modules/@sveltejs/kit/node_modules/magic-string": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@sveltejs/load-config": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
@@ -3060,13 +3066,6 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3136,33 +3135,6 @@
"@types/node": "*"
}
},
"node_modules/@vite-pwa/sveltekit": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@vite-pwa/sveltekit/-/sveltekit-1.1.0.tgz",
"integrity": "sha512-mMIf2tY+7Hg8jecpu/WY+Ki2ikoXy3hVmt3tOxi0K+lYYnKQrDYthuHireI0S+26Mg9BXzL7qQF1xeB5VYlYlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"kolorist": "^1.8.0",
"tinyglobby": "^0.2.9",
"vite-plugin-pwa": "^1.2.0"
},
"engines": {
"node": ">=18.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@sveltejs/kit": "^1.3.1 || ^2.0.1",
"@vite-pwa/assets-generator": "^1.0.0"
},
"peerDependenciesMeta": {
"@vite-pwa/assets-generator": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -3702,13 +3674,17 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=22"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-js-compat": {
@@ -4338,9 +4314,9 @@
}
},
"node_modules/esrap": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.4.tgz",
"integrity": "sha512-CHnJXZDfBeOAjLIGNy/0BEsmDu5+irHgUXRW9pGptxCbO3uyjJ98crilzFgiBqizW94UodjX3GAPLg/dhEnUfA==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz",
"integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5586,16 +5562,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/known-css-properties": {
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
@@ -5603,13 +5569,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true,
"license": "MIT"
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -6787,13 +6746,6 @@
"node": ">=20.0.0"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
"dev": true,
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -7183,24 +7135,23 @@
}
},
"node_modules/svelte": {
"version": "5.56.9",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz",
"integrity": "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==",
"version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
"integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"@jridgewell/remapping": "^2.3.5",
"@jridgewell/sourcemap-codec": "^1.6.0",
"@sveltejs/acorn-typescript": "^1.0.13",
"@types/estree": "^1.0.9",
"acorn": "^8.18.0",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.8.1",
"devalue": "^5.9.2",
"esm-env": "^1.2.1",
"esrap": "^2.2.12",
"esrap": "^2.3.6",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
+7 -7
View File
@@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
"imports": {
"#lib/*": "./src/lib/*"
},
"scripts": {
"dev": "vite dev",
"dev:codeserver": "VITE_DEV_PROFILE=codeserver vite dev --host 0.0.0.0",
@@ -19,25 +22,22 @@
"optionalDependencies": {
"lightningcss-linux-x64-gnu": "1.33.0"
},
"overrides": {
"cookie": "^0.7.2"
},
"devDependencies": {
"@eslint/js": "^10",
"@fontsource/roboto-condensed": "^5.3.0",
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/adapter-static": "^4",
"@sveltejs/kit": "^3",
"@sveltejs/vite-plugin-svelte": "^7",
"@tailwindcss/vite": "^4",
"@vite-pwa/sveltekit": "^1.1.0",
"eslint": "^10",
"eslint-plugin-svelte": "^3",
"globals": "^17",
"happy-dom": "20.11.0",
"svelte": "^5",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"tailwindcss": "^4",
"vite": "^8.1.5",
"vite-plugin-pwa": "^1",
"vitest": "^4.1.11"
}
}
+25 -10
View File
@@ -2,9 +2,8 @@
/**
* 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
* SvelteKit renders page HTML itself, so vite-plugin-pwa never injects 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
@@ -13,7 +12,10 @@
* 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).
* on that origin (this exact bug shipped once already). The same goes for
* the prerendered index page, which is also the navigation fallback: it is
* precached at the base URL only if `sw.js` was generated after SvelteKit
* prerendered (see `svelteKitPWA` in vite.config.js).
*
* 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
@@ -90,12 +92,13 @@ if (!registerKey) {
fail("No client chunk dynamically imports virtual:pwa-register.");
}
}
if (!jsFiles.some((f) => readFileSync(f, "utf8").includes("sw.js"))) {
fail(`No built JS under ${appDir} references "sw.js" — nothing registers it.`);
const base = resolveBase();
const swUrl = `${base}/sw.js`;
if (!jsFiles.some((f) => readFileSync(f, "utf8").includes(swUrl))) {
fail(`No built JS under ${appDir} references "${swUrl}" — nothing registers it.`);
}
// --- H1: precached audio entries carry the resolved base path -----------
const base = resolveBase();
// --- precached audio entries carry the resolved base path ---------------
const audioUrls = [...swSrc.matchAll(/"([^"]*\/audio\/[^"]*\.mp3)"/g)].map(
(m) => m[1],
);
@@ -107,12 +110,24 @@ if (audioUrls.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.",
"additionalManifestEntries and the sveltekit() paths.base in " +
"vite.config.js have drifted apart again.",
);
}
}
// --- the app shell is precached at the base URL and serves navigations ---
const indexUrl = `${base}/`;
if (!swSrc.includes(`{url:"${indexUrl}",revision:`)) {
fail(
`${swPath} doesn't precache the index page at "${indexUrl}" — the service ` +
"worker was generated before prerendering, or with the wrong base.",
);
}
if (!swSrc.includes(`createHandlerBoundToURL("${indexUrl}")`)) {
fail(`${swPath} doesn't route navigations to "${indexUrl}".`);
}
if (!ok) process.exit(1);
console.log(
`✓ PWA build check passed — base "${base}", ${jsFiles.length} client JS files, ` +
+6 -6
View File
@@ -32,17 +32,17 @@
</script>
<script>
import AutoCountdown from "$lib/AutoCountdown.svelte";
import AutoCountdown from "#lib/AutoCountdown.svelte";
import {
drawNext,
masterState,
saveMaster,
startNewGame,
} from "$lib/master-store.svelte.js";
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
import { settings } from "$lib/settings-store.svelte.js";
import { cancelPlayback, playNumber, unlockAudio } from "$lib/voice.js";
import { setWakeLock } from "$lib/wake-lock.js";
} from "#lib/master-store.svelte.js";
import MasterEmptyState from "#lib/MasterEmptyState.svelte";
import { settings } from "#lib/settings-store.svelte.js";
import { cancelPlayback, playNumber, unlockAudio } from "#lib/voice.js";
import { setWakeLock } from "#lib/wake-lock.js";
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
let autoRunning = $state(false);
+7 -7
View File
@@ -11,18 +11,18 @@
saveCrossedState,
saveGrid,
saveManualUnticks,
} from "$lib/game-logic.js";
import { focusTrap } from "$lib/focus-trap.js";
import { masterState } from "$lib/master-store.svelte.js";
import { pushOverlay } from "$lib/overlay-history.js";
import { applyMasterCalls } from "$lib/player-auto-cross.js";
import { settings } from "$lib/settings-store.svelte.js";
} from "#lib/game-logic.js";
import { focusTrap } from "#lib/focus-trap.js";
import { masterState } from "#lib/master-store.svelte.js";
import { pushOverlay } from "#lib/overlay-history.js";
import { applyMasterCalls } from "#lib/player-auto-cross.js";
import { settings } from "#lib/settings-store.svelte.js";
import {
cancelPlayback,
playBingo,
playWaiting,
unlockAudio,
} from "$lib/voice.js";
} from "#lib/voice.js";
const STORAGE_PREFIX = "loto";
const PREVIEW_CELLS = [...Array(27).keys()];
+5 -5
View File
@@ -1,14 +1,14 @@
<script>
import { VOICES } from "$lib/audio-manifest.js";
import { focusTrap } from "$lib/focus-trap.js";
import { pushOverlay } from "$lib/overlay-history.js";
import { VOICES } from "#lib/audio-manifest.js";
import { focusTrap } from "#lib/focus-trap.js";
import { pushOverlay } from "#lib/overlay-history.js";
import {
BOARD_TEXT_SCALES,
resetSettings,
saveSettings,
settings,
} from "$lib/settings-store.svelte.js";
import { clearAudioCache } from "$lib/voice.js";
} from "#lib/settings-store.svelte.js";
import { clearAudioCache } from "#lib/voice.js";
let open = $state(false);
+1 -1
View File
@@ -10,7 +10,7 @@
* @module lib/player-auto-cross
*/
import { findUncrossedCell } from "$lib/game-logic.js";
import { findUncrossedCell } from "#lib/game-logic.js";
/**
* @param {object} args
+1 -1
View File
@@ -6,7 +6,7 @@
* @module lib/settings-store
*/
import { DEFAULT_VOICE, VOICE_IDS } from "$lib/audio-manifest.js";
import { DEFAULT_VOICE, VOICE_IDS } from "#lib/audio-manifest.js";
const STORAGE_KEY = "loto_settings";
const HEX6 = /^#[0-9a-fA-F]{6}$/;
+6 -3
View File
@@ -4,8 +4,8 @@
* and are picked by `settings.voice`.
* @module lib/voice
*/
import { base } from "$app/paths";
import { settings } from "$lib/settings-store.svelte.js";
import { asset } from "$app/paths";
import { settings } from "#lib/settings-store.svelte.js";
/**
* Single reusable <audio> element, `.src` swapped per clip. iOS Safari
@@ -76,7 +76,10 @@ export function unlockAudio() {
function clipUrl(name) {
// `settings.voice` is allowlist-validated and `name` is internal-only,
// but encode anyway as defense-in-depth in case either ever escapes.
return `${base}/audio/${encodeURIComponent(settings.voice)}/${encodeURIComponent(name)}.mp3`;
// `asset()` is typed against the literal file list in /static, which a
// runtime-composed path can't match, hence the cast.
const file = `audio/${encodeURIComponent(settings.voice)}/${encodeURIComponent(name)}.mp3`;
return asset(/** @type {Parameters<typeof asset>[0]} */ (file));
}
/**
+8 -8
View File
@@ -1,20 +1,20 @@
<script>
import { onMount } from "svelte";
import "../app.css";
import { loadMaster } from "$lib/master-store.svelte.js";
import { loadSettings } from "$lib/settings-store.svelte.js";
import { loadMaster } from "#lib/master-store.svelte.js";
import { loadSettings } from "#lib/settings-store.svelte.js";
import {
activeTab,
claimActiveTab,
watchActiveTab,
} from "$lib/active-tab.svelte.js";
} from "#lib/active-tab.svelte.js";
import {
applyUpdate,
dismissUpdate,
setUpdateSW,
showUpdatePrompt,
updatePrompt,
} from "$lib/update-prompt.svelte.js";
} from "#lib/update-prompt.svelte.js";
let { children } = $props();
@@ -30,10 +30,10 @@
return watchActiveTab();
});
// `adapter-static` renders page HTML during `adapt()`, which runs AFTER
// the PWA plugin's `closeBundle` — so `@vite-pwa/sveltekit` never gets a
// chance to inject its own registration tag into the built HTML. We have
// to call the plugin's registration entry point ourselves. Dynamic import
// SvelteKit renders `src/app.html` itself, so vite-plugin-pwa never gets a
// chance to inject its own registration tag into the built HTML (it is
// configured with `injectRegister: false`). We have to call the plugin's
// registration entry point ourselves. Dynamic import
// (not a static one) so this module is never evaluated during SSR/
// prerendering — `virtual:pwa-register` reaches into `navigator` and
// `workbox-window`, both browser-only.
+5 -5
View File
@@ -1,10 +1,10 @@
<script>
import { slide } from "svelte/transition";
import MasterPanel from "$lib/MasterPanel.svelte";
import PageFooter from "$lib/PageFooter.svelte";
import PlayerBoard from "$lib/PlayerBoard.svelte";
import SettingsButton from "$lib/SettingsButton.svelte";
import { settings } from "$lib/settings-store.svelte.js";
import MasterPanel from "#lib/MasterPanel.svelte";
import PageFooter from "#lib/PageFooter.svelte";
import PlayerBoard from "#lib/PlayerBoard.svelte";
import SettingsButton from "#lib/SettingsButton.svelte";
import { settings } from "#lib/settings-store.svelte.js";
</script>
<div
-18
View File
@@ -1,18 +0,0 @@
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import { resolveBase } from "./base-path.js";
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: "build",
assets: "build",
fallback: undefined,
precompress: false,
strict: true,
}),
paths: { base: resolveBase() },
},
};
+106 -4
View File
@@ -1,8 +1,10 @@
import { readFileSync } from "node:fs";
import adapter from "@sveltejs/adapter-static";
import { sveltekit } from "@sveltejs/kit/vite";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
import { defineConfig, loadEnv } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { resolveBase } from "./base-path.js";
@@ -33,6 +35,69 @@ const defaultVoicePrecacheEntries = clipNames.map((n) => ({
revision: `audio-v1-${defaultVoiceId}-${n}`,
}));
// SvelteKit writes its build to `.svelte-kit/output` (`client/` for
// static files and JS, `prerendered/pages/` for the HTML), and the adapter
// copies both into `build/` as its last step.
const KIT_OUTPUT = ".svelte-kit/output";
/**
* Maps a path under KIT_OUTPUT to the URL the deployed app serves it at.
* Regular files become paths relative to `sw.js` (which sits at the app
* root, so Workbox resolves them under the base); the prerendered index
* page becomes the base URL itself, matching `navigateFallback`.
* @param {string} file
* @returns {string}
*/
function precacheUrl(file) {
if (file.startsWith("client/")) return file.slice("client/".length);
const page = file
.replace(/^prerendered\/pages\//, "")
.replace(/(^|\/)index\.html$/, "$1")
.replace(/\.html$/, "");
return page === "" ? `${base}/` : page;
}
/**
* vite-plugin-pwa wired into SvelteKit's build. The plugin's own build step
* generates `sw.js` when each Vite environment closes, but SvelteKit 3 builds
* SSR first, then the client, then prerenders, all inside its `buildApp`
* hook, so at those moments the app shell isn't on disk yet. Instead this
* drops that step and generates the service worker from a `buildApp` hook
* that runs after SvelteKit's (it must come after `sveltekit()` in the
* plugin list) and before the adapter's `post`-ordered one, which then
* copies `sw.js` into `build/` along with everything else.
*
* Also dropped, because SvelteKit renders `src/app.html` itself and warns
* about any plugin with a `transformIndexHtml` hook: `pwa-assets` (serves
* the unused `pwaAssets` icon generator; icons live in /static) and
* `dev-sw` (only acts when `devOptions.enabled` is true). Re-enabling
* either feature means keeping its plugin.
* @param {Partial<import("vite-plugin-pwa").VitePWAOptions>} options
* @returns {import("vite").Plugin[]}
*/
function svelteKitPWA(options) {
const plugins = VitePWA(options);
const main = plugins.find((p) => p.name === "vite-plugin-pwa");
const dropped = new Set([
"vite-plugin-pwa:build",
"vite-plugin-pwa:pwa-assets",
"vite-plugin-pwa:dev-sw",
]);
return [
...plugins.filter((p) => !dropped.has(p.name)),
{
name: "loto:pwa-generate-sw",
apply: "build",
async buildApp() {
/** @type {import("vite-plugin-pwa").VitePluginPWAAPI | undefined} */
const api = main?.api;
if (!api) throw new Error("vite-plugin-pwa API not found");
await api.generateSW();
},
},
];
}
export default defineConfig(({ mode }) => {
// .env.local lives outside process.env at config-eval time; loadEnv reads it.
const env = loadEnv(mode, process.cwd(), "");
@@ -43,8 +108,23 @@ export default defineConfig(({ mode }) => {
return {
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA({
sveltekit({
preprocess: vitePreprocess(),
adapter: adapter({
pages: "build",
assets: "build",
fallback: undefined,
precompress: false,
strict: true,
}),
paths: { base },
// Kit 3 polls `_app/version.json` hourly by default. The PWA's
// service worker already owns update detection (see the
// `registerType: "prompt"` note below), so keep polling off as it
// was under Kit 2.
version: { pollInterval: 0 },
}),
svelteKitPWA({
// "prompt" (not "autoUpdate"): a deploy must never force-reload a
// live tab mid-round — that would drop in-memory state
// (autoRunning, showCongrats, the auto-call countdown). The new SW
@@ -58,8 +138,30 @@ export default defineConfig(({ mode }) => {
// confusing.
strategies: "generateSW",
manifest: false,
// `+layout.svelte` imports `virtual:pwa-register` itself, so the
// plugin must not inject a registration script of its own.
injectRegister: false,
// Absolute SW URL and scope (`/loto/sw.js`, `/loto/`), so
// registration never depends on the page URL's trailing slash.
base: `${base}/`,
outDir: `${KIT_OUTPUT}/client`,
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,woff2,webmanifest}"],
globDirectory: KIT_OUTPUT,
// manifest.webmanifest is left out: the browser fetches it
// itself when installing, outside the service worker's cache.
globPatterns: [
"client/**/*.{js,css,html,svg,png,woff2}",
"prerendered/pages/**/*.html",
],
manifestTransforms: [
async (entries) => ({
manifest: entries.map((e) => ({ ...e, url: precacheUrl(e.url) })),
}),
],
// Content-hashed files need no revision. Tested against the glob
// path, before `manifestTransforms` strips the `client/` prefix.
dontCacheBustURLsMatching: /^client\/_app\/immutable\//,
navigateFallback: `${base}/`,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
// App shell + DEFAULT voice clips → guaranteed offline. Other
// voices fall through to the CacheFirst runtime rule below