@korajs/cli 1.0.0-beta.12 → 1.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -107
- package/dist/bin.cjs +808 -340
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +588 -174
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-G2PBYMMA.js → chunk-7Y4J2GXP.js} +168 -153
- package/dist/chunk-7Y4J2GXP.js.map +1 -0
- package/dist/{chunk-Z7GCXMGW.js → chunk-TIKGLNER.js} +44 -9
- package/dist/chunk-TIKGLNER.js.map +1 -0
- package/dist/{chunk-4IKCHRJ6.js → chunk-TVIRMDER.js} +122 -112
- package/dist/chunk-TVIRMDER.js.map +1 -0
- package/dist/create.cjs +63 -28
- package/dist/create.cjs.map +1 -1
- package/dist/create.js +2 -2
- package/dist/index.cjs +41 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +8 -2
- package/dist/{studio-server-NIFKZI4F.js → studio-server-XLGZNGG5.js} +11 -2
- package/dist/studio-server-XLGZNGG5.js.map +1 -0
- package/dist/vite.cjs +351 -0
- package/dist/vite.cjs.map +1 -0
- package/dist/vite.d.cts +161 -0
- package/dist/vite.d.ts +161 -0
- package/dist/vite.js +321 -0
- package/dist/vite.js.map +1 -0
- package/package.json +18 -8
- package/templates/react-basic/AGENTS.md +5 -5
- package/templates/react-basic/vite.config.ts +4 -1
- package/templates/react-sync/AGENTS.md +5 -5
- package/templates/react-sync/server.ts +8 -0
- package/templates/react-sync/src/vite-env.d.ts +1 -0
- package/templates/react-sync/vite.config.ts +4 -1
- package/templates/react-tailwind/AGENTS.md +5 -5
- package/templates/react-tailwind/vite.config.ts +10 -1
- package/templates/react-tailwind-sync/AGENTS.md +9 -7
- package/templates/react-tailwind-sync/server.ts +8 -0
- package/templates/react-tailwind-sync/src/App.tsx +1 -1
- package/templates/react-tailwind-sync/src/kora.ts +32 -0
- package/templates/react-tailwind-sync/src/main.tsx +1 -23
- package/templates/react-tailwind-sync/src/modules/todos/todo.mutations.ts +4 -8
- package/templates/react-tailwind-sync/src/modules/todos/todo.queries.ts +2 -2
- package/templates/react-tailwind-sync/src/modules/todos/useTodos.ts +1 -1
- package/templates/react-tailwind-sync/src/vite-env.d.ts +1 -0
- package/templates/react-tailwind-sync/vite.config.ts +10 -1
- package/templates/svelte-basic/AGENTS.md +4 -4
- package/templates/svelte-basic/package.json.hbs +6 -0
- package/templates/svelte-basic/src/App.svelte +2 -1
- package/templates/svelte-basic/vite.config.ts +4 -1
- package/templates/svelte-sync/AGENTS.md +4 -4
- package/templates/svelte-sync/server.ts +8 -0
- package/templates/svelte-sync/src/App.svelte +2 -1
- package/templates/svelte-sync/vite.config.ts +4 -1
- package/templates/svelte-tailwind/AGENTS.md +4 -4
- package/templates/svelte-tailwind/package.json.hbs +7 -1
- package/templates/svelte-tailwind/src/App.svelte +2 -1
- package/templates/svelte-tailwind/vite.config.ts +10 -1
- package/templates/svelte-tailwind-sync/AGENTS.md +4 -4
- package/templates/svelte-tailwind-sync/package.json.hbs +1 -1
- package/templates/svelte-tailwind-sync/server.ts +8 -0
- package/templates/svelte-tailwind-sync/src/App.svelte +2 -1
- package/templates/svelte-tailwind-sync/vite.config.ts +10 -1
- package/templates/tauri-react/AGENTS.md +5 -5
- package/templates/tauri-react/package.json.hbs +1 -1
- package/templates/tauri-react/server.ts +8 -0
- package/templates/tauri-react/src/vite-env.d.ts +1 -0
- package/templates/vue-basic/AGENTS.md +4 -4
- package/templates/vue-basic/package.json.hbs +6 -0
- package/templates/vue-basic/vite.config.ts +4 -1
- package/templates/vue-sync/AGENTS.md +4 -4
- package/templates/vue-sync/server.ts +8 -0
- package/templates/vue-sync/vite.config.ts +4 -1
- package/templates/vue-tailwind/AGENTS.md +4 -4
- package/templates/vue-tailwind/package.json.hbs +6 -0
- package/templates/vue-tailwind/vite.config.ts +4 -1
- package/templates/vue-tailwind-sync/AGENTS.md +4 -4
- package/templates/vue-tailwind-sync/server.ts +8 -0
- package/templates/vue-tailwind-sync/vite.config.ts +4 -1
- package/dist/chunk-4IKCHRJ6.js.map +0 -1
- package/dist/chunk-G2PBYMMA.js.map +0 -1
- package/dist/chunk-Z7GCXMGW.js.map +0 -1
- package/dist/studio-server-NIFKZI4F.js.map +0 -1
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline app shell for Kora apps (NEW-DX-3).
|
|
3
|
+
*
|
|
4
|
+
* A Kora app's DATA works offline; this makes its INTERFACE open offline too. The plugin
|
|
5
|
+
* runs only in `vite build`:
|
|
6
|
+
* - After the bundle is written (and after the template's sqlite WASM copy step), it walks
|
|
7
|
+
* the output directory and writes `sw.js`: a small, dependency-free service worker that
|
|
8
|
+
* precaches the built shell (index.html, hashed JS/CSS/WASM, the worker, the OPFS proxy
|
|
9
|
+
* and public files) into a cache versioned by the content of every precached file.
|
|
10
|
+
* - It injects a registration script into `index.html` with a "new version available"
|
|
11
|
+
* flow: the new worker waits, the page shows a reload prompt (or dispatches
|
|
12
|
+
* `kora:update-available` for the app to show its own), and only an accepted update
|
|
13
|
+
* activates it and reloads, so old and new assets never mix in one page.
|
|
14
|
+
*
|
|
15
|
+
* In `vite dev` it injects a script that unregisters any Kora service worker left on the
|
|
16
|
+
* dev origin, so a stale shell can never shadow the dev server.
|
|
17
|
+
*
|
|
18
|
+
* Built in-house instead of `vite-plugin-pwa` (CLAUDE.md: justify every dependency): the
|
|
19
|
+
* whole worker is under 150 lines, needs no Workbox runtime, and its caching rules are the
|
|
20
|
+
* ones a Kora app specifically needs (never cache the sync endpoint or auth routes; never
|
|
21
|
+
* serve an unhashed file such as `assets/sqlite3.wasm` cache-first).
|
|
22
|
+
*/
|
|
23
|
+
interface KoraServiceWorkerOptions {
|
|
24
|
+
/**
|
|
25
|
+
* URL path prefixes the worker never handles (always straight to the network, never
|
|
26
|
+
* cached). Defaults to the sync endpoint, auth routes and server operational endpoints:
|
|
27
|
+
* `['/kora-sync', '/auth', '/__kora', '/health']`.
|
|
28
|
+
*/
|
|
29
|
+
bypass?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Show the built-in "new version available" banner. Defaults to true. Set false to show
|
|
32
|
+
* your own UI: listen for the `kora:update-available` window event and call
|
|
33
|
+
* `event.detail.update()` when the user accepts.
|
|
34
|
+
*/
|
|
35
|
+
updatePrompt?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* A navigation waits this long for the network before the cached shell is served
|
|
38
|
+
* (a captive portal or a stalled 2G link otherwise leaves a blank page). Defaults to 4000.
|
|
39
|
+
*/
|
|
40
|
+
navigationTimeoutMs?: number;
|
|
41
|
+
/** File name of the generated worker, relative to the output directory. Defaults to `sw.js`. */
|
|
42
|
+
fileName?: string;
|
|
43
|
+
}
|
|
44
|
+
/** The subset of Vite's resolved config this plugin reads. */
|
|
45
|
+
interface ResolvedViteConfig {
|
|
46
|
+
root: string;
|
|
47
|
+
base: string;
|
|
48
|
+
command: 'build' | 'serve';
|
|
49
|
+
build: {
|
|
50
|
+
outDir: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
interface HtmlTag {
|
|
54
|
+
tag: string;
|
|
55
|
+
attrs?: Record<string, string | boolean>;
|
|
56
|
+
children?: string;
|
|
57
|
+
injectTo?: 'head' | 'body' | 'head-prepend' | 'body-prepend';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A Vite plugin, typed structurally so `@korajs/cli` does not depend on `vite`.
|
|
61
|
+
*/
|
|
62
|
+
interface KoraVitePlugin {
|
|
63
|
+
name: string;
|
|
64
|
+
enforce?: 'pre' | 'post';
|
|
65
|
+
configResolved(config: ResolvedViteConfig): void;
|
|
66
|
+
transformIndexHtml(html: string): {
|
|
67
|
+
html: string;
|
|
68
|
+
tags: HtmlTag[];
|
|
69
|
+
};
|
|
70
|
+
closeBundle: {
|
|
71
|
+
order: 'post';
|
|
72
|
+
sequential: true;
|
|
73
|
+
handler(): void;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Default URL prefixes the service worker never touches. */
|
|
77
|
+
declare const DEFAULT_SW_BYPASS: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* `<meta name="kora-shell">`, injected into every built HTML page: the worker recognises a
|
|
80
|
+
* page of a Kora build by it (RT-109).
|
|
81
|
+
*/
|
|
82
|
+
declare const SW_SHELL_META = "kora-shell";
|
|
83
|
+
/** Cache-name prefix shared by every version, so `activate` can delete old versions. */
|
|
84
|
+
declare const SW_CACHE_PREFIX = "kora-shell-";
|
|
85
|
+
/**
|
|
86
|
+
* Vite plugin that gives a Kora app an offline app shell.
|
|
87
|
+
*
|
|
88
|
+
* @param options - Bypass prefixes, update prompt and navigation timeout
|
|
89
|
+
* @returns A Vite plugin (add it last in `plugins`)
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* import { koraServiceWorker } from '@korajs/cli/vite'
|
|
94
|
+
* export default defineConfig({ plugins: [react(), koraServiceWorker()] })
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
declare function koraServiceWorker(options?: KoraServiceWorkerOptions): KoraVitePlugin;
|
|
98
|
+
/**
|
|
99
|
+
* The files to precache from a build directory, and a version derived from their content.
|
|
100
|
+
*
|
|
101
|
+
* Skipped: the worker itself, source maps, pre-compressed siblings (`.br`, `.gz`), dot
|
|
102
|
+
* directories (`.vite/`), and an unhashed copy whose bytes equal a content-hashed file
|
|
103
|
+
* (the templates copy `sqlite3-<hash>.wasm` to `sqlite3.wasm`; precaching both would
|
|
104
|
+
* double a ~1 MB first download on the slow links Kora targets).
|
|
105
|
+
*
|
|
106
|
+
* @param outDir - Absolute build directory
|
|
107
|
+
* @param base - Vite `base` (URL prefix ending in `/`)
|
|
108
|
+
* @param swFileName - The worker's own file name, excluded from the list
|
|
109
|
+
* @returns Precache URLs (sorted) and a 16-hex-digit content version
|
|
110
|
+
*/
|
|
111
|
+
declare function collectPrecache(outDir: string, base: string, swFileName?: string): {
|
|
112
|
+
urls: string[];
|
|
113
|
+
version: string;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Vite/Rollup content-hash naming (`name-[hash].ext`): the last `-`/`.` token of the stem
|
|
117
|
+
* is 8+ `[A-Za-z0-9_]` characters with an uppercase letter or digit. The same rule the
|
|
118
|
+
* production server uses for `immutable` caching, so the two never disagree.
|
|
119
|
+
*/
|
|
120
|
+
declare function isHashedName(path: string): boolean;
|
|
121
|
+
interface WorkerSourceInput {
|
|
122
|
+
version: string;
|
|
123
|
+
precache: string[];
|
|
124
|
+
shellUrl: string;
|
|
125
|
+
bypass: string[];
|
|
126
|
+
navigationTimeoutMs: number;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Source of the generated service worker.
|
|
130
|
+
*
|
|
131
|
+
* - install: precache every shell file into `kora-shell-<version>` (bypassing the HTTP
|
|
132
|
+
* cache). It does NOT skip waiting: an update waits for the page to accept it.
|
|
133
|
+
* - activate: delete every other `kora-shell-*` cache, then claim clients.
|
|
134
|
+
* - fetch (same-origin GET only, bypass prefixes untouched). One build per active worker
|
|
135
|
+
* (RT-109): while this worker is active the page runs THIS worker's build, online and
|
|
136
|
+
* offline alike, until the user accepts an update (or every tab of the app closes and
|
|
137
|
+
* the waiting worker takes over at the next start):
|
|
138
|
+
* - navigations: network first (with a timeout). A page of a Kora build (it carries
|
|
139
|
+
* `<meta name="kora-shell">`) that is not byte-identical to one of this build's
|
|
140
|
+
* precached pages belongs to another build (a deploy happened): this build's copy of
|
|
141
|
+
* that page (or its shell) is served instead. Other documents (server-rendered pages,
|
|
142
|
+
* downloads) pass through. Offline or timed out: this build's copy or shell;
|
|
143
|
+
* - files this build precached: cache first (this build's bytes, the unhashed
|
|
144
|
+
* `sqlite3.wasm` and public files included, never a newer deploy's);
|
|
145
|
+
* - other content-hashed files: cache first (their bytes can never change);
|
|
146
|
+
* - everything else: network first, falling back to the cache.
|
|
147
|
+
*/
|
|
148
|
+
declare function serviceWorkerSource(input: WorkerSourceInput): string;
|
|
149
|
+
/**
|
|
150
|
+
* Registration and update flow, injected into the built `index.html`.
|
|
151
|
+
*
|
|
152
|
+
* @param input - Worker URL, scope and whether to show the built-in prompt
|
|
153
|
+
* @returns Module script source
|
|
154
|
+
*/
|
|
155
|
+
declare function registrationScript(input: {
|
|
156
|
+
swUrl: string;
|
|
157
|
+
scope: string;
|
|
158
|
+
updatePrompt: boolean;
|
|
159
|
+
}): string;
|
|
160
|
+
|
|
161
|
+
export { DEFAULT_SW_BYPASS, type KoraServiceWorkerOptions, type KoraVitePlugin, SW_CACHE_PREFIX, SW_SHELL_META, collectPrecache, isHashedName, koraServiceWorker, registrationScript, serviceWorkerSource };
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/vite/service-worker.ts
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
6
|
+
import { join, relative, resolve, sep } from "path";
|
|
7
|
+
var DEFAULT_SW_BYPASS = ["/kora-sync", "/auth", "/__kora", "/health"];
|
|
8
|
+
var SW_SHELL_META = "kora-shell";
|
|
9
|
+
var SW_CACHE_PREFIX = "kora-shell-";
|
|
10
|
+
function koraServiceWorker(options = {}) {
|
|
11
|
+
const fileName = options.fileName ?? "sw.js";
|
|
12
|
+
const bypass = options.bypass ?? [...DEFAULT_SW_BYPASS];
|
|
13
|
+
const updatePrompt = options.updatePrompt ?? true;
|
|
14
|
+
const navigationTimeoutMs = options.navigationTimeoutMs ?? 4e3;
|
|
15
|
+
let config = null;
|
|
16
|
+
return {
|
|
17
|
+
name: "kora-service-worker",
|
|
18
|
+
enforce: "post",
|
|
19
|
+
configResolved(resolved) {
|
|
20
|
+
config = resolved;
|
|
21
|
+
},
|
|
22
|
+
transformIndexHtml(html) {
|
|
23
|
+
if (!config) return { html, tags: [] };
|
|
24
|
+
const children = config.command === "build" ? registrationScript({
|
|
25
|
+
swUrl: `${config.base}${fileName}`,
|
|
26
|
+
scope: config.base,
|
|
27
|
+
updatePrompt
|
|
28
|
+
}) : DEV_UNREGISTER_SCRIPT;
|
|
29
|
+
const tags = [
|
|
30
|
+
{ tag: "script", attrs: { type: "module" }, children, injectTo: "body" }
|
|
31
|
+
];
|
|
32
|
+
if (config.command === "build") {
|
|
33
|
+
tags.push({
|
|
34
|
+
tag: "meta",
|
|
35
|
+
attrs: { name: SW_SHELL_META, content: "1" },
|
|
36
|
+
injectTo: "head-prepend"
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { html, tags };
|
|
40
|
+
},
|
|
41
|
+
closeBundle: {
|
|
42
|
+
order: "post",
|
|
43
|
+
sequential: true,
|
|
44
|
+
handler() {
|
|
45
|
+
if (!config || config.command !== "build") return;
|
|
46
|
+
const outDir = resolve(config.root, config.build.outDir);
|
|
47
|
+
if (!existsSync(join(outDir, "index.html"))) return;
|
|
48
|
+
const { urls, version } = collectPrecache(outDir, config.base, fileName);
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(outDir, fileName),
|
|
51
|
+
serviceWorkerSource({
|
|
52
|
+
version,
|
|
53
|
+
precache: urls,
|
|
54
|
+
shellUrl: `${config.base}index.html`,
|
|
55
|
+
bypass,
|
|
56
|
+
navigationTimeoutMs
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function collectPrecache(outDir, base, swFileName = "sw.js") {
|
|
64
|
+
const files = [];
|
|
65
|
+
const walk = (dir) => {
|
|
66
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
67
|
+
if (entry.name.startsWith(".")) continue;
|
|
68
|
+
const full = join(dir, entry.name);
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
walk(full);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!entry.isFile()) continue;
|
|
74
|
+
const rel = relative(outDir, full).split(sep).join("/");
|
|
75
|
+
if (rel === swFileName || /\.(map|br|gz)$/.test(rel)) continue;
|
|
76
|
+
files.push({
|
|
77
|
+
path: rel,
|
|
78
|
+
digest: createHash("sha256").update(readFileSync(full)).digest("hex")
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(outDir);
|
|
83
|
+
const hashedDigests = new Set(files.filter((f) => isHashedName(f.path)).map((f) => f.digest));
|
|
84
|
+
const kept = files.filter((f) => isHashedName(f.path) || !hashedDigests.has(f.digest)).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
85
|
+
const version = createHash("sha256");
|
|
86
|
+
for (const f of kept) version.update(`${f.path}\0${f.digest}
|
|
87
|
+
`);
|
|
88
|
+
return {
|
|
89
|
+
urls: kept.map((f) => `${base}${f.path}`),
|
|
90
|
+
version: version.digest("hex").slice(0, 16)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function isHashedName(path) {
|
|
94
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
95
|
+
const dot = name.lastIndexOf(".");
|
|
96
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
97
|
+
const match = /[.-]([A-Za-z0-9_]{8,})$/.exec(stem);
|
|
98
|
+
return match !== null && /[A-Z0-9]/.test(match[1]);
|
|
99
|
+
}
|
|
100
|
+
function serviceWorkerSource(input) {
|
|
101
|
+
return `// Generated by @korajs/cli koraServiceWorker(). Do not edit: rebuilt on every \`vite build\`.
|
|
102
|
+
const VERSION = ${JSON.stringify(input.version)}
|
|
103
|
+
const CACHE = ${JSON.stringify(SW_CACHE_PREFIX)} + VERSION
|
|
104
|
+
const PRECACHE = ${JSON.stringify(input.precache)}
|
|
105
|
+
const SHELL = ${JSON.stringify(input.shellUrl)}
|
|
106
|
+
const BYPASS = ${JSON.stringify(input.bypass)}
|
|
107
|
+
const NAVIGATION_TIMEOUT_MS = ${JSON.stringify(input.navigationTimeoutMs)}
|
|
108
|
+
const PRECACHED = new Set(PRECACHE)
|
|
109
|
+
const PAGES = PRECACHE.filter((url) => /.html?$/i.test(url))
|
|
110
|
+
const SHELL_MARKER = ${JSON.stringify(`name="${SW_SHELL_META}"`)}
|
|
111
|
+
|
|
112
|
+
self.addEventListener('install', (event) => {
|
|
113
|
+
event.waitUntil(
|
|
114
|
+
caches.open(CACHE).then((cache) =>
|
|
115
|
+
cache.addAll(PRECACHE.map((url) => new Request(url, { cache: 'reload' }))),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
self.addEventListener('activate', (event) => {
|
|
121
|
+
event.waitUntil(
|
|
122
|
+
caches
|
|
123
|
+
.keys()
|
|
124
|
+
.then((keys) =>
|
|
125
|
+
Promise.all(
|
|
126
|
+
keys
|
|
127
|
+
.filter((key) => key.startsWith(${JSON.stringify(SW_CACHE_PREFIX)}) && key !== CACHE)
|
|
128
|
+
.map((key) => caches.delete(key)),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
.then(() => self.clients.claim()),
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
self.addEventListener('message', (event) => {
|
|
136
|
+
if (event.data && event.data.type === 'KORA_SW_SKIP_WAITING') self.skipWaiting()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
function isHashed(pathname) {
|
|
140
|
+
const name = pathname.slice(pathname.lastIndexOf('/') + 1)
|
|
141
|
+
const match = /[.-]([A-Za-z0-9_]{8,})\\.[A-Za-z0-9]+$/.exec(name)
|
|
142
|
+
return match !== null && /[A-Z0-9]/.test(match[1])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fromCache(request) {
|
|
146
|
+
return caches.open(CACHE).then((cache) => cache.match(request, { ignoreSearch: true }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// This build's copy of the page a navigation asked for: the precached page of that path,
|
|
150
|
+
// else the app shell (client-side routes).
|
|
151
|
+
function ownPage(url) {
|
|
152
|
+
return caches.open(CACHE).then((cache) =>
|
|
153
|
+
(PAGES.includes(url.pathname) ? cache.match(url.pathname) : Promise.resolve(undefined)).then(
|
|
154
|
+
(page) => page || cache.match(SHELL),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A navigation the network answered: kept when it is not a Kora page or is one of THIS
|
|
160
|
+
// build's pages; a page of another build (a newer deploy) is replaced by this build's
|
|
161
|
+
// copy, so one active worker serves one build (RT-109).
|
|
162
|
+
async function sameBuildOrOwn(url, response) {
|
|
163
|
+
const type = (response.headers && response.headers.get('content-type')) || ''
|
|
164
|
+
if (!response.ok || !/html/i.test(type)) return response
|
|
165
|
+
const text = await response.clone().text()
|
|
166
|
+
if (!text.includes(SHELL_MARKER)) return response
|
|
167
|
+
const cache = await caches.open(CACHE)
|
|
168
|
+
for (const page of PAGES) {
|
|
169
|
+
const cached = await cache.match(page)
|
|
170
|
+
if (cached && (await cached.text()) === text) return response
|
|
171
|
+
}
|
|
172
|
+
return (await ownPage(url)) || response
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function networkWithTimeout(request, ms) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const timer = setTimeout(() => reject(new Error('timeout')), ms)
|
|
178
|
+
fetch(request).then(
|
|
179
|
+
(response) => {
|
|
180
|
+
clearTimeout(timer)
|
|
181
|
+
resolve(response)
|
|
182
|
+
},
|
|
183
|
+
(error) => {
|
|
184
|
+
clearTimeout(timer)
|
|
185
|
+
reject(error)
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
self.addEventListener('fetch', (event) => {
|
|
192
|
+
const request = event.request
|
|
193
|
+
if (request.method !== 'GET') return
|
|
194
|
+
const url = new URL(request.url)
|
|
195
|
+
if (url.origin !== self.location.origin) return
|
|
196
|
+
if (BYPASS.some((prefix) => url.pathname === prefix || url.pathname.startsWith(prefix + '/')))
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if (request.mode === 'navigate') {
|
|
200
|
+
event.respondWith(
|
|
201
|
+
networkWithTimeout(request, NAVIGATION_TIMEOUT_MS).then(
|
|
202
|
+
(response) => sameBuildOrOwn(url, response),
|
|
203
|
+
() => ownPage(url).then((page) => page || Response.error()),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (PRECACHED.has(url.pathname)) {
|
|
210
|
+
event.respondWith(fromCache(request).then((cached) => cached || fetch(request)))
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (isHashed(url.pathname)) {
|
|
215
|
+
event.respondWith(
|
|
216
|
+
fromCache(request).then(
|
|
217
|
+
(cached) =>
|
|
218
|
+
cached ||
|
|
219
|
+
fetch(request).then((response) => {
|
|
220
|
+
if (response.ok && response.type === 'basic') {
|
|
221
|
+
const copy = response.clone()
|
|
222
|
+
caches.open(CACHE).then((cache) => cache.put(request, copy))
|
|
223
|
+
}
|
|
224
|
+
return response
|
|
225
|
+
}),
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
event.respondWith(
|
|
232
|
+
fetch(request).catch(() =>
|
|
233
|
+
fromCache(request).then((cached) => cached || Response.error()),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
var DEV_UNREGISTER_SCRIPT = `if ('serviceWorker' in navigator) {
|
|
240
|
+
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
|
241
|
+
for (const registration of registrations) {
|
|
242
|
+
const script = registration.active || registration.waiting || registration.installing
|
|
243
|
+
if (script && /\\/sw\\.js$/.test(new URL(script.scriptURL).pathname)) registration.unregister()
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
caches.keys().then((keys) => keys.filter((k) => k.startsWith(${JSON.stringify(SW_CACHE_PREFIX)})).forEach((k) => caches.delete(k)))
|
|
247
|
+
}`;
|
|
248
|
+
function registrationScript(input) {
|
|
249
|
+
return `// Kora offline app shell: service worker registration and update flow.
|
|
250
|
+
if ('serviceWorker' in navigator && window.isSecureContext && /^https?:$/.test(location.protocol)) {
|
|
251
|
+
const swUrl = ${JSON.stringify(input.swUrl)}
|
|
252
|
+
const showPrompt = ${JSON.stringify(input.updatePrompt)}
|
|
253
|
+
let accepted = false
|
|
254
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
255
|
+
// Reload only for an update the user accepted, never for the first install's claim.
|
|
256
|
+
if (accepted) location.reload()
|
|
257
|
+
})
|
|
258
|
+
const offer = (worker) => {
|
|
259
|
+
// No controller means this is the first install: there is nothing to update.
|
|
260
|
+
if (!navigator.serviceWorker.controller) return
|
|
261
|
+
const update = () => {
|
|
262
|
+
accepted = true
|
|
263
|
+
worker.postMessage({ type: 'KORA_SW_SKIP_WAITING' })
|
|
264
|
+
}
|
|
265
|
+
const event = new CustomEvent('kora:update-available', { detail: { update }, cancelable: true })
|
|
266
|
+
const handled = !window.dispatchEvent(event)
|
|
267
|
+
if (showPrompt && !handled) renderPrompt(update)
|
|
268
|
+
}
|
|
269
|
+
const renderPrompt = (update) => {
|
|
270
|
+
if (document.getElementById('kora-update-prompt')) return
|
|
271
|
+
const bar = document.createElement('div')
|
|
272
|
+
bar.id = 'kora-update-prompt'
|
|
273
|
+
bar.setAttribute('role', 'status')
|
|
274
|
+
bar.style.cssText = 'position:fixed;left:16px;right:16px;bottom:16px;z-index:2147483647;display:flex;gap:12px;align-items:center;justify-content:space-between;max-width:480px;margin:0 auto;padding:12px 16px;border-radius:8px;background:#111;color:#fff;font:14px/1.4 system-ui,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.3)'
|
|
275
|
+
const text = document.createElement('span')
|
|
276
|
+
text.textContent = 'A new version is available.'
|
|
277
|
+
const button = document.createElement('button')
|
|
278
|
+
button.type = 'button'
|
|
279
|
+
button.textContent = 'Reload'
|
|
280
|
+
button.style.cssText = 'padding:6px 12px;border:0;border-radius:6px;background:#fff;color:#111;font:inherit;cursor:pointer'
|
|
281
|
+
button.addEventListener('click', () => {
|
|
282
|
+
button.disabled = true
|
|
283
|
+
update()
|
|
284
|
+
})
|
|
285
|
+
bar.append(text, button)
|
|
286
|
+
document.body.append(bar)
|
|
287
|
+
}
|
|
288
|
+
window.addEventListener('load', () => {
|
|
289
|
+
navigator.serviceWorker
|
|
290
|
+
.register(swUrl, { scope: ${JSON.stringify(input.scope)}, updateViaCache: 'none' })
|
|
291
|
+
.then((registration) => {
|
|
292
|
+
if (registration.waiting) offer(registration.waiting)
|
|
293
|
+
registration.addEventListener('updatefound', () => {
|
|
294
|
+
const worker = registration.installing
|
|
295
|
+
if (!worker) return
|
|
296
|
+
worker.addEventListener('statechange', () => {
|
|
297
|
+
if (worker.state === 'installed') offer(worker)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
// Long-lived tabs (kiosks, shared tablets) still learn about new deploys.
|
|
301
|
+
const check = () => registration.update().catch(() => {})
|
|
302
|
+
setInterval(check, 60 * 60 * 1000)
|
|
303
|
+
document.addEventListener('visibilitychange', () => {
|
|
304
|
+
if (document.visibilityState === 'visible') check()
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
.catch((error) => console.warn('[kora] service worker registration failed:', error))
|
|
308
|
+
})
|
|
309
|
+
}`;
|
|
310
|
+
}
|
|
311
|
+
export {
|
|
312
|
+
DEFAULT_SW_BYPASS,
|
|
313
|
+
SW_CACHE_PREFIX,
|
|
314
|
+
SW_SHELL_META,
|
|
315
|
+
collectPrecache,
|
|
316
|
+
isHashedName,
|
|
317
|
+
koraServiceWorker,
|
|
318
|
+
registrationScript,
|
|
319
|
+
serviceWorkerSource
|
|
320
|
+
};
|
|
321
|
+
//# sourceMappingURL=vite.js.map
|
package/dist/vite.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/vite/service-worker.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'\nimport { join, relative, resolve, sep } from 'node:path'\n\n/**\n * The offline app shell for Kora apps (NEW-DX-3).\n *\n * A Kora app's DATA works offline; this makes its INTERFACE open offline too. The plugin\n * runs only in `vite build`:\n * - After the bundle is written (and after the template's sqlite WASM copy step), it walks\n * the output directory and writes `sw.js`: a small, dependency-free service worker that\n * precaches the built shell (index.html, hashed JS/CSS/WASM, the worker, the OPFS proxy\n * and public files) into a cache versioned by the content of every precached file.\n * - It injects a registration script into `index.html` with a \"new version available\"\n * flow: the new worker waits, the page shows a reload prompt (or dispatches\n * `kora:update-available` for the app to show its own), and only an accepted update\n * activates it and reloads, so old and new assets never mix in one page.\n *\n * In `vite dev` it injects a script that unregisters any Kora service worker left on the\n * dev origin, so a stale shell can never shadow the dev server.\n *\n * Built in-house instead of `vite-plugin-pwa` (CLAUDE.md: justify every dependency): the\n * whole worker is under 150 lines, needs no Workbox runtime, and its caching rules are the\n * ones a Kora app specifically needs (never cache the sync endpoint or auth routes; never\n * serve an unhashed file such as `assets/sqlite3.wasm` cache-first).\n */\nexport interface KoraServiceWorkerOptions {\n\t/**\n\t * URL path prefixes the worker never handles (always straight to the network, never\n\t * cached). Defaults to the sync endpoint, auth routes and server operational endpoints:\n\t * `['/kora-sync', '/auth', '/__kora', '/health']`.\n\t */\n\tbypass?: string[]\n\t/**\n\t * Show the built-in \"new version available\" banner. Defaults to true. Set false to show\n\t * your own UI: listen for the `kora:update-available` window event and call\n\t * `event.detail.update()` when the user accepts.\n\t */\n\tupdatePrompt?: boolean\n\t/**\n\t * A navigation waits this long for the network before the cached shell is served\n\t * (a captive portal or a stalled 2G link otherwise leaves a blank page). Defaults to 4000.\n\t */\n\tnavigationTimeoutMs?: number\n\t/** File name of the generated worker, relative to the output directory. Defaults to `sw.js`. */\n\tfileName?: string\n}\n\n/** The subset of Vite's resolved config this plugin reads. */\ninterface ResolvedViteConfig {\n\troot: string\n\tbase: string\n\tcommand: 'build' | 'serve'\n\tbuild: { outDir: string }\n}\n\ninterface HtmlTag {\n\ttag: string\n\tattrs?: Record<string, string | boolean>\n\tchildren?: string\n\tinjectTo?: 'head' | 'body' | 'head-prepend' | 'body-prepend'\n}\n\n/**\n * A Vite plugin, typed structurally so `@korajs/cli` does not depend on `vite`.\n */\nexport interface KoraVitePlugin {\n\tname: string\n\tenforce?: 'pre' | 'post'\n\tconfigResolved(config: ResolvedViteConfig): void\n\ttransformIndexHtml(html: string): { html: string; tags: HtmlTag[] }\n\tcloseBundle: { order: 'post'; sequential: true; handler(): void }\n}\n\n/** Default URL prefixes the service worker never touches. */\nexport const DEFAULT_SW_BYPASS: readonly string[] = ['/kora-sync', '/auth', '/__kora', '/health']\n\n/**\n * `<meta name=\"kora-shell\">`, injected into every built HTML page: the worker recognises a\n * page of a Kora build by it (RT-109).\n */\nexport const SW_SHELL_META = 'kora-shell'\n\n/** Cache-name prefix shared by every version, so `activate` can delete old versions. */\nexport const SW_CACHE_PREFIX = 'kora-shell-'\n\n/**\n * Vite plugin that gives a Kora app an offline app shell.\n *\n * @param options - Bypass prefixes, update prompt and navigation timeout\n * @returns A Vite plugin (add it last in `plugins`)\n *\n * @example\n * ```typescript\n * import { koraServiceWorker } from '@korajs/cli/vite'\n * export default defineConfig({ plugins: [react(), koraServiceWorker()] })\n * ```\n */\nexport function koraServiceWorker(options: KoraServiceWorkerOptions = {}): KoraVitePlugin {\n\tconst fileName = options.fileName ?? 'sw.js'\n\tconst bypass = options.bypass ?? [...DEFAULT_SW_BYPASS]\n\tconst updatePrompt = options.updatePrompt ?? true\n\tconst navigationTimeoutMs = options.navigationTimeoutMs ?? 4000\n\tlet config: ResolvedViteConfig | null = null\n\n\treturn {\n\t\tname: 'kora-service-worker',\n\t\tenforce: 'post',\n\t\tconfigResolved(resolved) {\n\t\t\tconfig = resolved\n\t\t},\n\t\ttransformIndexHtml(html) {\n\t\t\tif (!config) return { html, tags: [] }\n\t\t\tconst children =\n\t\t\t\tconfig.command === 'build'\n\t\t\t\t\t? registrationScript({\n\t\t\t\t\t\t\tswUrl: `${config.base}${fileName}`,\n\t\t\t\t\t\t\tscope: config.base,\n\t\t\t\t\t\t\tupdatePrompt,\n\t\t\t\t\t\t})\n\t\t\t\t\t: DEV_UNREGISTER_SCRIPT\n\t\t\tconst tags: HtmlTag[] = [\n\t\t\t\t{ tag: 'script', attrs: { type: 'module' }, children, injectTo: 'body' },\n\t\t\t]\n\t\t\tif (config.command === 'build') {\n\t\t\t\t// Marks the document as a page of a Kora build: the worker serves its OWN\n\t\t\t\t// build's copy of such a page until the user accepts an update (RT-109).\n\t\t\t\ttags.push({\n\t\t\t\t\ttag: 'meta',\n\t\t\t\t\tattrs: { name: SW_SHELL_META, content: '1' },\n\t\t\t\t\tinjectTo: 'head-prepend',\n\t\t\t\t})\n\t\t\t}\n\t\t\treturn { html, tags }\n\t\t},\n\t\tcloseBundle: {\n\t\t\torder: 'post',\n\t\t\tsequential: true,\n\t\t\thandler() {\n\t\t\t\tif (!config || config.command !== 'build') return\n\t\t\t\tconst outDir = resolve(config.root, config.build.outDir)\n\t\t\t\tif (!existsSync(join(outDir, 'index.html'))) return\n\t\t\t\tconst { urls, version } = collectPrecache(outDir, config.base, fileName)\n\t\t\t\twriteFileSync(\n\t\t\t\t\tjoin(outDir, fileName),\n\t\t\t\t\tserviceWorkerSource({\n\t\t\t\t\t\tversion,\n\t\t\t\t\t\tprecache: urls,\n\t\t\t\t\t\tshellUrl: `${config.base}index.html`,\n\t\t\t\t\t\tbypass,\n\t\t\t\t\t\tnavigationTimeoutMs,\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t},\n\t\t},\n\t}\n}\n\n/**\n * The files to precache from a build directory, and a version derived from their content.\n *\n * Skipped: the worker itself, source maps, pre-compressed siblings (`.br`, `.gz`), dot\n * directories (`.vite/`), and an unhashed copy whose bytes equal a content-hashed file\n * (the templates copy `sqlite3-<hash>.wasm` to `sqlite3.wasm`; precaching both would\n * double a ~1 MB first download on the slow links Kora targets).\n *\n * @param outDir - Absolute build directory\n * @param base - Vite `base` (URL prefix ending in `/`)\n * @param swFileName - The worker's own file name, excluded from the list\n * @returns Precache URLs (sorted) and a 16-hex-digit content version\n */\nexport function collectPrecache(\n\toutDir: string,\n\tbase: string,\n\tswFileName = 'sw.js',\n): { urls: string[]; version: string } {\n\tconst files: { path: string; digest: string }[] = []\n\tconst walk = (dir: string): void => {\n\t\tfor (const entry of readdirSync(dir, { withFileTypes: true })) {\n\t\t\tif (entry.name.startsWith('.')) continue\n\t\t\tconst full = join(dir, entry.name)\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\twalk(full)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!entry.isFile()) continue\n\t\t\tconst rel = relative(outDir, full).split(sep).join('/')\n\t\t\tif (rel === swFileName || /\\.(map|br|gz)$/.test(rel)) continue\n\t\t\tfiles.push({\n\t\t\t\tpath: rel,\n\t\t\t\tdigest: createHash('sha256').update(readFileSync(full)).digest('hex'),\n\t\t\t})\n\t\t}\n\t}\n\twalk(outDir)\n\n\tconst hashedDigests = new Set(files.filter((f) => isHashedName(f.path)).map((f) => f.digest))\n\tconst kept = files\n\t\t.filter((f) => isHashedName(f.path) || !hashedDigests.has(f.digest))\n\t\t.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))\n\n\tconst version = createHash('sha256')\n\tfor (const f of kept) version.update(`${f.path}\\0${f.digest}\\n`)\n\treturn {\n\t\turls: kept.map((f) => `${base}${f.path}`),\n\t\tversion: version.digest('hex').slice(0, 16),\n\t}\n}\n\n/**\n * Vite/Rollup content-hash naming (`name-[hash].ext`): the last `-`/`.` token of the stem\n * is 8+ `[A-Za-z0-9_]` characters with an uppercase letter or digit. The same rule the\n * production server uses for `immutable` caching, so the two never disagree.\n */\nexport function isHashedName(path: string): boolean {\n\tconst name = path.slice(path.lastIndexOf('/') + 1)\n\tconst dot = name.lastIndexOf('.')\n\tconst stem = dot > 0 ? name.slice(0, dot) : name\n\tconst match = /[.-]([A-Za-z0-9_]{8,})$/.exec(stem)\n\treturn match !== null && /[A-Z0-9]/.test(match[1] as string)\n}\n\ninterface WorkerSourceInput {\n\tversion: string\n\tprecache: string[]\n\tshellUrl: string\n\tbypass: string[]\n\tnavigationTimeoutMs: number\n}\n\n/**\n * Source of the generated service worker.\n *\n * - install: precache every shell file into `kora-shell-<version>` (bypassing the HTTP\n * cache). It does NOT skip waiting: an update waits for the page to accept it.\n * - activate: delete every other `kora-shell-*` cache, then claim clients.\n * - fetch (same-origin GET only, bypass prefixes untouched). One build per active worker\n * (RT-109): while this worker is active the page runs THIS worker's build, online and\n * offline alike, until the user accepts an update (or every tab of the app closes and\n * the waiting worker takes over at the next start):\n * - navigations: network first (with a timeout). A page of a Kora build (it carries\n * `<meta name=\"kora-shell\">`) that is not byte-identical to one of this build's\n * precached pages belongs to another build (a deploy happened): this build's copy of\n * that page (or its shell) is served instead. Other documents (server-rendered pages,\n * downloads) pass through. Offline or timed out: this build's copy or shell;\n * - files this build precached: cache first (this build's bytes, the unhashed\n * `sqlite3.wasm` and public files included, never a newer deploy's);\n * - other content-hashed files: cache first (their bytes can never change);\n * - everything else: network first, falling back to the cache.\n */\nexport function serviceWorkerSource(input: WorkerSourceInput): string {\n\treturn `// Generated by @korajs/cli koraServiceWorker(). Do not edit: rebuilt on every \\`vite build\\`.\nconst VERSION = ${JSON.stringify(input.version)}\nconst CACHE = ${JSON.stringify(SW_CACHE_PREFIX)} + VERSION\nconst PRECACHE = ${JSON.stringify(input.precache)}\nconst SHELL = ${JSON.stringify(input.shellUrl)}\nconst BYPASS = ${JSON.stringify(input.bypass)}\nconst NAVIGATION_TIMEOUT_MS = ${JSON.stringify(input.navigationTimeoutMs)}\nconst PRECACHED = new Set(PRECACHE)\nconst PAGES = PRECACHE.filter((url) => /\\.html?$/i.test(url))\nconst SHELL_MARKER = ${JSON.stringify(`name=\"${SW_SHELL_META}\"`)}\n\nself.addEventListener('install', (event) => {\n\tevent.waitUntil(\n\t\tcaches.open(CACHE).then((cache) =>\n\t\t\tcache.addAll(PRECACHE.map((url) => new Request(url, { cache: 'reload' }))),\n\t\t),\n\t)\n})\n\nself.addEventListener('activate', (event) => {\n\tevent.waitUntil(\n\t\tcaches\n\t\t\t.keys()\n\t\t\t.then((keys) =>\n\t\t\t\tPromise.all(\n\t\t\t\t\tkeys\n\t\t\t\t\t\t.filter((key) => key.startsWith(${JSON.stringify(SW_CACHE_PREFIX)}) && key !== CACHE)\n\t\t\t\t\t\t.map((key) => caches.delete(key)),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.then(() => self.clients.claim()),\n\t)\n})\n\nself.addEventListener('message', (event) => {\n\tif (event.data && event.data.type === 'KORA_SW_SKIP_WAITING') self.skipWaiting()\n})\n\nfunction isHashed(pathname) {\n\tconst name = pathname.slice(pathname.lastIndexOf('/') + 1)\n\tconst match = /[.-]([A-Za-z0-9_]{8,})\\\\.[A-Za-z0-9]+$/.exec(name)\n\treturn match !== null && /[A-Z0-9]/.test(match[1])\n}\n\nfunction fromCache(request) {\n\treturn caches.open(CACHE).then((cache) => cache.match(request, { ignoreSearch: true }))\n}\n\n// This build's copy of the page a navigation asked for: the precached page of that path,\n// else the app shell (client-side routes).\nfunction ownPage(url) {\n\treturn caches.open(CACHE).then((cache) =>\n\t\t(PAGES.includes(url.pathname) ? cache.match(url.pathname) : Promise.resolve(undefined)).then(\n\t\t\t(page) => page || cache.match(SHELL),\n\t\t),\n\t)\n}\n\n// A navigation the network answered: kept when it is not a Kora page or is one of THIS\n// build's pages; a page of another build (a newer deploy) is replaced by this build's\n// copy, so one active worker serves one build (RT-109).\nasync function sameBuildOrOwn(url, response) {\n\tconst type = (response.headers && response.headers.get('content-type')) || ''\n\tif (!response.ok || !/html/i.test(type)) return response\n\tconst text = await response.clone().text()\n\tif (!text.includes(SHELL_MARKER)) return response\n\tconst cache = await caches.open(CACHE)\n\tfor (const page of PAGES) {\n\t\tconst cached = await cache.match(page)\n\t\tif (cached && (await cached.text()) === text) return response\n\t}\n\treturn (await ownPage(url)) || response\n}\n\nfunction networkWithTimeout(request, ms) {\n\treturn new Promise((resolve, reject) => {\n\t\tconst timer = setTimeout(() => reject(new Error('timeout')), ms)\n\t\tfetch(request).then(\n\t\t\t(response) => {\n\t\t\t\tclearTimeout(timer)\n\t\t\t\tresolve(response)\n\t\t\t},\n\t\t\t(error) => {\n\t\t\t\tclearTimeout(timer)\n\t\t\t\treject(error)\n\t\t\t},\n\t\t)\n\t})\n}\n\nself.addEventListener('fetch', (event) => {\n\tconst request = event.request\n\tif (request.method !== 'GET') return\n\tconst url = new URL(request.url)\n\tif (url.origin !== self.location.origin) return\n\tif (BYPASS.some((prefix) => url.pathname === prefix || url.pathname.startsWith(prefix + '/')))\n\t\treturn\n\n\tif (request.mode === 'navigate') {\n\t\tevent.respondWith(\n\t\t\tnetworkWithTimeout(request, NAVIGATION_TIMEOUT_MS).then(\n\t\t\t\t(response) => sameBuildOrOwn(url, response),\n\t\t\t\t() => ownPage(url).then((page) => page || Response.error()),\n\t\t\t),\n\t\t)\n\t\treturn\n\t}\n\n\tif (PRECACHED.has(url.pathname)) {\n\t\tevent.respondWith(fromCache(request).then((cached) => cached || fetch(request)))\n\t\treturn\n\t}\n\n\tif (isHashed(url.pathname)) {\n\t\tevent.respondWith(\n\t\t\tfromCache(request).then(\n\t\t\t\t(cached) =>\n\t\t\t\t\tcached ||\n\t\t\t\t\tfetch(request).then((response) => {\n\t\t\t\t\t\tif (response.ok && response.type === 'basic') {\n\t\t\t\t\t\t\tconst copy = response.clone()\n\t\t\t\t\t\t\tcaches.open(CACHE).then((cache) => cache.put(request, copy))\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn response\n\t\t\t\t\t}),\n\t\t\t),\n\t\t)\n\t\treturn\n\t}\n\n\tevent.respondWith(\n\t\tfetch(request).catch(() =>\n\t\t\tfromCache(request).then((cached) => cached || Response.error()),\n\t\t),\n\t)\n})\n`\n}\n\n/** Unregisters Kora service workers on the dev origin (dev must never be served a stale shell). */\nconst DEV_UNREGISTER_SCRIPT = `if ('serviceWorker' in navigator) {\n\tnavigator.serviceWorker.getRegistrations().then((registrations) => {\n\t\tfor (const registration of registrations) {\n\t\t\tconst script = registration.active || registration.waiting || registration.installing\n\t\t\tif (script && /\\\\/sw\\\\.js$/.test(new URL(script.scriptURL).pathname)) registration.unregister()\n\t\t}\n\t})\n\tcaches.keys().then((keys) => keys.filter((k) => k.startsWith(${JSON.stringify(SW_CACHE_PREFIX)})).forEach((k) => caches.delete(k)))\n}`\n\n/**\n * Registration and update flow, injected into the built `index.html`.\n *\n * @param input - Worker URL, scope and whether to show the built-in prompt\n * @returns Module script source\n */\nexport function registrationScript(input: {\n\tswUrl: string\n\tscope: string\n\tupdatePrompt: boolean\n}): string {\n\treturn `// Kora offline app shell: service worker registration and update flow.\nif ('serviceWorker' in navigator && window.isSecureContext && /^https?:$/.test(location.protocol)) {\n\tconst swUrl = ${JSON.stringify(input.swUrl)}\n\tconst showPrompt = ${JSON.stringify(input.updatePrompt)}\n\tlet accepted = false\n\tnavigator.serviceWorker.addEventListener('controllerchange', () => {\n\t\t// Reload only for an update the user accepted, never for the first install's claim.\n\t\tif (accepted) location.reload()\n\t})\n\tconst offer = (worker) => {\n\t\t// No controller means this is the first install: there is nothing to update.\n\t\tif (!navigator.serviceWorker.controller) return\n\t\tconst update = () => {\n\t\t\taccepted = true\n\t\t\tworker.postMessage({ type: 'KORA_SW_SKIP_WAITING' })\n\t\t}\n\t\tconst event = new CustomEvent('kora:update-available', { detail: { update }, cancelable: true })\n\t\tconst handled = !window.dispatchEvent(event)\n\t\tif (showPrompt && !handled) renderPrompt(update)\n\t}\n\tconst renderPrompt = (update) => {\n\t\tif (document.getElementById('kora-update-prompt')) return\n\t\tconst bar = document.createElement('div')\n\t\tbar.id = 'kora-update-prompt'\n\t\tbar.setAttribute('role', 'status')\n\t\tbar.style.cssText = 'position:fixed;left:16px;right:16px;bottom:16px;z-index:2147483647;display:flex;gap:12px;align-items:center;justify-content:space-between;max-width:480px;margin:0 auto;padding:12px 16px;border-radius:8px;background:#111;color:#fff;font:14px/1.4 system-ui,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.3)'\n\t\tconst text = document.createElement('span')\n\t\ttext.textContent = 'A new version is available.'\n\t\tconst button = document.createElement('button')\n\t\tbutton.type = 'button'\n\t\tbutton.textContent = 'Reload'\n\t\tbutton.style.cssText = 'padding:6px 12px;border:0;border-radius:6px;background:#fff;color:#111;font:inherit;cursor:pointer'\n\t\tbutton.addEventListener('click', () => {\n\t\t\tbutton.disabled = true\n\t\t\tupdate()\n\t\t})\n\t\tbar.append(text, button)\n\t\tdocument.body.append(bar)\n\t}\n\twindow.addEventListener('load', () => {\n\t\tnavigator.serviceWorker\n\t\t\t.register(swUrl, { scope: ${JSON.stringify(input.scope)}, updateViaCache: 'none' })\n\t\t\t.then((registration) => {\n\t\t\t\tif (registration.waiting) offer(registration.waiting)\n\t\t\t\tregistration.addEventListener('updatefound', () => {\n\t\t\t\t\tconst worker = registration.installing\n\t\t\t\t\tif (!worker) return\n\t\t\t\t\tworker.addEventListener('statechange', () => {\n\t\t\t\t\t\tif (worker.state === 'installed') offer(worker)\n\t\t\t\t\t})\n\t\t\t\t})\n\t\t\t\t// Long-lived tabs (kiosks, shared tablets) still learn about new deploys.\n\t\t\t\tconst check = () => registration.update().catch(() => {})\n\t\t\t\tsetInterval(check, 60 * 60 * 1000)\n\t\t\t\tdocument.addEventListener('visibilitychange', () => {\n\t\t\t\t\tif (document.visibilityState === 'visible') check()\n\t\t\t\t})\n\t\t\t})\n\t\t\t.catch((error) => console.warn('[kora] service worker registration failed:', error))\n\t})\n}`\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korajs/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.13",
|
|
4
4
|
"description": "Kora.js CLI tooling and project scaffolding",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
"default": "./dist/index.cjs"
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
|
+
"./vite": {
|
|
26
|
+
"import": {
|
|
27
|
+
"types": "./dist/vite.d.ts",
|
|
28
|
+
"default": "./dist/vite.js"
|
|
29
|
+
},
|
|
30
|
+
"require": {
|
|
31
|
+
"types": "./dist/vite.d.cts",
|
|
32
|
+
"default": "./dist/vite.cjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
25
35
|
"./create": {
|
|
26
36
|
"import": "./dist/create.js",
|
|
27
37
|
"require": "./dist/create.cjs"
|
|
@@ -38,7 +48,7 @@
|
|
|
38
48
|
"esbuild": "^0.28.0",
|
|
39
49
|
"validate-npm-package-name": "^7.0.2",
|
|
40
50
|
"ws": "^8.18.0",
|
|
41
|
-
"@korajs/core": "1.0.0-beta.
|
|
51
|
+
"@korajs/core": "1.0.0-beta.13"
|
|
42
52
|
},
|
|
43
53
|
"devDependencies": {
|
|
44
54
|
"@types/better-sqlite3": "^7.6.13",
|
|
@@ -49,12 +59,12 @@
|
|
|
49
59
|
"tsup": "^8.3.6",
|
|
50
60
|
"typescript": "^5.7.3",
|
|
51
61
|
"vitest": "^3.0.4",
|
|
52
|
-
"@korajs/
|
|
53
|
-
"@korajs/
|
|
54
|
-
"@korajs/
|
|
55
|
-
"@korajs/
|
|
56
|
-
"@korajs/
|
|
57
|
-
"korajs": "1.0.0-beta.
|
|
62
|
+
"@korajs/server": "1.0.0-beta.13",
|
|
63
|
+
"@korajs/merge": "1.0.0-beta.13",
|
|
64
|
+
"@korajs/store": "1.0.0-beta.13",
|
|
65
|
+
"@korajs/sync": "1.0.0-beta.13",
|
|
66
|
+
"@korajs/test": "1.0.0-beta.13",
|
|
67
|
+
"korajs": "1.0.0-beta.13"
|
|
58
68
|
},
|
|
59
69
|
"license": "MIT",
|
|
60
70
|
"scripts": {
|
|
@@ -12,7 +12,7 @@ This is a **Kora.js** application: an offline-first app where all data lives in
|
|
|
12
12
|
2. **Never fetch application data over HTTP.** Do not add REST or GraphQL calls for app data, and do not talk to the sync server directly. Read and write through Kora collections only; sync happens automatically in the background.
|
|
13
13
|
3. **Await readiness before direct collection access.** Outside the UI bindings, `await app.ready` before calling `app.<collection>` methods. The bindings handle this for you inside components.
|
|
14
14
|
4. **Offline must keep working.** Any feature you add must function with the network off. Never gate a write or a read on connectivity. If you find yourself checking `navigator.onLine` before a data operation, you are doing it wrong.
|
|
15
|
-
5. **Surface mutation errors.** Fire-and-forget mutate calls
|
|
15
|
+
5. **Surface mutation errors.** Fire-and-forget mutate calls put errors into the mutation state. Always render the mutation's `error` in the UI or handle the promise from the async variant. Silent failure is the worst failure.
|
|
16
16
|
6. **Do not touch `src/kora-worker.ts`.** It wires the SQLite WASM binary URL for both dev and production builds. Changing it breaks production builds in ways that only show up after deploy.
|
|
17
17
|
7. **Do not add a state management library for server or app data.** Kora's reactive queries are the store. Local UI state (form inputs, toggles) can use the framework's normal state tools.
|
|
18
18
|
|
|
@@ -52,12 +52,12 @@ export default defineSchema({
|
|
|
52
52
|
})
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
If you
|
|
55
|
+
If you change a collection's shape, increment `version` and add a `migrations` entry for it (`migrate().addField(...)`): each device migrates its local database on its next open. `npx kora migrate` generates the matching migration for server databases and a transform stub for clients still on the older version.
|
|
56
56
|
|
|
57
57
|
## Project conventions
|
|
58
58
|
|
|
59
59
|
- Feature code lives in `src/modules/<feature>/` with the pattern: `<feature>.schema.ts` (collection definition, imported into `src/schema.ts`), `<feature>.queries.ts` (query builders), `<feature>.mutations.ts` (mutation functions taking a collection accessor).
|
|
60
|
-
- Conflict handling is declarative. Concurrent edits merge automatically (last-write-wins per field,
|
|
60
|
+
- Conflict handling is declarative. Concurrent edits merge automatically (last-write-wins per field, arrays as multisets that keep every device's additions and removals, objects per top-level key). If a field needs domain-specific merging (counters, quantities), add a `resolve` function in the schema rather than writing sync logic.
|
|
61
61
|
- `kora.config.ts` controls the dev environment (ports, sync server, schema watcher).
|
|
62
62
|
|
|
63
63
|
## Commands
|
|
@@ -77,11 +77,11 @@ Sync is configured in `src/main.*` via `createApp({ sync: { url, authClient } })
|
|
|
77
77
|
import { useCollection, useMutation, useQuery, useSyncStatus } from '@korajs/react'
|
|
78
78
|
|
|
79
79
|
const todos = useCollection('todos')
|
|
80
|
-
const rows = useQuery(orderedTodos(todos)) // reactive
|
|
80
|
+
const rows = useQuery(orderedTodos(todos)) // reactive; [] on the very first render
|
|
81
81
|
const addTodo = useMutation((data) => todos.insert(data))
|
|
82
82
|
addTodo.mutate({ title: 'x' }) // fire-and-forget; errors land in addTodo.error
|
|
83
83
|
await addTodo.mutateAsync({ title: 'x' }) // throws on failure; use when you need the error
|
|
84
84
|
const status = useSyncStatus() // status.status, status.pendingOperations
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
Rules: always render `mutation.error` somewhere. The app must work under React StrictMode (the framework's hooks are StrictMode-safe; keep `<StrictMode>` in `src/main.tsx`). `useQuery` returns
|
|
87
|
+
Rules: always render `mutation.error` somewhere. The app must work under React StrictMode (the framework's hooks are StrictMode-safe; keep `<StrictMode>` in `src/main.tsx`). `useQuery` reads the local store: no network, no spinner. Its very first render returns `[]` before the local query has run; use `useQueryState` when you must tell "loading" from "no rows".
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
|
+
import { koraServiceWorker } from '@korajs/cli/vite'
|
|
3
4
|
import react from '@vitejs/plugin-react'
|
|
4
5
|
import type { Plugin } from 'vite'
|
|
5
6
|
import { defineConfig } from 'vite'
|
|
@@ -70,8 +71,10 @@ function sqliteWasmHotfix(): Plugin {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// koraServiceWorker() (build only, keep it last): precaches the built app shell so the
|
|
75
|
+
// app opens offline, with a "new version available" reload prompt. See @korajs/cli/vite.
|
|
73
76
|
export default defineConfig({
|
|
74
|
-
plugins: [react(), crossOriginIsolation(), sqliteWasmHotfix()],
|
|
77
|
+
plugins: [react(), crossOriginIsolation(), sqliteWasmHotfix(), koraServiceWorker()],
|
|
75
78
|
worker: {
|
|
76
79
|
format: 'es',
|
|
77
80
|
},
|