@webjsdev/cli 0.10.8 → 0.10.10
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/bin/webjs.js +37 -1
- package/lib/check-json.js +47 -0
- package/lib/create.js +9 -0
- package/lib/mcp.js +408 -0
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +14 -0
- package/templates/.claude/hooks/require-tests-with-src.sh +35 -15
- package/templates/.claude.json +5 -0
- package/templates/.cursorrules +2 -1
- package/templates/.github/copilot-instructions.md +1 -1
- package/templates/AGENTS.md +171 -14
- package/templates/CONVENTIONS.md +39 -9
- package/templates/public/offline.html +34 -0
- package/templates/public/sw.js +106 -0
package/bin/webjs.js
CHANGED
|
@@ -41,7 +41,8 @@ const USAGE = `webjs commands:
|
|
|
41
41
|
webjs dev [--port 8080] Start dev server with live reload
|
|
42
42
|
webjs start [--port 8080] Start production server (serves source directly, no build step)
|
|
43
43
|
webjs test [--server|--browser] Run server + browser tests
|
|
44
|
-
webjs check
|
|
44
|
+
webjs check [--json] Run correctness checks on the app (--json emits structured violations)
|
|
45
|
+
webjs mcp Start the read-only MCP server (routes / actions / components / check)
|
|
45
46
|
webjs doctor Verify project health (Node, tsconfig, env, vendor pins, @webjsdev versions, git hook)
|
|
46
47
|
webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
|
|
47
48
|
webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
|
|
@@ -266,6 +267,18 @@ async function main() {
|
|
|
266
267
|
|
|
267
268
|
const violations = await checkConventions(process.cwd());
|
|
268
269
|
|
|
270
|
+
// --json emits the raw structured violations + a summary count as JSON,
|
|
271
|
+
// so an agent running `webjs check` in a loop consumes structured data
|
|
272
|
+
// instead of regex-scraping stdout. The shared projector keeps this byte-
|
|
273
|
+
// identical to the MCP `check` tool. The non-zero exit on violations is
|
|
274
|
+
// preserved (an agent gates on the exit code AND parses the report).
|
|
275
|
+
if (rest.includes('--json')) {
|
|
276
|
+
const { projectCheck } = await import('../lib/check-json.js');
|
|
277
|
+
console.log(JSON.stringify(projectCheck(violations)));
|
|
278
|
+
if (violations.length > 0) process.exit(1);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
269
282
|
if (violations.length === 0) {
|
|
270
283
|
console.log('webjs check: all checks pass ✓');
|
|
271
284
|
} else {
|
|
@@ -631,6 +644,29 @@ Full docs: https://docs.webjs.com`);
|
|
|
631
644
|
` --from PROVIDER CDN to resolve through. One of: ${[...SUPPORTED_PROVIDERS].join(', ')}. Default: jspm.`);
|
|
632
645
|
process.exit(1);
|
|
633
646
|
}
|
|
647
|
+
case 'mcp': {
|
|
648
|
+
// Read-only MCP server (#262) over stdio. STDOUT is the JSON-RPC channel,
|
|
649
|
+
// so nothing here may write to stdout: the data functions are read-only
|
|
650
|
+
// and `runMcpServer` routes all diagnostics to stderr. The CLI version is
|
|
651
|
+
// advertised in the initialize handshake's serverInfo.
|
|
652
|
+
const { readFileSync } = await import('node:fs');
|
|
653
|
+
let version = '0.0.0';
|
|
654
|
+
try {
|
|
655
|
+
const pkg = JSON.parse(
|
|
656
|
+
readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
|
|
657
|
+
);
|
|
658
|
+
version = pkg.version || version;
|
|
659
|
+
} catch {}
|
|
660
|
+
const { runMcpServer } = await import('../lib/mcp.js');
|
|
661
|
+
await runMcpServer({
|
|
662
|
+
stdin: process.stdin,
|
|
663
|
+
stdout: process.stdout,
|
|
664
|
+
stderr: process.stderr,
|
|
665
|
+
cwd: process.cwd(),
|
|
666
|
+
version,
|
|
667
|
+
});
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
634
670
|
case 'help':
|
|
635
671
|
case undefined:
|
|
636
672
|
console.log(USAGE);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON projector for `webjs check` violations (#262).
|
|
3
|
+
*
|
|
4
|
+
* `webjs check --json` and the `webjs mcp` server's `check` tool BOTH return
|
|
5
|
+
* the identical shape, so the projection lives here once. The input is the raw
|
|
6
|
+
* `Violation[]` from `checkConventions(appDir)` (each `{ rule, file, message,
|
|
7
|
+
* fix }`); the output adds a `summary` count plus a per-rule breakdown so an
|
|
8
|
+
* agent consuming the structured output never has to regex-scrape stdout.
|
|
9
|
+
*
|
|
10
|
+
* Pure and side-effect-free: it neither reads files nor prints. The caller owns
|
|
11
|
+
* running `checkConventions` and (for the CLI) the non-zero exit when there are
|
|
12
|
+
* violations.
|
|
13
|
+
*
|
|
14
|
+
* @module check-json
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{ rule: string, file: string, message: string, fix: string }} Violation
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{
|
|
23
|
+
* violations: Violation[],
|
|
24
|
+
* summary: { count: number, byRule: Record<string, number> },
|
|
25
|
+
* }} CheckReport
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Project a raw `Violation[]` into the structured `{ violations, summary }`
|
|
30
|
+
* report shared by `check --json` and the MCP `check` tool. `violations` is
|
|
31
|
+
* passed through verbatim (the `{ rule, file, message, fix }` shape), and
|
|
32
|
+
* `summary.byRule` tallies how many violations each rule produced.
|
|
33
|
+
*
|
|
34
|
+
* @param {Violation[]} violations
|
|
35
|
+
* @returns {CheckReport}
|
|
36
|
+
*/
|
|
37
|
+
export function projectCheck(violations) {
|
|
38
|
+
/** @type {Record<string, number>} */
|
|
39
|
+
const byRule = {};
|
|
40
|
+
for (const v of violations) {
|
|
41
|
+
byRule[v.rule] = (byRule[v.rule] || 0) + 1;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
violations,
|
|
45
|
+
summary: { count: violations.length, byRule },
|
|
46
|
+
};
|
|
47
|
+
}
|
package/lib/create.js
CHANGED
|
@@ -636,6 +636,15 @@ export type ActionResult<T> =
|
|
|
636
636
|
if (existsSync(tailwindSrc)) {
|
|
637
637
|
await cp(tailwindSrc, join(publicDir, 'tailwind-browser.js'));
|
|
638
638
|
}
|
|
639
|
+
// Progressive-enhancement service worker (#271): ship the opt-in offline
|
|
640
|
+
// primitive (the worker + its offline fallback) into the UI scaffolds
|
|
641
|
+
// (full-stack / saas; this block is api-excluded since api has no UI).
|
|
642
|
+
// Dormant until the app registers it (see agent-docs/service-worker.md);
|
|
643
|
+
// it never changes the JS-disabled baseline.
|
|
644
|
+
for (const swFile of ['sw.js', 'offline.html']) {
|
|
645
|
+
const swSrc = join(TEMPLATES, 'public', swFile);
|
|
646
|
+
if (existsSync(swSrc)) await cp(swSrc, join(publicDir, swFile));
|
|
647
|
+
}
|
|
639
648
|
|
|
640
649
|
const utilsDir = join(appDir, 'lib', 'utils');
|
|
641
650
|
await mkdir(utilsDir, { recursive: true });
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `webjs mcp`: a minimal, READ-ONLY Model Context Protocol server (#262).
|
|
3
|
+
*
|
|
4
|
+
* Exposes the live introspection surface an AI agent needs while editing a
|
|
5
|
+
* webjs app (the route table, registered server actions with their RPC
|
|
6
|
+
* endpoints, registered custom-element tags, and the structured `webjs check`
|
|
7
|
+
* violations) over MCP's stdio transport. Every tool REUSES an existing
|
|
8
|
+
* `@webjsdev/server` data function and MUTATES NOTHING. The prior art is
|
|
9
|
+
* Next.js's `next-devtools-mcp` (get_routes / get_server_action_by_id /
|
|
10
|
+
* get_errors); this is deliberately tiny.
|
|
11
|
+
*
|
|
12
|
+
* Transport: MCP stdio is newline-delimited JSON-RPC 2.0. One JSON object per
|
|
13
|
+
* line arrives on stdin; exactly one JSON response line per request is written
|
|
14
|
+
* to stdout. STDOUT IS THE PROTOCOL CHANNEL, so this module writes ONLY
|
|
15
|
+
* JSON-RPC frames there and routes every diagnostic to stderr. A malformed
|
|
16
|
+
* input line is answered with a JSON-RPC parse error and never crashes the
|
|
17
|
+
* loop. Hand-rolled with zero new dependency (webjs is buildless +
|
|
18
|
+
* minimal-deps).
|
|
19
|
+
*
|
|
20
|
+
* @module mcp
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createInterface } from 'node:readline';
|
|
24
|
+
import { relative } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The four read-only tools. Each takes an optional `{ appDir }` (default the
|
|
30
|
+
* server's cwd) and projects an EXISTING `@webjsdev/server` function's output
|
|
31
|
+
* into an agent-friendly shape. Descriptions are crisp so a model picks the
|
|
32
|
+
* right tool without reading source.
|
|
33
|
+
*/
|
|
34
|
+
const TOOL_DEFS = [
|
|
35
|
+
{
|
|
36
|
+
name: 'list_routes',
|
|
37
|
+
description:
|
|
38
|
+
'List the app route table: SSR pages (path, file, dynamic flag, param names) and route.{js,ts} API handlers (path, file, HTTP methods). Read-only.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'list_actions',
|
|
42
|
+
description:
|
|
43
|
+
'List registered server actions (the .server.{js,ts} files with "use server"): file, exported function name, and the /__webjs/action/<hash>/<fn> RPC endpoint. Read-only.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'list_components',
|
|
47
|
+
description:
|
|
48
|
+
'List registered custom-element tags: tag name, defining file, and class name. Read-only.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'check',
|
|
52
|
+
description:
|
|
53
|
+
'Run webjs check (correctness rules) and return the structured violations { rule, file, message, fix } plus a summary count and per-rule breakdown. Read-only.',
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** The shared input schema: every tool takes an optional appDir override. */
|
|
58
|
+
const TOOL_INPUT_SCHEMA = {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
appDir: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'App directory to introspect. Defaults to the server cwd.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: [],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Lexically extract the names exported from a module source. Recognises the
|
|
71
|
+
* common forms a server-action / route file uses without LOADING the module
|
|
72
|
+
* (loading would run its top-level side effects: Prisma init, DB connects).
|
|
73
|
+
*
|
|
74
|
+
* export async function foo() {} export function foo() {}
|
|
75
|
+
* export const foo = ... export let/var foo = ...
|
|
76
|
+
* export default ... (recorded as 'default')
|
|
77
|
+
* export { a, b as c } (the EXPORTED name, so `c`)
|
|
78
|
+
*
|
|
79
|
+
* @param {string} src
|
|
80
|
+
* @returns {string[]} unique export names, in source order
|
|
81
|
+
*/
|
|
82
|
+
export function extractExportNames(src) {
|
|
83
|
+
/** @type {string[]} */
|
|
84
|
+
const names = [];
|
|
85
|
+
const add = (n) => { if (n && !names.includes(n)) names.push(n); };
|
|
86
|
+
|
|
87
|
+
// export [async] function NAME / export const|let|var NAME / export class NAME
|
|
88
|
+
const declRe =
|
|
89
|
+
/\bexport\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
|
|
90
|
+
let m;
|
|
91
|
+
while ((m = declRe.exec(src)) !== null) add(m[1]);
|
|
92
|
+
|
|
93
|
+
// export default ...
|
|
94
|
+
if (/\bexport\s+default\b/.test(src)) add('default');
|
|
95
|
+
|
|
96
|
+
// export { a, b as c, default as d }
|
|
97
|
+
const namedRe = /\bexport\s*\{([^}]*)\}/g;
|
|
98
|
+
while ((m = namedRe.exec(src)) !== null) {
|
|
99
|
+
for (const part of m[1].split(',')) {
|
|
100
|
+
const seg = part.trim();
|
|
101
|
+
if (!seg) continue;
|
|
102
|
+
// `local as exported` -> the exported name is what callers import.
|
|
103
|
+
const asMatch = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(seg);
|
|
104
|
+
if (asMatch) add(asMatch[1]);
|
|
105
|
+
else {
|
|
106
|
+
const idMatch = /^([A-Za-z_$][\w$]*)$/.exec(seg);
|
|
107
|
+
if (idMatch) add(idMatch[1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return names;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Lexically extract the HTTP method exports of a route.{js,ts} file. The webjs
|
|
116
|
+
* API router (`api.js`) dispatches the five standard verbs, plus `WS` for a
|
|
117
|
+
* WebSocket upgrade. We report exactly those that are exported, NOT `HEAD` /
|
|
118
|
+
* `OPTIONS` (the router does not dispatch a named handler for them, so listing
|
|
119
|
+
* them would imply a route the framework ignores). Read-only: no module load.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} src
|
|
122
|
+
* @returns {string[]}
|
|
123
|
+
*/
|
|
124
|
+
export function extractRouteMethods(src) {
|
|
125
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'WS'];
|
|
126
|
+
const exported = new Set(extractExportNames(src));
|
|
127
|
+
return METHODS.filter((m) => exported.has(m));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`,
|
|
132
|
+
* the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the
|
|
133
|
+
* same normalization `buildRouteTable` uses for matching.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} routeDir POSIX-style, `.` for the app root.
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
function routePathFromDir(routeDir) {
|
|
139
|
+
if (!routeDir || routeDir === '.') return '/';
|
|
140
|
+
const segs = routeDir
|
|
141
|
+
.split('/')
|
|
142
|
+
.filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'));
|
|
143
|
+
return segs.length ? '/' + segs.join('/') : '/';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The tool runners. Each is async, takes `(appDir)`, and returns a plain
|
|
148
|
+
* JSON-serialisable projection of an existing server data function. All are
|
|
149
|
+
* read-only.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
|
|
152
|
+
*/
|
|
153
|
+
export function makeToolRunners(deps) {
|
|
154
|
+
const {
|
|
155
|
+
buildRouteTable,
|
|
156
|
+
buildActionIndex,
|
|
157
|
+
hashFile,
|
|
158
|
+
scanComponents,
|
|
159
|
+
checkConventions,
|
|
160
|
+
projectCheck,
|
|
161
|
+
readFile,
|
|
162
|
+
} = deps;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
async list_routes(appDir) {
|
|
166
|
+
const table = await buildRouteTable(appDir);
|
|
167
|
+
const pages = await Promise.all(
|
|
168
|
+
table.pages.map(async (r) => {
|
|
169
|
+
/** @type {{ path: string, file: string, dynamic?: boolean, params?: string[] }} */
|
|
170
|
+
const out = {
|
|
171
|
+
path: routePathFromDir(r.routeDir),
|
|
172
|
+
file: relative(appDir, r.file),
|
|
173
|
+
};
|
|
174
|
+
if (r.paramNames && r.paramNames.length) {
|
|
175
|
+
out.dynamic = true;
|
|
176
|
+
out.params = r.paramNames;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
const apis = await Promise.all(
|
|
182
|
+
table.apis.map(async (r) => {
|
|
183
|
+
let methods = [];
|
|
184
|
+
try {
|
|
185
|
+
methods = extractRouteMethods(await readFile(r.file, 'utf8'));
|
|
186
|
+
} catch {}
|
|
187
|
+
return {
|
|
188
|
+
path: routePathFromDir(r.routeDir),
|
|
189
|
+
file: relative(appDir, r.file),
|
|
190
|
+
methods,
|
|
191
|
+
};
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
return { pages, apis };
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async list_actions(appDir) {
|
|
198
|
+
// `skipExposeLoad` builds the file -> hash maps WITHOUT importing any
|
|
199
|
+
// `expose()` module, so this stays truly read-only (no Prisma/DB init, and
|
|
200
|
+
// no stray stdout from a loaded module corrupting the JSON-RPC channel).
|
|
201
|
+
// The RPC hash is over the file path only, so no module load is needed.
|
|
202
|
+
const idx = await buildActionIndex(appDir, false, { skipExposeLoad: true });
|
|
203
|
+
/** @type {Array<{ file: string, fn: string, endpoint: string }>} */
|
|
204
|
+
const actions = [];
|
|
205
|
+
for (const [file, hash] of idx.fileToHash) {
|
|
206
|
+
let names = [];
|
|
207
|
+
try {
|
|
208
|
+
names = extractExportNames(await readFile(file, 'utf8'));
|
|
209
|
+
} catch {}
|
|
210
|
+
for (const fn of names) {
|
|
211
|
+
actions.push({
|
|
212
|
+
file: relative(appDir, file),
|
|
213
|
+
fn,
|
|
214
|
+
endpoint: `/__webjs/action/${hash}/${fn}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Stable order for deterministic output.
|
|
219
|
+
actions.sort((a, b) =>
|
|
220
|
+
a.file === b.file ? a.fn.localeCompare(b.fn) : a.file.localeCompare(b.file),
|
|
221
|
+
);
|
|
222
|
+
return actions;
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async list_components(appDir) {
|
|
226
|
+
const comps = await scanComponents(appDir);
|
|
227
|
+
return comps
|
|
228
|
+
.map((c) => ({
|
|
229
|
+
tag: c.tag,
|
|
230
|
+
file: relative(appDir, c.file),
|
|
231
|
+
className: c.className,
|
|
232
|
+
}))
|
|
233
|
+
.sort((a, b) => a.tag.localeCompare(b.tag));
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async check(appDir) {
|
|
237
|
+
const violations = await checkConventions(appDir);
|
|
238
|
+
return projectCheck(violations);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A JSON-RPC 2.0 result frame.
|
|
245
|
+
* @param {string|number|null} id
|
|
246
|
+
* @param {any} result
|
|
247
|
+
*/
|
|
248
|
+
function rpcResult(id, result) {
|
|
249
|
+
return { jsonrpc: '2.0', id, result };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A JSON-RPC 2.0 error frame.
|
|
254
|
+
* @param {string|number|null} id
|
|
255
|
+
* @param {number} code
|
|
256
|
+
* @param {string} message
|
|
257
|
+
*/
|
|
258
|
+
function rpcError(id, code, message) {
|
|
259
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Run the read-only webjs MCP server over the given streams. Reads
|
|
264
|
+
* newline-delimited JSON-RPC from `stdin`, writes one response line per request
|
|
265
|
+
* to `stdout`, and logs diagnostics to `stderr` ONLY (stdout is the protocol
|
|
266
|
+
* channel). Resolves when stdin ends (clean shutdown).
|
|
267
|
+
*
|
|
268
|
+
* Injectable streams + `cwd` keep it testable in-process with PassThrough
|
|
269
|
+
* streams; the bin passes the real `process.std*` + `process.cwd()`.
|
|
270
|
+
*
|
|
271
|
+
* @param {{
|
|
272
|
+
* stdin: NodeJS.ReadableStream,
|
|
273
|
+
* stdout: NodeJS.WritableStream,
|
|
274
|
+
* stderr: NodeJS.WritableStream,
|
|
275
|
+
* cwd: string,
|
|
276
|
+
* version?: string,
|
|
277
|
+
* deps?: object,
|
|
278
|
+
* }} opts
|
|
279
|
+
* @returns {Promise<void>}
|
|
280
|
+
*/
|
|
281
|
+
export async function runMcpServer(opts) {
|
|
282
|
+
const { stdin, stdout, stderr, cwd } = opts;
|
|
283
|
+
const version = opts.version || '0.0.0';
|
|
284
|
+
|
|
285
|
+
// Resolve the data functions from @webjsdev/server (reading them is
|
|
286
|
+
// read-only). Injectable for tests so they need not boot the real server.
|
|
287
|
+
let deps = opts.deps;
|
|
288
|
+
if (!deps) {
|
|
289
|
+
const server = await import('@webjsdev/server');
|
|
290
|
+
const check = await import('@webjsdev/server/check');
|
|
291
|
+
const { readFile } = await import('node:fs/promises');
|
|
292
|
+
const { projectCheck } = await import('./check-json.js');
|
|
293
|
+
deps = {
|
|
294
|
+
buildRouteTable: server.buildRouteTable,
|
|
295
|
+
buildActionIndex: server.buildActionIndex,
|
|
296
|
+
hashFile: server.hashFile,
|
|
297
|
+
scanComponents: server.scanComponents,
|
|
298
|
+
checkConventions: check.checkConventions,
|
|
299
|
+
projectCheck,
|
|
300
|
+
readFile,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const runners = makeToolRunners(deps);
|
|
304
|
+
|
|
305
|
+
/** Write one JSON-RPC frame as a single line to stdout. */
|
|
306
|
+
const send = (frame) => {
|
|
307
|
+
stdout.write(JSON.stringify(frame) + '\n');
|
|
308
|
+
};
|
|
309
|
+
/** Diagnostics go to stderr only, never stdout. */
|
|
310
|
+
const logErr = (msg) => {
|
|
311
|
+
try { stderr.write(`[webjs mcp] ${msg}\n`); } catch {}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Dispatch one parsed JSON-RPC message. Returns a frame to send, or null
|
|
316
|
+
* for a notification (no `id`) which gets no response.
|
|
317
|
+
*/
|
|
318
|
+
const dispatch = async (msg) => {
|
|
319
|
+
const id = msg && Object.prototype.hasOwnProperty.call(msg, 'id') ? msg.id : null;
|
|
320
|
+
const isNotification = id === null || id === undefined;
|
|
321
|
+
const method = msg && msg.method;
|
|
322
|
+
|
|
323
|
+
if (method === 'initialize') {
|
|
324
|
+
return rpcResult(id, {
|
|
325
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
326
|
+
capabilities: { tools: {} },
|
|
327
|
+
serverInfo: { name: 'webjs', version },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// `notifications/initialized` (and any other notification) gets no reply.
|
|
332
|
+
if (isNotification) return null;
|
|
333
|
+
|
|
334
|
+
if (method === 'tools/list') {
|
|
335
|
+
return rpcResult(id, {
|
|
336
|
+
tools: TOOL_DEFS.map((t) => ({
|
|
337
|
+
name: t.name,
|
|
338
|
+
description: t.description,
|
|
339
|
+
inputSchema: TOOL_INPUT_SCHEMA,
|
|
340
|
+
})),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (method === 'tools/call') {
|
|
345
|
+
const params = (msg && msg.params) || {};
|
|
346
|
+
const name = params.name;
|
|
347
|
+
const args = params.arguments || {};
|
|
348
|
+
const runner = runners[name];
|
|
349
|
+
if (!runner) {
|
|
350
|
+
return rpcError(id, -32602, `Unknown tool: ${String(name)}`);
|
|
351
|
+
}
|
|
352
|
+
const appDir = typeof args.appDir === 'string' && args.appDir ? args.appDir : cwd;
|
|
353
|
+
try {
|
|
354
|
+
const result = await runner(appDir);
|
|
355
|
+
return rpcResult(id, {
|
|
356
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
357
|
+
});
|
|
358
|
+
} catch (e) {
|
|
359
|
+
// A tool failure is an MCP tool-result error (isError), not a transport
|
|
360
|
+
// error, so the agent sees the message in the content channel.
|
|
361
|
+
logErr(`tool ${name} failed: ${e && e.message ? e.message : e}`);
|
|
362
|
+
return rpcResult(id, {
|
|
363
|
+
isError: true,
|
|
364
|
+
content: [
|
|
365
|
+
{ type: 'text', text: `Error running ${name}: ${e && e.message ? e.message : String(e)}` },
|
|
366
|
+
],
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return rpcError(id, -32601, `Method not found: ${String(method)}`);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
|
|
375
|
+
|
|
376
|
+
await new Promise((resolveRun) => {
|
|
377
|
+
// Serialise line handling so responses preserve request order even though
|
|
378
|
+
// dispatch is async.
|
|
379
|
+
let chain = Promise.resolve();
|
|
380
|
+
rl.on('line', (line) => {
|
|
381
|
+
const trimmed = line.trim();
|
|
382
|
+
if (!trimmed) return;
|
|
383
|
+
chain = chain.then(async () => {
|
|
384
|
+
let msg;
|
|
385
|
+
try {
|
|
386
|
+
msg = JSON.parse(trimmed);
|
|
387
|
+
} catch {
|
|
388
|
+
// Malformed line: a JSON-RPC parse error, never a crash.
|
|
389
|
+
send(rpcError(null, -32700, 'Parse error'));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const frame = await dispatch(msg);
|
|
394
|
+
if (frame) send(frame);
|
|
395
|
+
} catch (e) {
|
|
396
|
+
logErr(`dispatch error: ${e && e.message ? e.message : e}`);
|
|
397
|
+
const id =
|
|
398
|
+
msg && Object.prototype.hasOwnProperty.call(msg, 'id') ? msg.id : null;
|
|
399
|
+
send(rpcError(id, -32603, 'Internal error'));
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
rl.on('close', () => {
|
|
404
|
+
// Drain the in-flight chain, then resolve (clean shutdown on stdin end).
|
|
405
|
+
chain.then(() => resolveRun()).catch(() => resolveRun());
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
package/package.json
CHANGED
|
@@ -114,6 +114,20 @@ self-review loop.
|
|
|
114
114
|
`static styles = css\`...\``) or third-party-embed isolation. `<slot>`
|
|
115
115
|
projection works identically in both modes (named slots, fallback content,
|
|
116
116
|
`assignedNodes` / `slotchange`, first-wins resolution).
|
|
117
|
+
- **Tailwind-first styling.** Tailwind utilities are the strong default for
|
|
118
|
+
pages AND light-DOM components: layout, spacing, color (via `@theme`
|
|
119
|
+
tokens), typography, borders, radius, shadows, interaction states. Light
|
|
120
|
+
DOM does not scope, so utilities apply directly. The lit reflex to scope
|
|
121
|
+
CSS (`static styles = css\`...\``) or write an inline `<style>` with
|
|
122
|
+
semantic class names (`.hero`, `.card`) in a light-DOM component is wrong:
|
|
123
|
+
the scoped block needs `static shadow = true`, and inline class names leak
|
|
124
|
+
globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper
|
|
125
|
+
returning an `` html`...` `` fragment, not a CSS class. Reserve raw CSS for
|
|
126
|
+
the allowlist (design tokens / `@theme`, `@property` + `@keyframes`,
|
|
127
|
+
`::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` /
|
|
128
|
+
gradients); when unavoidable in a light-DOM component, prefix every class
|
|
129
|
+
selector with the component tag. Shadow-DOM components legitimately use
|
|
130
|
+
`static styles = css\`...\`` for scoped CSS.
|
|
117
131
|
- Custom-element tag names are passed to `.register('tag-name')`. They are NOT
|
|
118
132
|
a static field on the class.
|
|
119
133
|
- One function per server action file (`*.server.ts`).
|
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# PreToolUse hook (scaffolded by `webjs create`):
|
|
3
|
+
# PreToolUse hook (scaffolded by `webjs create`): WARN on a `git commit`
|
|
4
4
|
# that adds or changes application code without any accompanying test.
|
|
5
5
|
#
|
|
6
|
-
# webjs is AI-first
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# webjs is AI-first, and "every change ships with a test" is the right
|
|
7
|
+
# default. But it is a CONVENTION, not a correctness check: a sensible
|
|
8
|
+
# app can legitimately want a test-less commit (a spike, a vendored
|
|
9
|
+
# file, a pure refactor). The convention-vs-check principle in this
|
|
10
|
+
# app's AGENTS.md and CONVENTIONS.md says guidance like this WARNS, it
|
|
11
|
+
# does not hard-block by default. So this hook surfaces a loud reminder
|
|
12
|
+
# and lets the commit proceed.
|
|
9
13
|
#
|
|
10
14
|
# What a hook CANNOT do: judge WHICH test layer a change needs (a unit
|
|
11
|
-
# test vs a browser/e2e test is a judgement call). So it
|
|
12
|
-
# floor (some real test
|
|
13
|
-
# browser/e2e coverage for interactive surfaces.
|
|
14
|
-
#
|
|
15
|
+
# test vs a browser/e2e test is a judgement call). So it nudges toward
|
|
16
|
+
# the floor (some real test should accompany app code) and reminds you
|
|
17
|
+
# to add browser/e2e coverage for interactive surfaces. The actual test
|
|
18
|
+
# suite runs in CI (.github/workflows/ci.yml), which is the real gate.
|
|
15
19
|
#
|
|
16
20
|
# Scope: fires only on `git commit`. Inspects the STAGED diff.
|
|
17
21
|
#
|
|
18
|
-
#
|
|
19
|
-
# components/, lib/) but stages no test (test/** or *.test.* / *.spec.*)
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
+
# Behavior when the staged diff changes app code (app/, modules/,
|
|
23
|
+
# components/, lib/) but stages no test (test/** or *.test.* / *.spec.*):
|
|
24
|
+
# - Default: WARN via additionalContext, then allow the commit (exit 0).
|
|
25
|
+
# - WEBJS_TEST_GATE=block: restore the old hard floor (print BLOCKED,
|
|
26
|
+
# exit 2), for a project that wants the strict gate. Set it in
|
|
27
|
+
# .claude/settings.json env, your shell, or CI.
|
|
28
|
+
# - WEBJS_NO_TEST_GATE=1: skip entirely (no warn, no block), for a
|
|
29
|
+
# genuine non-code commit (docs, config).
|
|
22
30
|
#
|
|
23
31
|
# Bypass (humans, emergencies): git commit --no-verify.
|
|
24
32
|
|
|
@@ -52,7 +60,9 @@ test_staged=$(printf '%s\n' "$staged" \
|
|
|
52
60
|
| grep -E '(^|/)test/|\.test\.[mc]?[jt]sx?$|\.spec\.[mc]?[jt]sx?$' || true)
|
|
53
61
|
|
|
54
62
|
if [ -z "$test_staged" ]; then
|
|
55
|
-
|
|
63
|
+
# Hard-mode opt-in: restore the old block when the project asks for it.
|
|
64
|
+
if [ "${WEBJS_TEST_GATE:-}" = "block" ] || [ "${WEBJS_TEST_GATE:-}" = "hard" ]; then
|
|
65
|
+
cat >&2 <<'EOF'
|
|
56
66
|
BLOCKED: this commit changes app code but stages no test.
|
|
57
67
|
|
|
58
68
|
You staged application code (app/, modules/, components/, lib/) with no
|
|
@@ -66,11 +76,21 @@ Pick the layer the change needs (a unit test is not always enough):
|
|
|
66
76
|
real behaviour in a browser, not just the function in isolation.
|
|
67
77
|
|
|
68
78
|
See `webjs test` and the testing guide. Genuine non-code commit (docs,
|
|
69
|
-
config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1.
|
|
79
|
+
config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1. Hard mode is
|
|
80
|
+
on because WEBJS_TEST_GATE=block is set; unset it to fall back to a warning.
|
|
70
81
|
|
|
71
82
|
Hook: .claude/hooks/require-tests-with-src.sh
|
|
72
83
|
EOF
|
|
73
|
-
|
|
84
|
+
exit 2
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Default: warn loudly via additionalContext, then allow the commit.
|
|
88
|
+
# A missing test for app code subsumes the interactive-component
|
|
89
|
+
# reminder, so emit this warning alone and skip that reminder below.
|
|
90
|
+
jq -n --arg ctx "Heads up: this commit stages app code (app/, modules/, components/, lib/) with no test. Every change should ship with a test (it is a convention, not a hard gate). Pick the layer the change needs: a unit test for logic/actions/queries/utils, and a browser or e2e test for a component, hydration, the client router, or a server action called from the client. The suite runs in CI regardless. To enforce a hard block locally, set WEBJS_TEST_GATE=block. To silence this for a genuine non-code commit, set WEBJS_NO_TEST_GATE=1." '{
|
|
91
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx }
|
|
92
|
+
}'
|
|
93
|
+
exit 0
|
|
74
94
|
fi
|
|
75
95
|
|
|
76
96
|
# Reminder for interactive surfaces: a unit test alone rarely covers them.
|
package/templates/.claude.json
CHANGED
package/templates/.cursorrules
CHANGED
|
@@ -103,7 +103,8 @@ self-review loop.
|
|
|
103
103
|
|
|
104
104
|
- No build step: source files are served as ES modules
|
|
105
105
|
- **Erasable TypeScript only.** Node 24+ strips types via `module.stripTypeScriptTypes` (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback.
|
|
106
|
-
-
|
|
106
|
+
- **Tailwind-first styling.** Tailwind utilities are the strong default for pages AND light-DOM components (the default DOM mode): layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, interaction states. Light DOM does not scope, so utilities apply directly. The lit reflex to scope CSS (`static styles = css`) or write an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component is wrong: the scoped block needs `static shadow = true`, and inline class names leak globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment, not a CSS class. Reserve raw CSS for the allowlist (design tokens / `@theme`, `@property` + `@keyframes`, `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` / gradients); when unavoidable in a light-DOM component, prefix every class selector with the component tag.
|
|
107
|
+
- Shadow-DOM components opt in with `static shadow = true` and use `static styles = css` for scoped CSS, not inline styles. That is the right home for scoped CSS.
|
|
107
108
|
- One function per server action file (*.server.ts)
|
|
108
109
|
- Components must call customElements.define('tag', Class)
|
|
109
110
|
- Server-only code (@prisma/client, node:*, anything that needs Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap the access in a .server.{js,ts} file; the framework rewrites that import into an RPC stub for the browser. lib/ holds both server-only infra (lib/prisma.server.ts) and browser-safe utilities (lib/utils/cn.ts with cn); follow the same rule per file: if a lib/ file needs Node APIs, only import it from server-only files.
|
|
@@ -100,7 +100,7 @@ each change must include.
|
|
|
100
100
|
build tools or bundlers in the critical path.
|
|
101
101
|
- **Erasable TypeScript only.** Node 24+ strips types via `module.stripTypeScriptTypes` (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback.
|
|
102
102
|
- Tagged template: html`<div>${value}</div>` with css`...` for styles.
|
|
103
|
-
|
|
103
|
+
- **Tailwind-first styling.** Tailwind utilities are the strong default for pages AND light-DOM components (the default DOM mode): layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, interaction states. Light DOM does not scope, so utilities apply directly. The lit reflex to scope CSS (`static styles = css\`...\``) or write an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component is wrong: the scoped block needs `static shadow = true`, and inline class names leak globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment, not a CSS class. Reserve raw CSS for the allowlist (design tokens / `@theme`, `@property` + `@keyframes`, `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` / gradients); when unavoidable in a light-DOM component, prefix every class selector with the component tag. Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`...\`` for scoped CSS; don't use inline `style="..."` there.
|
|
104
104
|
- Components: extend WebComponent, declare `static properties` (and `static styles` for shadow-DOM components), call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field.
|
|
105
105
|
- Server actions: *.server.ts files with one exported async function each.
|
|
106
106
|
- Server-only code (@prisma/client, node:*, anything needing Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap in a .server.{js,ts} file; the framework rewrites that import to an RPC stub for the browser. lib/ holds both server-only infra (lib/prisma.server.ts) and browser-safe utilities (lib/utils/cn.ts with cn); apply the same rule per file.
|
package/templates/AGENTS.md
CHANGED
|
@@ -618,7 +618,7 @@ Practical consequences for agents writing webjs code.
|
|
|
618
618
|
| Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
|
|
619
619
|
| Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | `declare student: Student` plus constructor default |
|
|
620
620
|
| `@property()` decorator | Banned by invariant 10 (erasable TS) | `static properties = { ... }` plus `declare` |
|
|
621
|
-
| `static styles = css` block without `static shadow = true
|
|
621
|
+
| Scoped `static styles = css` or an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component | Scoped block does nothing without `static shadow = true`; inline `<style>` class names leak globally | Tailwind utilities (the light-DOM default); or `static shadow = true` for genuinely scoped CSS |
|
|
622
622
|
| `willUpdate` computing SSR-visible derived state | Works (runs at SSR), but overriding it opts the component out of elision | Fine for interactive components; for display-only, derive inline in `render()` |
|
|
623
623
|
| `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or via a `static properties` + `declare` reactive prop |
|
|
624
624
|
| `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
|
|
@@ -627,6 +627,30 @@ The full annotated catalog with code examples lives in the framework
|
|
|
627
627
|
repo at
|
|
628
628
|
[`agent-docs/lit-muscle-memory-gotchas.md`](https://github.com/webjsdev/webjs/blob/main/agent-docs/lit-muscle-memory-gotchas.md).
|
|
629
629
|
|
|
630
|
+
### Styling: Tailwind-first (the most common lit reflex to unlearn)
|
|
631
|
+
|
|
632
|
+
**Tailwind utilities are the strong default for pages AND light-DOM
|
|
633
|
+
components (the default DOM mode).** Use them for layout, spacing, color
|
|
634
|
+
(via the `@theme` tokens), typography, borders, radius, shadows, and
|
|
635
|
+
interaction states (hover/focus/active/disabled, dark mode). Light DOM
|
|
636
|
+
does not scope styles, so utilities apply directly.
|
|
637
|
+
|
|
638
|
+
The lit habit is to scope CSS in a shadow root (`static styles =
|
|
639
|
+
css\`\``) or write an inline `<style>` with semantic class names
|
|
640
|
+
(`.hero`, `.card`). In a light-DOM webjs component the scoped block does
|
|
641
|
+
nothing without `static shadow = true`, and the inline class names leak
|
|
642
|
+
globally. Prefer Tailwind. When a utility bundle repeats, extract it into
|
|
643
|
+
a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment, not a
|
|
644
|
+
CSS class.
|
|
645
|
+
|
|
646
|
+
Reserve raw CSS for what utilities cannot express: design-token `:root` /
|
|
647
|
+
`@theme` definitions, `@property` + `@keyframes` animations,
|
|
648
|
+
`::-webkit-scrollbar`, `prefers-reduced-motion` blocks, and complex
|
|
649
|
+
`color-mix()` / gradient effects. When custom CSS is unavoidable in a
|
|
650
|
+
light-DOM component, prefix every class selector with the component tag
|
|
651
|
+
(invariant below). Shadow-DOM components (`static shadow = true`)
|
|
652
|
+
legitimately use `static styles = css\`\`` for scoped CSS.
|
|
653
|
+
|
|
630
654
|
## Server action pattern
|
|
631
655
|
|
|
632
656
|
```ts
|
|
@@ -741,12 +765,116 @@ return html`
|
|
|
741
765
|
|
|
742
766
|
The router's `closest('webjs-frame')` detection takes precedence over
|
|
743
767
|
layout markers. Only the frame's content swaps. Use this sparingly,
|
|
744
|
-
folder-based layouts handle 99% of cases.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
768
|
+
folder-based layouts handle 99% of cases.
|
|
769
|
+
|
|
770
|
+
**External targeting + `_top` (Turbo-style).** A trigger does not have to be
|
|
771
|
+
nested in the frame it drives. An `<a>` or `<form>` (or any ancestor)
|
|
772
|
+
carrying `data-webjs-frame="<id>"` drives the frame with that id from
|
|
773
|
+
anywhere (an external sidebar/nav link, a filter form), resolved via
|
|
774
|
+
`getElementById`. The reserved token `data-webjs-frame="_top"` on a trigger
|
|
775
|
+
INSIDE a frame breaks OUT to a full-page navigation. An id that does not
|
|
776
|
+
resolve to a live `<webjs-frame>` warns once and falls back to a normal nav
|
|
777
|
+
(never throws). With JS disabled a `data-webjs-frame` link is an inert
|
|
778
|
+
attribute on a plain `<a href>`, so the click is a normal full navigation.
|
|
779
|
+
|
|
780
|
+
**Busy state.** While a frame nav is in flight the router sets the native
|
|
781
|
+
`aria-busy="true"` on the frame (cleared to `"false"` on any exit: success,
|
|
782
|
+
error, abort, or a missing frame), so AT announces it and CSS can style
|
|
783
|
+
`webjs-frame[aria-busy="true"]`. It also dispatches a bubbling
|
|
784
|
+
`webjs:frame-busy` event on the frame at start and finish (detail
|
|
785
|
+
`{ frameId, busy }`).
|
|
786
|
+
|
|
787
|
+
**Self-loading (`src` + `loading`).** A frame can fetch its OWN content:
|
|
788
|
+
`<webjs-frame id="comments" src="/posts/42/comments" loading="lazy">` self-fetches
|
|
789
|
+
that URL as a frame nav and applies the matching `<webjs-frame id>` subtree into
|
|
790
|
+
itself, through the same frame-swap path (so the busy lifecycle + navigation-error
|
|
791
|
+
recovery + frame-missing fallback all apply). `loading="eager"` (or absent)
|
|
792
|
+
fetches on connect; `loading="lazy"` fetches on viewport entry. The request sends
|
|
793
|
+
the `x-webjs-frame` header, so the SERVER returns ONLY the matched subtree (not
|
|
794
|
+
the full page), falling back to the full page when the frame is absent. A `src` is
|
|
795
|
+
JS-DEPENDENT (the browser does not natively fetch a `<webjs-frame src>`), so with
|
|
796
|
+
JS off the frame shows only the children rendered into it; use it for DEFERRED
|
|
797
|
+
content (comments, a recommendations rail) where a no-JS placeholder is fine, and
|
|
798
|
+
render content server-side into the frame when it must exist without JS.
|
|
799
|
+
|
|
800
|
+
**View Transitions + persistent elements (opt-in).** Add
|
|
801
|
+
`<meta name="view-transition" content="same-origin">` to the page head and the
|
|
802
|
+
router wraps every swap (the layout-marker swap, the `<webjs-frame>` swap, and
|
|
803
|
+
the full-body fallback) in `document.startViewTransition` for an animated
|
|
804
|
+
crossfade. OFF by default (no animation surprise); a browser without the API
|
|
805
|
+
falls back to the identical synchronous swap. To keep a live element running
|
|
806
|
+
across a navigation (a playing `<audio>` / `<video>`, a map, a stateful
|
|
807
|
+
widget), mark it `data-webjs-permanent` AND give it an `id`: the router keeps
|
|
808
|
+
the SAME DOM node by identity across the swap instead of recreating it (Turbo's
|
|
809
|
+
permanent-element behaviour). Inert with JS off.
|
|
810
|
+
|
|
811
|
+
When a frame nav's response lacks the matching `<webjs-frame id>` (e.g. an
|
|
812
|
+
auth redirect), the router fires a cancelable, bubbling `webjs:frame-missing`
|
|
813
|
+
event (detail `{ frameId, url, document }`) and leaves the frame unchanged
|
|
814
|
+
rather than silently swapping the whole page; call `preventDefault()` to take
|
|
815
|
+
over the outcome (e.g. `location.assign(e.detail.url)`).
|
|
816
|
+
|
|
817
|
+
### 5. Stream actions for surgical element-level updates
|
|
818
|
+
|
|
819
|
+
When a region swap is too coarse (append ONE comment, remove ONE row, bump a
|
|
820
|
+
count, insert a toast), a server response can declare per-element actions as
|
|
821
|
+
plain HTML, a `<webjs-stream action target>` wrapping one `<template>`:
|
|
822
|
+
|
|
823
|
+
```html
|
|
824
|
+
<webjs-stream action="append" target="comments">
|
|
825
|
+
<template><li>Nice post!</li></template>
|
|
826
|
+
</webjs-stream>
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
Actions (Turbo's set): `append` / `prepend` (last / first child of the target
|
|
830
|
+
id), `before` / `after` (sibling), `replace` (the target element), `update`
|
|
831
|
+
(its children), `remove` (delete it). The `<webjs-stream>` element self-applies
|
|
832
|
+
on connect and removes itself. ONE applier serves two paths:
|
|
833
|
+
|
|
834
|
+
- **A content-negotiated `<form>`.** The router adds `Accept:
|
|
835
|
+
text/vnd.webjs-stream.html` on a JS-driven submission, so the server returns a
|
|
836
|
+
stream only then (apply it surgically) and a JS-OFF form gets a normal
|
|
837
|
+
render/redirect. Additive and progressive-enhancement-safe.
|
|
838
|
+
- **A live channel.** `renderStream(message)` from a `connectWS` handler applies
|
|
839
|
+
a `broadcast()`ed payload, so chat / notifications reuse the same applier.
|
|
840
|
+
|
|
841
|
+
Build the payload server-side and apply it client-side:
|
|
842
|
+
|
|
843
|
+
```ts
|
|
844
|
+
// app/posts/[id]/route.ts
|
|
845
|
+
import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
|
|
846
|
+
export async function POST(req: Request, { params }) {
|
|
847
|
+
const c = await addComment(params.id, await req.formData());
|
|
848
|
+
const html = stream.append('comments', `<li>${escapeHtml(c.text)}</li>`);
|
|
849
|
+
broadcast(`post:${params.id}`, html); // fan out to other viewers
|
|
850
|
+
if (acceptsStream(req)) return streamResponse(html); // JS client: surgical
|
|
851
|
+
return Response.redirect(`/posts/${params.id}`, 303); // no-JS: normal render
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
```ts
|
|
856
|
+
// a component, for the live channel
|
|
857
|
+
import { connectWS, renderStream } from '@webjsdev/core';
|
|
858
|
+
connectWS(`/posts/${id}/feed`, { onMessage: (m) => renderStream(m) });
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
`stream.*` escapes the target id but NOT the content (server-authored HTML, like
|
|
862
|
+
an `html` hole, so escape any user substring yourself). `renderStream` is
|
|
863
|
+
auto-registered by the client router.
|
|
864
|
+
|
|
865
|
+
**Failed navigations recover in place, never a destructive full reload.** A
|
|
866
|
+
successful swap and an HTML error body of any status (e.g. a `422` re-rendered
|
|
867
|
+
form) both apply in place. For the remaining failure cases (a non-HTML error
|
|
868
|
+
response like a `500` with a JSON body, or a transport/parse failure) the
|
|
869
|
+
router fires a cancelable, bubbling `webjs:navigation-error` event on
|
|
870
|
+
`document` (detail `{ url, status, error }`, where `status` is the HTTP status
|
|
871
|
+
or `null`, and `error` is the `Error` or `null`). `preventDefault()` hands
|
|
872
|
+
recovery to you and leaves the page exactly as it is (shell, scroll, focus,
|
|
873
|
+
client state preserved); otherwise the router renders a minimal in-place
|
|
874
|
+
`<div role="alert">` into the deepest layout children slot (outer chrome
|
|
875
|
+
preserved), only hard-loading as a last resort when there is no shared layout
|
|
876
|
+
marker. An AbortError (a superseding nav) is a normal supersede and never fires
|
|
877
|
+
the event.
|
|
750
878
|
|
|
751
879
|
### 5. `loading.ts` for per-segment skeletons
|
|
752
880
|
|
|
@@ -776,6 +904,32 @@ default export, scoped to that boundary (outer layouts stay alive).
|
|
|
776
904
|
|
|
777
905
|
Full reference: see the [Client Router docs](https://docs.webjs.dev/docs/client-router) and the framework AGENTS.md "Client navigation" section.
|
|
778
906
|
|
|
907
|
+
## Offline support (opt-in service worker)
|
|
908
|
+
|
|
909
|
+
The UI scaffolds (full-stack and saas) ship a progressive-enhancement service
|
|
910
|
+
worker at `public/sw.js` plus a `public/offline.html` fallback (the api template
|
|
911
|
+
has no UI, so it omits them). They are **dormant until you register them**, so
|
|
912
|
+
the JS-disabled baseline is unchanged. To enable offline support, add the opt-in
|
|
913
|
+
registration snippet to the root layout `<head>`:
|
|
914
|
+
|
|
915
|
+
```html
|
|
916
|
+
<script>
|
|
917
|
+
if ('serviceWorker' in navigator) {
|
|
918
|
+
addEventListener('load', () => {
|
|
919
|
+
const tag = document.querySelector('script[type="importmap"]');
|
|
920
|
+
const build = (tag && tag.dataset.webjsBuild) || '';
|
|
921
|
+
navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
</script>
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
Navigations become network-first (fresh server HTML, with an offline fallback to
|
|
928
|
+
a cached page or `/offline.html`); same-origin assets are stale-while-revalidate.
|
|
929
|
+
The cache version ties to the deploy via the `?v=<build>` id, so a new deploy
|
|
930
|
+
evicts the old cache automatically. `sw.js` is YOUR file, so edit the strategy as
|
|
931
|
+
needed. Full reference: `agent-docs/service-worker.md`.
|
|
932
|
+
|
|
779
933
|
## Metadata (per-page)
|
|
780
934
|
|
|
781
935
|
The `metadata` export is Next.js-compatible. Common fields shown below;
|
|
@@ -939,13 +1093,16 @@ composition, so a nested shell ends up dropped by the HTML parser.
|
|
|
939
1093
|
feature surface changed, `webjs check` passing. A unit test is not
|
|
940
1094
|
always enough: a component, hydration, the client router, or a server
|
|
941
1095
|
action called from the client needs a browser test
|
|
942
|
-
(`webjs test --browser`) asserting the behaviour in a real browser.
|
|
943
|
-
commit that stages app code (`app/`, `modules/`,
|
|
944
|
-
with no test
|
|
945
|
-
`.claude/hooks/require-tests-with-src.sh
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1096
|
+
(`webjs test --browser`) asserting the behaviour in a real browser. For
|
|
1097
|
+
Claude Code, a commit that stages app code (`app/`, `modules/`,
|
|
1098
|
+
`components/`, `lib/`) with no test WARNS via
|
|
1099
|
+
`.claude/hooks/require-tests-with-src.sh` (every change should still ship
|
|
1100
|
+
with a test, but that is a convention, not a hard gate). A project that
|
|
1101
|
+
wants the strict floor opts into a hard block by setting
|
|
1102
|
+
`WEBJS_TEST_GATE=block` (in `.claude/settings.json` env, your shell, or
|
|
1103
|
+
CI). The real enforcement is CI: the test suite runs in
|
|
1104
|
+
`.github/workflows/ci.yml`, not in the pre-commit hook, so `git commit`
|
|
1105
|
+
stays fast and the gate cannot be skipped with a local `--no-verify`.
|
|
949
1106
|
3. Commit and push **per logical unit**, not at the end. A logical unit is one
|
|
950
1107
|
feature, one fix, one rename, one doc rewrite. If you have 5+ unstaged files
|
|
951
1108
|
spanning different concerns, commit the current group before continuing.
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -494,14 +494,19 @@ This is also why the auth test lives at `test/auth/auth.test.ts` (the
|
|
|
494
494
|
feature-folder convention), NOT `test/unit/auth.test.ts`. Test KIND is a
|
|
495
495
|
subfolder inside a feature, never the top level.
|
|
496
496
|
|
|
497
|
-
**Every change ships with a test.**
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
497
|
+
**Every change ships with a test.** This is a convention, not a hard
|
|
498
|
+
gate, consistent with the convention-vs-check principle this file
|
|
499
|
+
states (a sensible app can legitimately want a test-less commit for a
|
|
500
|
+
spike, a vendored file, or a pure refactor). For Claude Code, a commit
|
|
501
|
+
that stages app code (`app/`, `modules/`, `components/`, `lib/`) without
|
|
502
|
+
staging a test WARNS via `.claude/hooks/require-tests-with-src.sh`, then
|
|
503
|
+
lets the commit through. A project that wants the strict floor opts into
|
|
504
|
+
a hard block by setting `WEBJS_TEST_GATE=block` (in
|
|
505
|
+
`.claude/settings.json` env, your shell, or CI). A unit test alone is
|
|
506
|
+
not enough for interactive or component code: add the browser test that
|
|
507
|
+
asserts the rendered/hydrated behaviour. The real enforcement is CI: the
|
|
508
|
+
test suite runs in `.github/workflows/ci.yml` on every PR and push to
|
|
509
|
+
main, so the gate cannot be skipped with a local `--no-verify`.
|
|
505
510
|
|
|
506
511
|
### Choosing a feature folder
|
|
507
512
|
|
|
@@ -687,7 +692,7 @@ Both hydrate without flash on the client.
|
|
|
687
692
|
|
|
688
693
|
---
|
|
689
694
|
|
|
690
|
-
## Styling: Tailwind + JS helpers
|
|
695
|
+
## Styling: Tailwind-first + JS helpers
|
|
691
696
|
|
|
692
697
|
<!-- OVERRIDE -->
|
|
693
698
|
|
|
@@ -697,6 +702,31 @@ fluid type scale value, and motion duration is declared once in `@theme`
|
|
|
697
702
|
and available everywhere via utility classes (`text-fg`, `bg-bg-elev`,
|
|
698
703
|
`font-serif`, `duration-fast`, `text-display`).
|
|
699
704
|
|
|
705
|
+
**Tailwind-first is the strong default for pages AND light-DOM
|
|
706
|
+
components (the default DOM mode).** Use utilities for layout, spacing,
|
|
707
|
+
color (via the `@theme` tokens), typography, borders, radius, shadows,
|
|
708
|
+
and interaction states (hover/focus/active/disabled, dark mode). Light
|
|
709
|
+
DOM does not scope styles, so utilities apply directly.
|
|
710
|
+
|
|
711
|
+
**The lit muscle-memory trap.** If you have written lit, the habit is to
|
|
712
|
+
scope CSS in a shadow root (`static styles = css\`\``) or write an inline
|
|
713
|
+
`<style>` with semantic class names (`.hero`, `.feature`, `.card`) for
|
|
714
|
+
every component. In a webjs light-DOM component the scoped block does
|
|
715
|
+
nothing without `static shadow = true`, and the inline class names leak
|
|
716
|
+
into the global namespace. Prefer Tailwind utilities. When the same
|
|
717
|
+
bundle repeats, extract a `lib/utils/ui.ts` helper (below), not a CSS
|
|
718
|
+
class.
|
|
719
|
+
|
|
720
|
+
**Custom-CSS allowlist (the only things raw CSS is for).** Reserve raw
|
|
721
|
+
CSS for what utilities cannot express: design-token `:root` / `@theme`
|
|
722
|
+
definitions, `@property` animated custom properties with `@keyframes`,
|
|
723
|
+
`::-webkit-scrollbar` / `scrollbar-color`, `prefers-reduced-motion`
|
|
724
|
+
blocks, and complex `color-mix()` or gradient effects. When custom CSS is
|
|
725
|
+
unavoidable in a light-DOM component, the class-prefix rule (see the
|
|
726
|
+
Components section above) still applies. Shadow-DOM components
|
|
727
|
+
(`static shadow = true`) legitimately author `static styles = css\`\``;
|
|
728
|
+
that is the right home for scoped CSS.
|
|
729
|
+
|
|
700
730
|
**Dedup repeated Tailwind class bundles with JS helpers, not `@apply`.**
|
|
701
731
|
When the same string of classes appears in 2+ places, extract it into a
|
|
702
732
|
small function in `lib/utils/ui.ts`:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Offline</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
body {
|
|
10
|
+
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
11
|
+
font: 16px/1.6 system-ui, sans-serif; background: #fafafa; color: #1a1a1a;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) { body { background: #0d0d10; color: #e6e6e6; } }
|
|
14
|
+
main { max-width: 28rem; padding: 2rem; text-align: center; }
|
|
15
|
+
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
|
|
16
|
+
p { margin: 0 0 1.5rem; opacity: 0.8; }
|
|
17
|
+
.retry {
|
|
18
|
+
display: inline-block; font: inherit; padding: 0.6rem 1.2rem;
|
|
19
|
+
border-radius: 0.5rem; background: #1a1a1a; color: #fff;
|
|
20
|
+
text-decoration: none; cursor: pointer;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) { .retry { background: #e6e6e6; color: #0d0d10; } }
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<main>
|
|
27
|
+
<h1>You are offline</h1>
|
|
28
|
+
<p>This page is not available without a network connection. Pages you have already visited still work offline.</p>
|
|
29
|
+
<!-- An empty href reloads the current URL (the page the user tried to
|
|
30
|
+
reach), so retry works with NO inline JS, staying CSP-compatible. -->
|
|
31
|
+
<a class="retry" href="">Try again</a>
|
|
32
|
+
</main>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* webjs progressive-enhancement service worker (OPT-IN, #271).
|
|
3
|
+
*
|
|
4
|
+
* This adds an offline fallback and an asset cache WITHOUT changing the
|
|
5
|
+
* JavaScript-disabled baseline: with JS off no service worker registers, so
|
|
6
|
+
* pages, links, and forms behave exactly as they do today. It is registered
|
|
7
|
+
* explicitly (see the opt-in snippet in agent-docs/service-worker.md), never
|
|
8
|
+
* automatically.
|
|
9
|
+
*
|
|
10
|
+
* Strategy:
|
|
11
|
+
* - Navigations are NETWORK-FIRST: always try the network so the user sees
|
|
12
|
+
* fresh server-rendered HTML, caching each successful page (the SSR shell)
|
|
13
|
+
* so a later OFFLINE visit to a page you have seen still renders. When the
|
|
14
|
+
* network fails and nothing is cached, serve /offline.html.
|
|
15
|
+
* - Same-origin static assets (the per-file ESM modules, the framework
|
|
16
|
+
* runtime under /__webjs/core/, vendor bundles, public assets) are
|
|
17
|
+
* stale-while-revalidate, so a repeat visit works offline. In production
|
|
18
|
+
* these URLs carry a ?v=<hash> content fingerprint, so a changed file gets
|
|
19
|
+
* a new URL and the cache can never serve stale bytes.
|
|
20
|
+
*
|
|
21
|
+
* Versioning ties to the deploy. The page registers this worker as
|
|
22
|
+
* `/sw.js?v=<data-webjs-build>` (the importmap build id), so a new deploy
|
|
23
|
+
* changes the worker's own URL, the browser fetches the new worker, and its
|
|
24
|
+
* `activate` deletes every cache that is not the current version. The cache
|
|
25
|
+
* name is derived from that `?v=` below.
|
|
26
|
+
*
|
|
27
|
+
* NEVER cached: non-GET requests, cross-origin requests, the action RPC
|
|
28
|
+
* endpoint (/__webjs/action/), the dev live-reload SSE (/__webjs/events) and
|
|
29
|
+
* dev reload client (/__webjs/reload.js).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const BUILD = new URL(self.location.href).searchParams.get('v') || 'dev';
|
|
33
|
+
const CACHE = 'webjs-' + BUILD;
|
|
34
|
+
const OFFLINE_URL = '/offline.html';
|
|
35
|
+
|
|
36
|
+
self.addEventListener('install', (event) => {
|
|
37
|
+
event.waitUntil((async () => {
|
|
38
|
+
const cache = await caches.open(CACHE);
|
|
39
|
+
// Precache the offline fallback. `reload` bypasses the HTTP cache so the
|
|
40
|
+
// freshly-deployed offline page is stored, not a stale one.
|
|
41
|
+
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
|
|
42
|
+
await self.skipWaiting();
|
|
43
|
+
})());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
self.addEventListener('activate', (event) => {
|
|
47
|
+
event.waitUntil((async () => {
|
|
48
|
+
const keys = await caches.keys();
|
|
49
|
+
await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
|
|
50
|
+
await self.clients.claim();
|
|
51
|
+
})());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Decide whether a GET request to a same-origin path is a cacheable asset. */
|
|
55
|
+
function isCacheableAsset(pathname) {
|
|
56
|
+
if (pathname.startsWith('/__webjs/action/')) return false; // RPC, never cache
|
|
57
|
+
if (pathname === '/__webjs/events' || pathname === '/__webjs/reload.js') return false; // dev
|
|
58
|
+
if (pathname.startsWith('/__webjs/core/') || pathname.startsWith('/__webjs/vendor/')) return true;
|
|
59
|
+
return /\.(?:js|mjs|ts|css|woff2?|png|jpe?g|svg|webp|gif|ico|json|map)$/.test(pathname);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
self.addEventListener('fetch', (event) => {
|
|
63
|
+
const req = event.request;
|
|
64
|
+
if (req.method !== 'GET') return; // never cache writes
|
|
65
|
+
const url = new URL(req.url);
|
|
66
|
+
if (url.origin !== self.location.origin) return; // only same-origin
|
|
67
|
+
|
|
68
|
+
// Network-first for page navigations: fresh server HTML, cache it for offline,
|
|
69
|
+
// fall back to the cached page then the offline page.
|
|
70
|
+
if (req.mode === 'navigate') {
|
|
71
|
+
event.respondWith((async () => {
|
|
72
|
+
try {
|
|
73
|
+
const fresh = await fetch(req);
|
|
74
|
+
// Cache ONLY a successful page (never a 404/500 error page, or an
|
|
75
|
+
// offline visit would serve the cached error instead of the fallback).
|
|
76
|
+
// waitUntil keeps the worker alive until the write lands (a worker can
|
|
77
|
+
// be terminated the moment respondWith settles).
|
|
78
|
+
if (fresh && fresh.ok) {
|
|
79
|
+
const copy = fresh.clone();
|
|
80
|
+
event.waitUntil(caches.open(CACHE).then((cache) => cache.put(req, copy)));
|
|
81
|
+
}
|
|
82
|
+
return fresh;
|
|
83
|
+
} catch (_err) {
|
|
84
|
+
const cache = await caches.open(CACHE);
|
|
85
|
+
const cached = await cache.match(req);
|
|
86
|
+
return cached || (await cache.match(OFFLINE_URL)) || Response.error();
|
|
87
|
+
}
|
|
88
|
+
})());
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Stale-while-revalidate for static assets.
|
|
93
|
+
if (isCacheableAsset(url.pathname)) {
|
|
94
|
+
event.respondWith((async () => {
|
|
95
|
+
const cache = await caches.open(CACHE);
|
|
96
|
+
const cached = await cache.match(req);
|
|
97
|
+
const network = fetch(req)
|
|
98
|
+
.then((res) => { if (res && res.ok) cache.put(req, res.clone()); return res; })
|
|
99
|
+
.catch(() => cached);
|
|
100
|
+
// Keep the worker alive for the background revalidation + write, which
|
|
101
|
+
// would otherwise be a floating promise lost on worker termination.
|
|
102
|
+
event.waitUntil(network.catch(() => {}));
|
|
103
|
+
return cached || network;
|
|
104
|
+
})());
|
|
105
|
+
}
|
|
106
|
+
});
|