@webjsdev/server 0.9.0 → 0.10.0

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.
Files changed (101) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +427 -109
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +96 -14
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +262 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/dev/bun-app-source.js +95 -5
  33. package/src/dev/config.js +7 -51
  34. package/src/dev/dev-service-worker.js +48 -0
  35. package/src/dev/esm-hot-hooks.js +373 -0
  36. package/src/dev/handler.js +502 -155
  37. package/src/dev/helpers.js +127 -96
  38. package/src/dev/hot-host.js +33 -6
  39. package/src/dev/poke-state.js +110 -0
  40. package/src/dev/serve.js +177 -129
  41. package/src/dev/server.js +92 -33
  42. package/src/dev/specifier-versions.js +125 -0
  43. package/src/dev/watch-ignore.js +49 -4
  44. package/src/dev/watch-poll.js +78 -0
  45. package/src/dev/watch-tree.js +59 -22
  46. package/src/dev-classify.js +59 -26
  47. package/src/dev-error.js +104 -3
  48. package/src/dev-hot-classify.js +291 -0
  49. package/src/dev-hot-client.js +245 -0
  50. package/src/dev-import.js +22 -5
  51. package/src/dev-overlay.js +26 -0
  52. package/src/dev-ready-probe.js +69 -0
  53. package/src/dev-regenerate.js +2 -2
  54. package/src/dev-reload-hold.js +49 -20
  55. package/src/dev-reload-reason.js +61 -0
  56. package/src/dev-reload-worker.js +62 -13
  57. package/src/dev-secret.js +73 -0
  58. package/src/dev-source-locations-hook.js +4 -1
  59. package/src/dev-source-locations.js +33 -2
  60. package/src/dev.js +0 -1
  61. package/src/env-files.js +124 -0
  62. package/src/file-storage.js +2 -2
  63. package/src/foreign-imports.js +228 -0
  64. package/src/form-action-identity.js +52 -25
  65. package/src/form-dispatch.js +140 -65
  66. package/src/html-cache.js +35 -4
  67. package/src/importmap.js +2 -2
  68. package/src/instrumentation.js +59 -3
  69. package/src/js-scan.js +5 -4
  70. package/src/jwe.js +155 -0
  71. package/src/lazy-components.js +166 -0
  72. package/src/listener-core.js +4 -2
  73. package/src/lit-hints.js +172 -0
  74. package/src/middleware-load.js +248 -0
  75. package/src/module-graph.js +73 -47
  76. package/src/module-side-effect.js +284 -0
  77. package/src/public-files.js +73 -0
  78. package/src/redirect-location.js +19 -0
  79. package/src/redirects.js +9 -1
  80. package/src/route-frontier.js +151 -0
  81. package/src/route-report.js +130 -0
  82. package/src/router.js +120 -13
  83. package/src/session.js +32 -10
  84. package/src/ssr/client-router-flag.js +1 -1
  85. package/src/ssr/document.js +37 -13
  86. package/src/ssr/head.js +78 -23
  87. package/src/ssr/preloads.js +31 -45
  88. package/src/ssr/render.js +241 -55
  89. package/src/ssr/responses.js +51 -4
  90. package/src/ssr/shipped-modules.js +22 -16
  91. package/src/testing.d.ts +31 -2
  92. package/src/testing.js +80 -17
  93. package/src/thenable-params.js +26 -0
  94. package/src/vendor/jspm.js +12 -1
  95. package/src/vendor/pins.js +9 -8
  96. package/src/vendor/scanner.js +14 -9
  97. package/src/vendor.js +5 -3
  98. package/webjs-config.schema.json +1 -6
  99. package/src/component-elision.js +0 -1828
  100. package/src/elision-differential.js +0 -101
  101. package/src/elision-report.js +0 -193
@@ -1,4 +1,5 @@
1
- import { cspNonce, renderToString } from '@webjsdev/core';
1
+ import { cspNonce, renderToString, isRedirect } from '@webjsdev/core';
2
+ import { headerSafeLocation } from '../redirect-location.js';
2
3
  import { BUFFERED_MARKER, STREAM_MARKER } from '../conditional-get.js';
3
4
  import { publishedBuildId, appSourceId } from '../importmap.js';
4
5
  import { escapeAttr, escapeHtml } from './escape.js';
@@ -82,7 +83,22 @@ export function htmlResponse(html, status, req, url, metadata) {
82
83
  // which is what keeps the router's partial responses cheap (#1140).
83
84
  // See conditional-get.js.
84
85
  headers.set(BUFFERED_MARKER, '1');
85
- return new Response(html, { status, headers });
86
+ return new Response(noindexErrorPage(html, status), { status, headers });
87
+ }
88
+
89
+ /**
90
+ * A 404, 403 or 401 page is not something to index, so, as Next.js does for
91
+ * every not-found page (#1686), it carries `<meta name="robots"
92
+ * content="noindex">` unless the app set robots metadata itself. Inserted
93
+ * before `</head>`, so a boundary inside the layouts and a `global-not-found`
94
+ * that writes its own document are covered alike.
95
+ * @param {string} html @param {number} status
96
+ */
97
+ export function noindexErrorPage(html, status) {
98
+ if (status !== 404 && status !== 403 && status !== 401) return html;
99
+ if (typeof html !== 'string' || /<meta\s[^>]*name=["']?robots/i.test(html)) return html;
100
+ const at = html.indexOf('</head>');
101
+ return at < 0 ? html : html.slice(0, at) + '<meta name="robots" content="noindex">' + html.slice(at);
86
102
  }
87
103
 
88
104
  /**
@@ -99,8 +115,10 @@ export function htmlResponse(html, status, req, url, metadata) {
99
115
  * @param {Record<string, any>} [metadata]
100
116
  * @param {string} [nonce]
101
117
  * @param {boolean} [dev] dev surfaces a streamed-boundary error message; prod stays silent
118
+ * @param {(e: unknown) => Promise<string | null>} [accessFallback] the markup of the
119
+ * nearest not-found / forbidden / unauthorized boundary for such a throw, else null
102
120
  */
103
- export function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce, dev) {
121
+ export function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce, dev, accessFallback) {
104
122
  const encoder = new TextEncoder();
105
123
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
106
124
  // Default: no caching. Pages are dynamic by default: the developer
@@ -137,6 +155,7 @@ export function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req
137
155
  // still arriving" and would block its progressive swap until the slow
138
156
  // boundary (#473). The comment is inert for the native initial-load parse.
139
157
  controller.enqueue(encoder.encode(prefix + bodyHtml + '<!--wj-stream-shell-->'));
158
+ let noindexSent = /<meta\s[^>]*name=["']?robots/i.test(prefix);
140
159
  try {
141
160
  // Loop: resolve all currently-pending promises in parallel; nested
142
161
  // Suspense inside resolved content adds more pending entries.
@@ -160,6 +179,22 @@ export function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req
160
179
  for (const n of sub.pending) ctx.pending.push(n);
161
180
  return { id: p.id, html };
162
181
  } catch (e) {
182
+ // A redirect() thrown inside a streamed boundary arrives after
183
+ // the 200 is sent, so it becomes a client redirect, as Next.js
184
+ // does (#1686): a marker the soft-navigation path follows, plus
185
+ // a refresh and a script for a first load (below).
186
+ if (isRedirect(e)) {
187
+ const to = headerSafeLocation(/** @type {any} */ (e).url);
188
+ return { id: p.id, html: `<meta data-webjs-redirect="${escapeAttr(to)}">`, redirect: to };
189
+ }
190
+ // notFound() / forbidden() / unauthorized() after the 200 went
191
+ // out: the nearest boundary renders in place of the streamed
192
+ // content and the page is marked noindex, as Next.js documents
193
+ // ("keeps its 200 status. Next.js adds a noindex tag", #1686).
194
+ if (accessFallback) {
195
+ const fb = await accessFallback(e);
196
+ if (fb !== null) return { id: p.id, html: fb, noindex: true };
197
+ }
163
198
  // Match the SSR error-isolation policy (render-server.js's
164
199
  // defaultSSRErrorTemplate): dev surfaces the message so the
165
200
  // failure is obvious, prod stays SILENT so no internal detail
@@ -171,6 +206,14 @@ export function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req
171
206
  })
172
207
  );
173
208
  for (const r of settled) {
209
+ if (r.noindex && !noindexSent) {
210
+ noindexSent = true;
211
+ const n = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
212
+ controller.enqueue(encoder.encode(
213
+ `<meta name="robots" content="noindex">` +
214
+ `<script${n}>document.head.insertAdjacentHTML('beforeend','<meta name="robots" content="noindex">')</script>`,
215
+ ));
216
+ }
174
217
  // Emit just the <template>: the MutationObserver-based resolver
175
218
  // in the boot script detects it and swaps it into the placeholder.
176
219
  // Falls back to the __webjsResolve global for browsers without MO.
@@ -181,9 +224,13 @@ export function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req
181
224
  // <script> is here for legacy / extremely-restrictive
182
225
  // environments. Either way it must be nonce-signed.
183
226
  const scriptNonce = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
227
+ const go = r.redirect
228
+ ? `<meta http-equiv="refresh" content="0;url=${escapeAttr(r.redirect)}">`
229
+ + `<script${scriptNonce}>location.replace(${JSON.stringify(r.redirect).replace(/</g, '\\u003c')})</script>`
230
+ : '';
184
231
  const chunk =
185
232
  `<template data-webjs-resolve="${r.id}">${r.html}</template>` +
186
- `<script${scriptNonce}>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
233
+ `<script${scriptNonce}>window.__webjsResolve&&__webjsResolve("${r.id}")</script>` + go;
187
234
  controller.enqueue(encoder.encode(chunk));
188
235
  }
189
236
  }
@@ -2,11 +2,11 @@
2
2
  * The boot's shipped-module assembly, shared by the SSR render and the 103
3
3
  * Early Hints (#1644).
4
4
  *
5
- * For a matched route the client boot imports the page and its layouts MINUS
6
- * the inert ones (a module that does no client work, #170), with an
7
- * IMPORT-ONLY module (#605) replaced by the component frontier it exists to
8
- * import, and a `static lazy` component in that frontier (#1524) diverted to
9
- * the lazy loader instead of imported. `ssr/render.js` derived that list per
5
+ * For a matched route the client boot imports the component FRONTIER of each
6
+ * of its route modules (layouts, loading templates, the page), never a route
7
+ * module itself (#1686, `route-frontier.js`), with a `static lazy` component
8
+ * in that frontier (#1524) diverted to the lazy loader instead of imported.
9
+ * `ssr/render.js` derived that list per
10
10
  * request, and the Node listener's `routeFor` (the 103 Early Hints) carried a
11
11
  * second copy of the same loop that had drifted: it knew nothing of lazy
12
12
  * files, so a lazy frontier component was early-hinted although the boot
@@ -15,8 +15,8 @@
15
15
  * One function now owns the assembly, memoized per ROUTE OBJECT in a WeakMap
16
16
  * (the route table is rebuilt per build, so a stale entry dies with its
17
17
  * route, and a dynamic route cannot grow the memo the way a pathname key
18
- * would) and validated against the build epoch (the inert / import-only /
19
- * lazy sets are per-build inputs). The hinted url list, the same modules
18
+ * would) and validated against the build epoch (the frontiers and the lazy
19
+ * set are per-build inputs). The hinted url list, the same modules
20
20
  * through `withBasePath` then `withAssetHash`, is memoized beside it so a
21
21
  * warm GET costs one WeakMap lookup.
22
22
  */
@@ -28,8 +28,7 @@ import { toUrlPath } from './preloads.js';
28
28
  /**
29
29
  * @typedef {object} ShippedDeps
30
30
  * @property {string} appDir
31
- * @property {Set<string> | undefined} inert inert page / layout files (never shipped)
32
- * @property {Map<string, string[]> | undefined} importOnly import-only module -> the component files emitted in its place
31
+ * @property {Map<string, string[]> | undefined} frontiers route module -> the component files the boot imports in its place
33
32
  * @property {Map<string, string[]> | undefined} lazyFiles lazy component file -> its tags
34
33
  * @property {string | undefined} instrumentationClient `instrumentation-client.*`, imported first when present
35
34
  */
@@ -47,14 +46,20 @@ const MEMO = new WeakMap();
47
46
  * The modules the client boot imports for `route`, plus the lazy frontier it
48
47
  * registers instead. Same object back for the same route within a build.
49
48
  *
50
- * @param {{ file: string, layouts: string[] }} route the matched route (its object identity is the memo key)
49
+ * @param {{ file: string, layouts: string[], loadings?: string[] }} route the matched route (its object identity is the memo key)
51
50
  * @param {ShippedDeps} deps
52
51
  * @returns {ShippedModules}
53
52
  */
54
53
  export function shippedBootModules(route, deps) {
55
54
  const hit = MEMO.get(route);
56
55
  if (hit && hit.epoch === buildEpoch()) return hit.value;
57
- const value = assembleBootModules([route.file, ...route.layouts], deps);
56
+ // Nesting order, outermost layout first and the page last, so an ancestor's
57
+ // elements are defined before a descendant's. A context provider in a layout
58
+ // then exists when a page component's context request fires on upgrade; with
59
+ // the page first, that one-shot request went unanswered and the consumer
60
+ // silently got no value. A loading template renders before the page streams
61
+ // in, so a component only it renders must register too.
62
+ const value = assembleBootModules([...route.layouts, ...(route.loadings || []), route.file], deps);
58
63
  MEMO.set(route, { epoch: buildEpoch(), value, hinted: null, hintedBasePath: '' });
59
64
  return value;
60
65
  }
@@ -72,7 +77,7 @@ export function shippedBootModules(route, deps) {
72
77
  * @returns {ShippedModules}
73
78
  */
74
79
  export function assembleBootModules(files, deps) {
75
- const { appDir, inert, importOnly, lazyFiles, instrumentationClient } = deps;
80
+ const { appDir, frontiers, lazyFiles, instrumentationClient } = deps;
76
81
  /** @type {string[]} */
77
82
  const moduleUrls = [];
78
83
  /** @type {Record<string, string>} */
@@ -84,11 +89,12 @@ export function assembleBootModules(files, deps) {
84
89
  if (tags) { for (const t of tags) frontierLazy[t] = u; return; }
85
90
  if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
86
91
  };
92
+ // A route module never ships: only its component frontier does. A file with
93
+ // no frontier entry (none computed yet, in the pre-warm window) emits
94
+ // nothing, never the file itself.
87
95
  for (const f of files) {
88
- if (inert && inert.has(f)) continue;
89
- const emit = importOnly && importOnly.get(f);
96
+ const emit = frontiers && frontiers.get(f);
90
97
  if (emit) emit.forEach(push);
91
- else push(f);
92
98
  }
93
99
  // instrumentation-client.{js,ts} (#848): import it FIRST in the client boot
94
100
  // so it runs before app modules (Next's instrumentation-client ordering).
@@ -137,7 +143,7 @@ export function earlyHintLinks(urls) {
137
143
  * imports, through `withBasePath` then `withAssetHash`, so the hint warms
138
144
  * exactly the url the body requests (#243). Memoized beside the assembly.
139
145
  *
140
- * @param {{ file: string, layouts: string[] }} route
146
+ * @param {{ file: string, layouts: string[], loadings?: string[] }} route
141
147
  * @param {ShippedDeps} deps
142
148
  * @param {string} basePath the normalized `webjs.basePath` (`''` at a root mount)
143
149
  * @returns {string[]}
package/src/testing.d.ts CHANGED
@@ -46,12 +46,22 @@ export declare function withSessionCookie(init: RequestInit | undefined, session
46
46
  * Submit a page's `<form action={action}>` the way a browser with JS OFF does:
47
47
  * render the page, reuse the identity the server put in its hidden
48
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`.
49
+ * omits that field is not a form submission and is answered `405`. Without
50
+ * `match`, it picks among the forms bound to an action, skipping a layout's
51
+ * plain form. When several remain and no `index` is given, it posts the one
52
+ * whose inputs hold every submitted field name, and throws (listing each form
53
+ * and its fields) when none or more than one does. A `File` / `Blob` value
54
+ * posts the form multipart.
55
+ *
56
+ * @example
57
+ * // A layout's bound logout form and the page's task form: the fields pick.
58
+ * const res = await submitForm(app.handle, '/projects/1', { title: 'Ship it' }, { cookies });
59
+ * assert.equal(res.status, 303);
50
60
  */
51
61
  export declare function submitForm(
52
62
  handle: Handle,
53
63
  path: string,
54
- fields?: Record<string, string | string[]> | URLSearchParams | FormData,
64
+ fields?: Record<string, string | string[] | Blob | Blob[]> | URLSearchParams | FormData,
55
65
  opts?: {
56
66
  index?: number;
57
67
  match?: string | RegExp;
@@ -91,6 +101,25 @@ export declare function actionEndpoint(
91
101
  * Round-trip an action through its REAL RPC endpoint (serializer + CSRF + prod
92
102
  * error sanitization) and return the parsed return value. Models a same-origin
93
103
  * browser POST, so it passes the Origin / Sec-Fetch-Site CSRF check.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * // test/tasks/add-task.test.ts, run with `webjs test`
108
+ * import { test } from 'node:test';
109
+ * import assert from 'node:assert/strict';
110
+ * import { createRequestHandler } from '@webjsdev/server';
111
+ * import { invokeActionForTest, submitForm } from '@webjsdev/server/testing';
112
+ *
113
+ * test('a blank title is refused', async () => {
114
+ * const app = await createRequestHandler({ appDir: process.cwd() });
115
+ * const result = await invokeActionForTest<{ success: boolean }>(
116
+ * app, 'modules/tasks/actions/add-task.server.ts', 'addTask', [{ title: '' }]);
117
+ * assert.equal(result.success, false);
118
+ * // The no-JS form path: renders /tasks, posts its bound form, 422 on failure.
119
+ * const res = await submitForm(app.handle, '/tasks', { title: '' });
120
+ * assert.equal(res.status, 422);
121
+ * });
122
+ * ```
94
123
  */
95
124
  export declare function invokeActionForTest<T = unknown>(
96
125
  app: AppOrHandle,
package/src/testing.js CHANGED
@@ -33,7 +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
+ import { FORM_ACTION_FIELD, redirect } from '@webjsdev/core';
37
37
  import { getSerializer } from './serializer.js';
38
38
  import { join, sep } from 'node:path';
39
39
 
@@ -269,15 +269,56 @@ function findForms(html) {
269
269
  return out;
270
270
  }
271
271
 
272
+ /**
273
+ * The submittable field names a form carries, `[]` suffixes dropped, the
274
+ * framework's own `__webjs_action` left out.
275
+ * @param {string} form
276
+ * @returns {string[]}
277
+ */
278
+ function formFieldNames(form) {
279
+ const names = new Set();
280
+ for (const m of form.matchAll(/<(?:input|select|textarea|button)\b[^>]*?\bname\s*=\s*["']([^"']+)["']/gi)) {
281
+ if (m[1] !== '__webjs_action') names.add(m[1].replace(/\[\]$/, ''));
282
+ }
283
+ return [...names];
284
+ }
285
+
272
286
  export async function submitForm(handle, path, fields = {}, opts = {}) {
273
287
  const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
274
288
  const page = await testRequest(handle, path, getInit);
275
289
  const html = await page.text();
276
290
 
277
291
  const forms = findForms(html);
278
- const candidates = opts.match
292
+ // Without `match`, a bound form is the one a test means: a layout's plain
293
+ // form (a logout POST, a search GET) often comes first on the page, and
294
+ // picking it reported the page's real form as unbound.
295
+ const isBound = (/** @type {string} */ f) => /name="__webjs_action"/.test(f);
296
+ let candidates = opts.match
279
297
  ? forms.filter((f) => (opts.match instanceof RegExp ? opts.match.test(f) : f.includes(opts.match)))
280
- : forms;
298
+ : (forms.some(isBound) ? forms.filter(isBound) : forms);
299
+ // Several candidates and no `index`: the form a test means is the one whose
300
+ // fields it is filling in (a layout's logout form beside a page's task form).
301
+ // When the field names do not single one out, refuse rather than post the
302
+ // first, which used to submit a logout silently and pass a later assertion.
303
+ if (candidates.length > 1 && opts.index === undefined) {
304
+ const keys = typeof fields.keys === 'function' ? [...new Set(fields.keys())] : Object.keys(fields);
305
+ const wanted = keys.map((k) => k.replace(/\[\]$/, ''));
306
+ const covering = candidates.filter((f) => {
307
+ const names = new Set(formFieldNames(f));
308
+ return wanted.every((k) => names.has(k));
309
+ });
310
+ if (covering.length !== 1) {
311
+ const list = candidates
312
+ .map((f) => ` ${f.slice(0, f.indexOf('>') + 1)} fields: ${formFieldNames(f).join(', ') || '(none)'}`)
313
+ .join('\n');
314
+ throw new Error(
315
+ `submitForm: ${candidates.length} ${opts.match ? 'matching' : 'bound'} forms on ${path}, and the submitted `
316
+ + `fields (${wanted.join(', ') || 'none'}) ${covering.length ? 'fit more than one' : 'fit none of them'}. `
317
+ + `Pick one with { match: 'name="title"' } or { index: N }:\n${list}`,
318
+ );
319
+ }
320
+ candidates = covering;
321
+ }
281
322
  const form = candidates[opts.index || 0];
282
323
  if (!form) {
283
324
  throw new Error(
@@ -287,30 +328,44 @@ export async function submitForm(handle, path, fields = {}, opts = {}) {
287
328
  }
288
329
  const id = form.match(/name="__webjs_action"\s+value="([^"]*)"/);
289
330
  if (!id) {
331
+ const openTag = form.slice(0, form.indexOf('>') + 1);
290
332
  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.',
333
+ `submitForm: the <form> on ${path} it picked (${openTag}) carries no __webjs_action `
334
+ + 'field, so it is not bound to a server action. Write it as `<form action=${myAction}>` '
335
+ + 'with the action imported from a `use server` module, or pick another form with '
336
+ + '{ match } / { index }.',
294
337
  );
295
338
  }
296
339
 
297
- const body = new URLSearchParams();
298
- body.set(FORM_ACTION_FIELD, id[1]);
299
- const entries = fields instanceof URLSearchParams || fields instanceof FormData
340
+ const entries = [...(fields instanceof URLSearchParams || fields instanceof FormData
300
341
  ? fields.entries()
301
- : Object.entries(fields);
342
+ : Object.entries(fields))];
343
+ // A File or Blob value posts multipart, the way a browser submits a file
344
+ // input; anything else posts URL-encoded.
345
+ const isBlob = (/** @type {unknown} */ v) => typeof Blob !== 'undefined' && v instanceof Blob;
346
+ const multipart = entries.some(([, v]) => isBlob(v) || (Array.isArray(v) && v.some(isBlob)));
347
+ const body = multipart ? new FormData() : new URLSearchParams();
348
+ body.set(FORM_ACTION_FIELD, id[1]);
349
+ /** @param {string} k @param {unknown} v */
350
+ const add = (k, v) => {
351
+ if (multipart && isBlob(v)) /** @type {FormData} */ (body).append(k, /** @type {Blob} */ (v));
352
+ else body.append(k, String(v));
353
+ };
302
354
  // An array value posts once per element, the way a checkbox group submits,
303
355
  // so a test can exercise the `name[]` convention (#1664).
304
356
  for (const [k, v] of entries) {
305
- if (Array.isArray(v)) for (const item of v) body.append(k, String(item));
306
- else body.append(k, String(v));
357
+ if (Array.isArray(v)) for (const item of v) add(k, item);
358
+ else add(k, v);
307
359
  }
308
360
 
309
- const init = {
310
- method: 'POST',
311
- headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
312
- body: body.toString(),
313
- };
361
+ // A FormData body sets its own multipart content type with the boundary.
362
+ const init = multipart
363
+ ? { method: 'POST', headers: { ...(opts.headers || {}) }, body }
364
+ : {
365
+ method: 'POST',
366
+ headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
367
+ body: body.toString(),
368
+ };
314
369
  return await testRequest(
315
370
  handle,
316
371
  opts.submitPath || path,
@@ -405,6 +460,14 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
405
460
  const res = await testRequest(handle, endpoint, { method: 'POST', headers, body });
406
461
 
407
462
  const text = await res.text();
463
+ // redirect() in the action: rethrow the sentinel, as the generated client
464
+ // stub rejects with it (the browser navigates instead of returning).
465
+ const rd = res.headers.get('x-webjs-redirect');
466
+ if (rd) {
467
+ const cut = rd.lastIndexOf(';');
468
+ const type = rd.slice(cut + 1) === 'replace' ? 'replace' : 'push';
469
+ redirect(cut === -1 ? rd : rd.slice(0, cut), type);
470
+ }
408
471
  const ct = res.headers.get('content-type') || '';
409
472
  let parsed;
410
473
  if (ct.includes(RPC_CONTENT_TYPE)) parsed = serializer.deserialize(text);
@@ -40,3 +40,29 @@ export function makeThenable(obj) {
40
40
  });
41
41
  return obj;
42
42
  }
43
+
44
+ /**
45
+ * A page's `searchParams` the Next.js way (#1686): each key maps to its value,
46
+ * and a key that repeats (`?tag=a&tag=b`) maps to every value in order, as an
47
+ * array. `Object.fromEntries` kept only the last one, so a multi-select filter
48
+ * read one choice.
49
+ * @param {URLSearchParams} params
50
+ * @returns {Record<string, string | string[]>}
51
+ */
52
+ export function searchParamsRecord(params) {
53
+ /** @type {Record<string, string | string[]>} */
54
+ const out = {};
55
+ for (const [k, v] of params) {
56
+ // Own keys only, and defined rather than assigned: `?constructor=x` must
57
+ // not read Object.prototype's member, and `?__proto__=x` must be a key,
58
+ // never the record's prototype.
59
+ if (!Object.hasOwn(out, k)) {
60
+ Object.defineProperty(out, k, { value: v, writable: true, enumerable: true, configurable: true });
61
+ continue;
62
+ }
63
+ const prev = out[k];
64
+ if (Array.isArray(prev)) prev.push(v);
65
+ else out[k] = [/** @type {string} */ (prev), v];
66
+ }
67
+ return out;
68
+ }
@@ -1,4 +1,5 @@
1
1
  import { BUILTIN, FRAMEWORK_SERVER_ONLY, extractPackageName } from './scanner.js';
2
+ import { foreignImport } from '../foreign-imports.js';
2
3
  import { getPackageVersion } from './manifest.js';
3
4
  import { normalizeProvider } from './providers.js';
4
5
 
@@ -325,7 +326,7 @@ export async function vendorImportMapEntries(bareImports, appDir) {
325
326
  for (const spec of bareImports) {
326
327
  if (BUILTIN.has(spec)) continue;
327
328
  const pkg = extractPackageName(spec);
328
- if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
329
+ if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg) || foreignImport(spec)) continue;
329
330
  const version = getPackageVersion(pkg, appDir);
330
331
  if (!version) continue;
331
332
  // Splice the version into the specifier: 'dayjs/plugin/utc' with
@@ -346,3 +347,13 @@ export function clearJspmCache() {
346
347
  jspmCache.clear();
347
348
  }
348
349
 
350
+ /**
351
+ * The number of cached jspm resolves. A test seam (#1689): the dev rebuild test
352
+ * reads it to prove an ordinary edit keeps the cache and a `package.json` edit
353
+ * clears it. Not re-exported from the package.
354
+ * @returns {number}
355
+ */
356
+ export function _jspmCacheSize() {
357
+ return jspmCache.size;
358
+ }
359
+
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { BUILTIN, FRAMEWORK_SERVER_ONLY, extractPackageName, scanBareImports } from './scanner.js';
6
+ import { foreignImport } from '../foreign-imports.js';
6
7
  import { getPackageVersion, getPackageDeps } from './manifest.js';
7
8
  import { SUPPORTED_PROVIDERS } from './providers.js';
8
9
  import { PIN_BUNDLE_TIMEOUT_MS, fetchIntegrity, sha384Integrity } from './integrity.js';
@@ -494,12 +495,12 @@ export function basePackage(spec) {
494
495
 
495
496
  /**
496
497
  * Prune a pinned import map to the vendor specifiers still reachable from
497
- * NON-elided modules. A committed pin is the whole map, but elision can make
498
- * a pinned package unreachable (its only importer is a display-only component
499
- * that ships no JS, e.g. dayjs via the blog's vendor-badge). The live-resolve
500
- * path prunes such a package by excluding elided components from the bare-
501
- * import scan; this brings the pinned path to the same result, so a pinned app
502
- * and an unpinned app serve the same import map (issue #197).
498
+ * modules the browser loads. A committed pin is the whole map, but a pinned
499
+ * package can be unreachable from the browser (its only importer is a route
500
+ * module, which runs only on the server). The live-resolve path prunes such a
501
+ * package by excluding the route-table files from the bare-import scan; this
502
+ * brings the pinned path to the same result, so a pinned app and an unpinned
503
+ * app serve the same import map (issue #197).
503
504
  *
504
505
  * Keeps an entry when its specifier is reachable, OR when its base package is
505
506
  * the base of any reachable specifier (so a pinned base entry `dayjs` survives
@@ -520,7 +521,7 @@ export function basePackage(spec) {
520
521
  *
521
522
  * @param {Record<string, string>} imports pin entries (specifier -> URL)
522
523
  * @param {Record<string, string>} integrity SRI hashes keyed by URL
523
- * @param {Set<string>} reachable bare specifiers used by non-elided modules
524
+ * @param {Set<string>} reachable bare specifiers used by modules the browser loads
524
525
  * @param {{ appDir?: string }} [opts]
525
526
  * @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
526
527
  */
@@ -699,7 +700,7 @@ export async function pinAll(appDir, opts = {}) {
699
700
  for (const spec of bare) {
700
701
  if (BUILTIN.has(spec)) continue;
701
702
  const pkg = extractPackageName(spec);
702
- if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
703
+ if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg) || foreignImport(spec)) continue;
703
704
  const version = getPackageVersion(pkg, appDir);
704
705
  if (!version) {
705
706
  droppedUnresolvable.push(spec);
@@ -1,7 +1,9 @@
1
1
  import { buildModuleGraph, reachableBareSpecifiers } from '../module-graph.js';
2
2
  import { browserEntryFiles } from '../browser-entries.js';
3
+ import { routeTableFiles } from '../route-frontier.js';
3
4
  import { scanComponents } from '../component-scanner.js';
4
5
  import { buildRouteTable } from '../router.js';
6
+ import { foreignImport } from '../foreign-imports.js';
5
7
 
6
8
  /**
7
9
  * Set of package names whose importmap entries are populated by the
@@ -84,11 +86,10 @@ export function extractPackageName(spec) {
84
86
  *
85
87
  * This is the OUT-OF-PROCESS entry point (`pinAll`, `webjs doctor`). It builds
86
88
  * its own graph, route table and component scan, roughly 70-200ms on the
87
- * in-repo apps, and applies NO elision pruning, so the result is a SUPERSET of
88
- * what the running server serves. That superset relation is what lets
89
- * `prunePinToReachable` intersect a committed pin down to the runtime answer
90
- * (#197). The dev server calls `reachedBareImports` instead, with the graph it
91
- * already has and its elision skip set.
89
+ * in-repo apps, and skips the same route-table files the running server does,
90
+ * so the result is what that server serves. `prunePinToReachable` intersects a
91
+ * committed pin down to it (#197). The dev server calls `reachedBareImports`
92
+ * instead, with the graph it already has.
92
93
  *
93
94
  * @param {string} appDir
94
95
  * @returns {Promise<Set<string>>}
@@ -101,22 +102,22 @@ export async function scanBareImports(appDir) {
101
102
  routeTable = await buildRouteTable(appDir);
102
103
  } catch {
103
104
  // An app the analysis cannot process yields no vendor specifiers rather
104
- // than a throw, matching how check.js and elision-report.js degrade. The
105
+ // than a throw, matching how check.js and route-report.js degrade. The
105
106
  // dev server surfaces the real problem.
106
107
  return new Set();
107
108
  }
108
- return reachedBareImports(graph, [...browserEntryFiles(routeTable, components)], appDir);
109
+ return reachedBareImports(graph, [...browserEntryFiles(routeTable, components)], appDir, routeTableFiles(routeTable));
109
110
  }
110
111
 
111
112
  /**
112
113
  * The in-process form: the caller already has a module graph, the entry set,
113
- * and (on the runtime path) the elision skip set. Used by the dev server so a
114
+ * and the route-table skip set. Used by the dev server so a
114
115
  * vendor resolve does not rebuild analysis it just built.
115
116
  *
116
117
  * @param {import('../module-graph.js').ModuleGraph} graph
117
118
  * @param {string[]} entryFiles
118
119
  * @param {string} appDir
119
- * @param {Set<string>} [skip] elided / inert / import-only modules
120
+ * @param {Set<string>} [skip] files never loaded in the browser (the route-table files)
120
121
  * @returns {Set<string>}
121
122
  */
122
123
  export function reachedBareImports(graph, entryFiles, appDir, skip) {
@@ -127,6 +128,10 @@ export function reachedBareImports(graph, entryFiles, appDir, skip) {
127
128
  for (const spec of found) {
128
129
  const p = extractPackageName(spec);
129
130
  if (p && (BUILTIN.has(p) || FRAMEWORK_SERVER_ONLY.has(p))) found.delete(spec);
131
+ // A foreign framework (`lit`, `react`, `next/*`) is refused at check and
132
+ // at analysis time; it must never be resolved from jspm meanwhile, or a
133
+ // second component runtime would load from the CDN beside WebJs.
134
+ else if (foreignImport(spec)) found.delete(spec);
130
135
  }
131
136
  return found;
132
137
  }
package/src/vendor.js CHANGED
@@ -52,9 +52,11 @@ import { clearJspmCache } from './vendor/jspm.js';
52
52
  import { clearLiveIntegrityCache } from './vendor/resolver.js';
53
53
 
54
54
  /**
55
- * Clear the resolved-importmap cache. Called on file-watcher rebuild
56
- * so newly-added bare imports trigger a fresh api.jspm.io/generate
57
- * call on the next request to populate the in-memory cache.
55
+ * Clear the resolved-importmap cache. A dev rebuild calls it only when the
56
+ * app's `package.json` changed, or when the caller named no changed files
57
+ * (#1689): the cache is keyed by the sorted install set and the SRI by url, so
58
+ * a newly-added bare import already misses it, and only a dependency version
59
+ * change can make a hit stale.
58
60
  */
59
61
  export function clearVendorCache() {
60
62
  clearJspmCache();
@@ -7,11 +7,6 @@
7
7
  "type": "object",
8
8
  "additionalProperties": false,
9
9
  "properties": {
10
- "elide": {
11
- "description": "Display-only and inert-route dead-JS elision switch. Default true. Set to false to ship every module's JS app-wide, as before the feature existed. Read by readElideEnabled in dev.js; the WEBJS_ELIDE env override wins over this.",
12
- "type": "boolean",
13
- "default": true
14
- },
15
10
  "seed": {
16
11
  "description": "SSR action-result seeding switch (#472). Default true. When on, a 'use server' action's SSR result is serialized into the page so an async-render component does not re-fetch it over RPC on hydration. Set to false to disable (the client re-fetches as before). Read by readSeedEnabled in dev.js; the WEBJS_SEED env override wins over this.",
17
12
  "type": "boolean",
@@ -242,7 +237,7 @@
242
237
  "additionalProperties": false,
243
238
  "properties": {
244
239
  "gate": {
245
- "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.",
240
+ "description": "Per-check severity, keyed by the stable DOCTOR_CODES code (NODE_VERSION, UNMARKED_ASSET_LINKS, ROUTE_BROWSER_CODE, ...). `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.",
246
241
  "type": "object",
247
242
  "propertyNames": { "pattern": "^[A-Z][A-Z0-9_]*$" },
248
243
  "additionalProperties": {