@webjsdev/server 0.8.57 → 0.8.59

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/src/stream.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * The payload is plain HTML, a `<webjs-stream action target>` wrapping one
7
7
  * `<template>`. These helpers compose that string for the two delivery paths,
8
8
  * a content-negotiated HTTP form response and a `broadcast()` / WS message, so
9
- * an app never hand-writes the markup. A page `action` or a `route` handler
9
+ * an app never hand-writes the markup. A form-bound action or a `route` handler
10
10
  * branches on `acceptsStream(request)`, returning `streamResponse(...)` when the
11
11
  * router asked for a stream and a normal render otherwise (the JS-off degrade).
12
12
  *
package/src/testing.d.ts CHANGED
@@ -42,6 +42,25 @@ export declare function withCookies(init: RequestInit | undefined, cookieValue:
42
42
  /** Merge a session cookie string into a `RequestInit`'s `Cookie` header. */
43
43
  export declare function withSessionCookie(init: RequestInit | undefined, sessionCookie: string): RequestInit;
44
44
 
45
+ /**
46
+ * Submit a page's `<form action={action}>` the way a browser with JS OFF does:
47
+ * render the page, reuse the identity the server put in its hidden
48
+ * `__webjs_action` field, and POST it back with the given fields. A POST that
49
+ * omits that field is not a form submission and is answered `405`.
50
+ */
51
+ export declare function submitForm(
52
+ handle: Handle,
53
+ path: string,
54
+ fields?: Record<string, string> | URLSearchParams | FormData,
55
+ opts?: {
56
+ index?: number;
57
+ match?: string | RegExp;
58
+ cookies?: string;
59
+ submitPath?: string;
60
+ headers?: Record<string, string>;
61
+ },
62
+ ): Promise<Response>;
63
+
45
64
  /**
46
65
  * Drive the REAL credentials login (`POST /api/auth/signin/credentials` by
47
66
  * default) and capture the genuine signed session `Set-Cookie`.
package/src/testing.js CHANGED
@@ -33,6 +33,7 @@
33
33
  */
34
34
 
35
35
  import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
36
+ import { FORM_ACTION_FIELD } from '@webjsdev/core';
36
37
  import { getSerializer } from './serializer.js';
37
38
  import { join, sep } from 'node:path';
38
39
 
@@ -200,6 +201,118 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
200
201
  return { cookies, setCookies, response: res };
201
202
  }
202
203
 
204
+ /**
205
+ * Submit a page's `<form action=${action}>` the way a browser with JS OFF does
206
+ * (#1155): render the page, take the identity the server put in its hidden
207
+ * `__webjs_action` field, and POST it back with the author's fields.
208
+ *
209
+ * A bound form carries that field because the identity is what tells the
210
+ * dispatcher WHICH action to run, so a hand-written POST that omits it is not a
211
+ * form submission at all and is answered `405`. Reproducing the scrape in every
212
+ * app test is how that ends up looking like a database problem instead: the
213
+ * status is wrong, an assertion fails, and a `catch` around it reports a skip.
214
+ * Hence a helper, so the identity is never the test's business.
215
+ *
216
+ * The form is located by INDEX (`opts.index`, default the first on the page),
217
+ * which is enough for the common one-form page. For a page with several, pass
218
+ * `opts.match`: a string or RegExp that must appear in the form's markup, so
219
+ * the test names the form by something an author recognizes (a heading, a field
220
+ * name, an id) rather than by counting.
221
+ *
222
+ * @param {Handle} handle
223
+ * @param {string} path the page to render AND, by default, to submit back to
224
+ * @param {Record<string, string> | URLSearchParams | FormData} [fields]
225
+ * @param {{
226
+ * index?: number,
227
+ * match?: string | RegExp,
228
+ * cookies?: string,
229
+ * submitPath?: string,
230
+ * headers?: Record<string, string>,
231
+ * }} [opts]
232
+ * @returns {Promise<Response>} the submission response (a `303` on success, a
233
+ * `422` re-render on a failure result)
234
+ */
235
+ /**
236
+ * The `<form>...</form>` blocks of a rendered page.
237
+ *
238
+ * A bare `/<form[\s\S]*?<\/form>/g` gets two things wrong that a test helper
239
+ * must not, because both fail SILENTLY: a form inside an HTML comment is
240
+ * matched (so `opts.index` counts a form that is not on the page), and a
241
+ * `</form>` sitting inside an ATTRIBUTE VALUE ends the match early (so the
242
+ * identity field falls outside it and the helper reports an unbound form).
243
+ * Comments are stripped first, and the scan skips quoted attribute values while
244
+ * looking for the end of the start tag.
245
+ *
246
+ * @param {string} html
247
+ * @returns {string[]}
248
+ */
249
+ function findForms(html) {
250
+ const src = html.replace(/<!--[\s\S]*?-->/g, '');
251
+ const out = [];
252
+ const open = /<form\b/gi;
253
+ let m;
254
+ while ((m = open.exec(src))) {
255
+ // Walk the start tag, skipping quoted values so `data-x="<form>"` is inert.
256
+ let i = m.index + m[0].length;
257
+ let quote = '';
258
+ for (; i < src.length; i++) {
259
+ const c = src[i];
260
+ if (quote) { if (c === quote) quote = ''; continue; }
261
+ if (c === '"' || c === "'") { quote = c; continue; }
262
+ if (c === '>') break;
263
+ }
264
+ const close = src.toLowerCase().indexOf('</form>', i);
265
+ if (close === -1) continue;
266
+ out.push(src.slice(m.index, close + '</form>'.length));
267
+ open.lastIndex = close;
268
+ }
269
+ return out;
270
+ }
271
+
272
+ export async function submitForm(handle, path, fields = {}, opts = {}) {
273
+ const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
274
+ const page = await testRequest(handle, path, getInit);
275
+ const html = await page.text();
276
+
277
+ const forms = findForms(html);
278
+ const candidates = opts.match
279
+ ? forms.filter((f) => (opts.match instanceof RegExp ? opts.match.test(f) : f.includes(opts.match)))
280
+ : forms;
281
+ const form = candidates[opts.index || 0];
282
+ if (!form) {
283
+ throw new Error(
284
+ `submitForm: no ${opts.match ? 'matching ' : ''}<form> found on ${path} `
285
+ + `(status ${page.status}, ${forms.length} form(s) on the page).`,
286
+ );
287
+ }
288
+ const id = form.match(/name="__webjs_action"\s+value="([^"]*)"/);
289
+ if (!id) {
290
+ throw new Error(
291
+ `submitForm: the <form> on ${path} carries no __webjs_action field, so it `
292
+ + 'is not bound to a server action. Write it as `<form action=${myAction}>` '
293
+ + 'with the action imported from a `use server` module.',
294
+ );
295
+ }
296
+
297
+ const body = new URLSearchParams();
298
+ body.set(FORM_ACTION_FIELD, id[1]);
299
+ const entries = fields instanceof URLSearchParams || fields instanceof FormData
300
+ ? fields.entries()
301
+ : Object.entries(fields);
302
+ for (const [k, v] of entries) body.append(k, String(v));
303
+
304
+ const init = {
305
+ method: 'POST',
306
+ headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
307
+ body: body.toString(),
308
+ };
309
+ return await testRequest(
310
+ handle,
311
+ opts.submitPath || path,
312
+ opts.cookies ? withCookies(init, opts.cookies) : init,
313
+ );
314
+ }
315
+
203
316
  /**
204
317
  * Compute the RPC endpoint path for an action, addressing it the same way the
205
318
  * generated client stub does: by the SHA-256 hash of the action's ABSOLUTE file
package/src/vendor.js CHANGED
@@ -338,6 +338,27 @@ let lastLiveResolveFailed = false;
338
338
 
339
339
  const JSPM_GENERATE_ENDPOINT = 'https://api.jspm.io/generate';
340
340
  const JSPM_GENERATE_TIMEOUT_MS = 10_000;
341
+ // Bounds a single bundle GET during the SERVER's warmup live-integrity pass
342
+ // (`fetchLiveIntegrity`). Short on purpose: that pass gates readiness, so a
343
+ // stalled CDN must not hold the first request, and it is additionally capped
344
+ // by INTEGRITY_TOTAL_BUDGET_MS across all URLs.
345
+ const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
346
+
347
+ // Bounds a single bundle GET made by the pin command, which either writes the
348
+ // bytes to disk (`downloadBundle`) or fetches them to hash
349
+ // (`fetchIntegrity`). Deliberately six times the warmup budget, because the
350
+ // two are not the same situation: a pin is a one-shot command a person ran and
351
+ // is waiting on, with a whole multi-megabyte package to transfer, while the
352
+ // warmup is a server holding a request. Ten seconds is generous for the
353
+ // latter and tight for the former on a slow link.
354
+ //
355
+ // 60s matches what importmap-rails effectively allows. It sets no timeout at
356
+ // all, but Ruby's Net::HTTP defaults open_timeout and read_timeout to 60s, so
357
+ // a Rails pin is bounded at a minute without asking. JavaScript's fetch() has
358
+ // no default whatsoever, which is why this has to be explicit: without it a
359
+ // CDN that accepts the connection and then stalls hangs the pin forever, with
360
+ // no ambient deadline on a CLI run to cut it short.
361
+ const PIN_BUNDLE_TIMEOUT_MS = 60_000;
341
362
 
342
363
  /**
343
364
  * Provider names accepted by `webjs vendor pin --from <provider>`.
@@ -387,12 +408,15 @@ export function normalizeProvider(name) {
387
408
  *
388
409
  * @param {Array<string>} installs e.g. ['dayjs@1.11.13', '@codemirror/lint@6.9.6']
389
410
  * @param {string} provider one of SUPPORTED_PROVIDERS
411
+ * @param {number} [timeoutMs] defaults to the SERVER budget; a CLI caller
412
+ * passes the longer one, since it is a command someone is waiting on rather
413
+ * than a request being held open.
390
414
  * @returns {Promise<JspmCallResult>}
391
415
  */
392
- async function jspmCall(installs, provider) {
416
+ async function jspmCall(installs, provider, timeoutMs = JSPM_GENERATE_TIMEOUT_MS) {
393
417
  const label = installs.length === 1 ? `'${installs[0]}'` : `${installs.length} packages`;
394
418
  const controller = new AbortController();
395
- const timer = setTimeout(() => controller.abort(), JSPM_GENERATE_TIMEOUT_MS);
419
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
396
420
  try {
397
421
  const response = await fetch(JSPM_GENERATE_ENDPOINT, {
398
422
  method: 'POST',
@@ -440,7 +464,7 @@ async function jspmCall(installs, provider) {
440
464
  return { ok: true, imports, transient: false };
441
465
  } catch (e) {
442
466
  const msg = e && e.name === 'AbortError'
443
- ? `timed out after ${JSPM_GENERATE_TIMEOUT_MS}ms`
467
+ ? `timed out after ${timeoutMs}ms`
444
468
  : `${e && e.message}`;
445
469
  console.error(`[webjs] could not vendor ${label} via ${provider}: ${msg}`);
446
470
  return { ok: false, imports: {}, transient: true };
@@ -466,8 +490,8 @@ async function jspmCall(installs, provider) {
466
490
  * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
467
491
  * @returns {Promise<Record<string, string>>}
468
492
  */
469
- async function jspmResolveOne(install, provider = 'jspm') {
470
- const { ok, imports, transient } = await jspmProbeOne(install, provider);
493
+ async function jspmResolveOne(install, provider = 'jspm', timeoutMs) {
494
+ const { ok, imports, transient } = await jspmProbeOne(install, provider, timeoutMs);
471
495
  // Preserve the public contract: an empty map on any failure, and the
472
496
  // module-global retry flag set ONLY on a transient one (a permanent 401
473
497
  // for an unresolvable private/server-only dep is tolerated).
@@ -492,13 +516,13 @@ async function jspmResolveOne(install, provider = 'jspm') {
492
516
  * @param {string} provider
493
517
  * @returns {Promise<JspmCallResult>}
494
518
  */
495
- function jspmProbeOne(install, provider) {
519
+ function jspmProbeOne(install, provider, timeoutMs) {
496
520
  const cacheKey = `${provider}::probe::${install}`;
497
521
  const existing = jspmCache.get(cacheKey);
498
522
  if (existing) return existing;
499
523
 
500
524
  const promise = (async () => {
501
- const result = await jspmCall([install], provider);
525
+ const result = await jspmCall([install], provider, timeoutMs);
502
526
  // Do not cache a failure: a transient one must be re-attempted on the
503
527
  // next resolve, and a permanent one is cheap to re-confirm and must not
504
528
  // pin a stale "unresolvable" verdict across a dependency change.
@@ -543,14 +567,18 @@ function jspmProbeOne(install, provider) {
543
567
  *
544
568
  * @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
545
569
  * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
570
+ * @param {number} [timeoutMs] per-call budget. Defaults to the SERVER one,
571
+ * because this runs on a cold first request as well as from the CLI; the two
572
+ * pin commands pass PIN_BUNDLE_TIMEOUT_MS instead. importmap-rails only ever
573
+ * resolves from the CLI, so its flat 60s has no request path to slow down.
546
574
  * @returns {Promise<Record<string, string>>}
547
575
  */
548
- export async function jspmGenerate(installs, provider = 'jspm') {
576
+ export async function jspmGenerate(installs, provider = 'jspm', timeoutMs) {
549
577
  if (installs.length === 0) return {};
550
578
 
551
579
  // A single install has no cross-package graph to reconcile, so the
552
580
  // isolated path IS the coherent path; reuse the per-install cache.
553
- if (installs.length === 1) return jspmResolveOne(installs[0], provider);
581
+ if (installs.length === 1) return jspmResolveOne(installs[0], provider, timeoutMs);
554
582
 
555
583
  // Stable key regardless of scan order so the same dep set hits cache.
556
584
  const unifiedKey = `${provider}::unified::${[...installs].sort().join('\n')}`;
@@ -558,7 +586,7 @@ export async function jspmGenerate(installs, provider = 'jspm') {
558
586
  if (cached) return cached;
559
587
 
560
588
  const promise = (async () => {
561
- const unified = await jspmCall(installs, provider);
589
+ const unified = await jspmCall(installs, provider, timeoutMs);
562
590
  if (unified.ok) return unified.imports;
563
591
 
564
592
  // The unified call failed. Drop the cached failure so a later retry
@@ -570,14 +598,14 @@ export async function jspmGenerate(installs, provider = 'jspm') {
570
598
  // per-install fragments (each may still be cached / reachable) so we
571
599
  // serve whatever we can, and flag the transient failure for retry.
572
600
  lastLiveResolveFailed = true;
573
- return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider))));
601
+ return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider, timeoutMs))));
574
602
  }
575
603
 
576
604
  // Permanent failure: at least one install is unresolvable. Probe each
577
605
  // in isolation to learn which ones jspm can resolve, then re-run the
578
606
  // unified call over only those so the survivors form one consistent
579
607
  // graph (restores #446 coherence for the resolvable subset).
580
- const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider)));
608
+ const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider, timeoutMs)));
581
609
 
582
610
  // A GOOD package whose isolated probe failed TRANSIENTLY (a network blip
583
611
  // mid-probe) must NOT be classified as unresolvable and dropped. Only a
@@ -612,11 +640,11 @@ export async function jspmGenerate(installs, provider = 'jspm') {
612
640
  return mergePerInstall(probes.map(p => p.imports));
613
641
  }
614
642
  if (resolvable.length === 0) return {};
615
- if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider);
643
+ if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider, timeoutMs);
616
644
 
617
645
  // Re-run unified over the resolvable subset. If even that fails (a
618
646
  // conflict among the survivors), fall back to their merged fragments.
619
- const retry = await jspmCall(resolvable, provider);
647
+ const retry = await jspmCall(resolvable, provider, timeoutMs);
620
648
  if (retry.ok) return retry.imports;
621
649
  return mergePerInstall(resolvable.map(i => probes[installs.indexOf(i)].imports));
622
650
  })();
@@ -679,6 +707,12 @@ export async function vendorImportMapEntries(bareImports, appDir) {
679
707
  export function clearVendorCache() {
680
708
  jspmCache.clear();
681
709
  liveIntegrityCache.clear();
710
+ // Deliberately does NOT touch `lastLiveResolveFailed`, which looks like an
711
+ // omission and is not. `resolveVendorImports` is the flag's only reader and
712
+ // it resets the flag on entry, while the pinned short-circuit above that
713
+ // returns `ok: true` outright, so a value left behind by an earlier
714
+ // `pinAll` or `vendorImportMapEntries` can never be observed. Clearing it
715
+ // here would be a change nothing could write a failing test for (#1150).
682
716
  }
683
717
 
684
718
  /**
@@ -1071,14 +1105,21 @@ async function writePinFile(appDir, imports, integrity, provider) {
1071
1105
  * success or null on failure. The integrity hash is computed from the
1072
1106
  * downloaded bytes so it's always consistent with what's on disk.
1073
1107
  *
1108
+ * Bounded by PIN_BUNDLE_TIMEOUT_MS. `pinAll(dir, { download: true })`
1109
+ * runs this once per resolved URL on a CLI run with no ambient
1110
+ * deadline, so a CDN that accepts the connection and then stalls would
1111
+ * otherwise hang the pin with nothing to interrupt it (#1150).
1112
+ *
1074
1113
  * @param {string} url
1075
1114
  * @param {string} appDir
1076
1115
  * @param {string} filename
1077
1116
  * @returns {Promise<{ bytes: number, integrity: string } | null>}
1078
1117
  */
1079
1118
  async function downloadBundle(url, appDir, filename) {
1119
+ const controller = new AbortController();
1120
+ const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
1080
1121
  try {
1081
- const response = await fetch(url);
1122
+ const response = await fetch(url, { signal: controller.signal });
1082
1123
  if (!response.ok) {
1083
1124
  console.error(`[webjs] download ${url} returned ${response.status}`);
1084
1125
  return null;
@@ -1094,8 +1135,13 @@ async function downloadBundle(url, appDir, filename) {
1094
1135
  await writeFile(join(pinDir(appDir), filename), buf);
1095
1136
  return { bytes: buf.byteLength, integrity: await sha384Integrity(buf) };
1096
1137
  } catch (e) {
1097
- console.error(`[webjs] download ${url} failed: ${e && e.message}`);
1138
+ const why = e && e.name === 'AbortError'
1139
+ ? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
1140
+ : e && e.message;
1141
+ console.error(`[webjs] download ${url} failed: ${why}`);
1098
1142
  return null;
1143
+ } finally {
1144
+ clearTimeout(timer);
1099
1145
  }
1100
1146
  }
1101
1147
 
@@ -1105,12 +1151,21 @@ async function downloadBundle(url, appDir, filename) {
1105
1151
  * so the importmap can carry SRI hashes even when bundles aren't
1106
1152
  * locally vendored.
1107
1153
  *
1154
+ * Bounded by PIN_BUNDLE_TIMEOUT_MS, the same budget `downloadBundle`
1155
+ * gets, since it transfers the same bytes and differs only in whether
1156
+ * they are written to disk. Default-mode `pinAll` runs this once per
1157
+ * resolved URL, so a CDN that accepts the connection and then stalls
1158
+ * would otherwise hang the pin with nothing to interrupt it: there is
1159
+ * no ambient deadline on a CLI run (#1150).
1160
+ *
1108
1161
  * @param {string} url
1109
1162
  * @returns {Promise<string | null>} the integrity string, or null on failure
1110
1163
  */
1111
1164
  async function fetchIntegrity(url) {
1165
+ const controller = new AbortController();
1166
+ const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
1112
1167
  try {
1113
- const response = await fetch(url);
1168
+ const response = await fetch(url, { signal: controller.signal });
1114
1169
  if (!response.ok) {
1115
1170
  console.error(`[webjs] hash ${url} returned ${response.status}`);
1116
1171
  return null;
@@ -1121,8 +1176,13 @@ async function fetchIntegrity(url) {
1121
1176
  const buf = new Uint8Array(await response.arrayBuffer());
1122
1177
  return await sha384Integrity(buf);
1123
1178
  } catch (e) {
1124
- console.error(`[webjs] hash ${url} failed: ${e && e.message}`);
1179
+ const why = e && e.name === 'AbortError'
1180
+ ? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
1181
+ : e && e.message;
1182
+ console.error(`[webjs] hash ${url} failed: ${why}`);
1125
1183
  return null;
1184
+ } finally {
1185
+ clearTimeout(timer);
1126
1186
  }
1127
1187
  }
1128
1188
 
@@ -1254,7 +1314,7 @@ export async function pinAll(appDir, opts = {}) {
1254
1314
  installs.push(install);
1255
1315
  partsByInstall.set(spec, { pkg, version, subpath });
1256
1316
  }
1257
- const resolved = await jspmGenerate(installs, from);
1317
+ const resolved = await jspmGenerate(installs, from, PIN_BUNDLE_TIMEOUT_MS);
1258
1318
 
1259
1319
  /** @type {Record<string, string>} */
1260
1320
  const importmap = {};
@@ -1496,7 +1556,11 @@ export async function listPinned(appDir) {
1496
1556
  // ---------------------------------------------------------------------------
1497
1557
 
1498
1558
  const NPM_REGISTRY = 'https://registry.npmjs.org';
1499
- const NPM_TIMEOUT_MS = 10_000;
1559
+ // The npm registry is reached only by `audit`, `outdated`, and `update`,
1560
+ // all CLI commands, so it takes the same generous budget the pin bundle
1561
+ // fetch does rather than the server's. importmap-rails makes these same two
1562
+ // calls with Ruby's 60s Net::HTTP default.
1563
+ const NPM_TIMEOUT_MS = 60_000;
1500
1564
 
1501
1565
  /**
1502
1566
  * Fetch one URL from registry.npmjs.org with a small timeout. Returns
@@ -1696,7 +1760,7 @@ export async function updatePinned(appDir, opts = {}) {
1696
1760
  if (specPkg !== pkg) continue;
1697
1761
  const subpath = spec.slice(specPkg.length);
1698
1762
  const install = `${pkg}@${latest}${subpath}`;
1699
- const resolved = await jspmGenerate([install], from);
1763
+ const resolved = await jspmGenerate([install], from, PIN_BUNDLE_TIMEOUT_MS);
1700
1764
  const newUrl = resolved[spec];
1701
1765
  if (!newUrl) continue;
1702
1766
  newImports[spec] = newUrl;
@@ -2169,7 +2233,6 @@ export async function checkImportmapCoherence(imports, opts) {
2169
2233
  */
2170
2234
  const liveIntegrityCache = new Map();
2171
2235
 
2172
- const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
2173
2236
  // Cap concurrent bundle fetches so a large dep set does not open dozens of
2174
2237
  // sockets at once during warmup. Matches the bounded posture of the rest of
2175
2238
  // vendor.js (the jspm resolve is per-package but the network is the shared
@@ -2,8 +2,8 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://webjs.dev/schemas/webjs-config.schema.json",
4
4
  "title": "webjs config block",
5
- "description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented reader (a server reader, or for `dev`/`start` the CLI's readAppTasks). An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
6
- "$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the reader functions (readElideEnabled in dev.js, readSeedEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js, and the CLI's readAppTasks in packages/cli/lib/app-tasks.js for dev/start, #550). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
5
+ "description": "Schema for the `webjs` object in a WebJs app's package.json. Each key maps to a documented reader, mostly a server reader and for a few keys a CLI one. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
6
+ "$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the reader functions. Adding a `webjs.*` key means updating all three (plus the KNOWN_KEYS drift test). The reader inventory is deliberately NOT repeated here: packages/server/AGENTS.md holds the one canonical list and the procedure, because every duplicate of that list drifted.",
7
7
  "type": "object",
8
8
  "additionalProperties": false,
9
9
  "properties": {
@@ -160,7 +160,7 @@
160
160
  "default": 5000
161
161
  },
162
162
  "dev": {
163
- "description": "Dev task orchestration (#550). `before`/`parallel` are read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js); `regenerate` is read by the server (readRegenerateRules in dev-regenerate.js). webjs dev runs `before` (one-shot, e.g. webjs db migrate) to completion, then `parallel` (long-lived watchers) alongside the server. Replaces the old predev hook + concurrently watchers.",
163
+ "description": "Dev task orchestration (#550). Split per sub-key: `before`/`parallel` are read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js), while `regenerate` (readRegenerateRules in dev-regenerate.js) and `watch` (readDevWatchPathsFromApp in dev.js) are read by the server. webjs dev runs `before` (one-shot, e.g. webjs db migrate) to completion, then `parallel` (long-lived watchers) alongside the server. Replaces the old predev hook + concurrently watchers.",
164
164
  "type": "object",
165
165
  "additionalProperties": false,
166
166
  "properties": {
@@ -216,6 +216,22 @@
216
216
  "items": { "type": "string" }
217
217
  }
218
218
  }
219
+ },
220
+ "doctor": {
221
+ "description": "`webjs doctor` policy (#1257). Read by the CLI (readDoctorPolicy in packages/cli/lib/doctor.js), NOT the server.",
222
+ "type": "object",
223
+ "additionalProperties": false,
224
+ "properties": {
225
+ "gate": {
226
+ "description": "Per-check severity, keyed by the stable DOCTOR_CODES code (NODE_VERSION, UNMARKED_ASSET_LINKS, ELISION_CARRIERS, ...). `error` fails the exit, `warn` reports without failing, `off` silences the check: its finding is not printed and it cannot fail the exit, including under --strict (it still appears on the checklist as [off], in the summary's silenced count, and in full under --json). A code with no entry keeps its default: error for a hard toolchain failure, warn otherwise. This is how CI gates on a chosen subset without --strict making every warning fatal. A malformed gate is a hard error, so a typo cannot silently un-gate CI: that covers an unknown code, a bad severity, a wrong SHAPE (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`. A result that could not check (a network or toolchain outage) is capped at warn and can never be escalated to error.",
227
+ "type": "object",
228
+ "propertyNames": { "pattern": "^[A-Z][A-Z0-9_]*$" },
229
+ "additionalProperties": {
230
+ "type": "string",
231
+ "enum": ["off", "warn", "error"]
232
+ }
233
+ }
234
+ }
219
235
  }
220
236
  }
221
237
  }