@webjsdev/server 0.8.91 → 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 (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. 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).
@@ -102,12 +108,42 @@ export function assembleBootModules(files, deps) {
102
108
  return { moduleUrls, frontierLazy };
103
109
  }
104
110
 
111
+ /**
112
+ * The 103 Early Hints `Link` values for the boot's modules: a plain
113
+ * `rel=preload; as=script; crossorigin=anonymous` per url, NEVER
114
+ * `rel=modulepreload`.
115
+ *
116
+ * A module hint in a 103 arrives before the document, so it is processed
117
+ * before the page's `<script type="importmap">` can be parsed. Firefox treats
118
+ * a modulepreload as the start of module loading and then refuses the import
119
+ * map ("Import maps are not allowed after a module load or preload has
120
+ * started"), so every bare `@webjsdev/core` import failed and no script ran on
121
+ * a Node-served page. The HTML spec gives modulepreload no early-hint
122
+ * behaviour at all (its "process a link header" steps do nothing), and
123
+ * Chromium acts only on preload and preconnect there, so the modulepreload
124
+ * hint was a no-op in Chromium and fatal in Firefox.
125
+ *
126
+ * A plain preload only puts the bytes in the preload cache: it creates no
127
+ * module script, resolves no specifier and fetches no descendants, so it
128
+ * cannot run ahead of the import map on any engine. `as=script` with
129
+ * `crossorigin=anonymous` is a CORS fetch with same-origin credentials,
130
+ * which is exactly the request a module script makes, so the body's
131
+ * `<link rel="modulepreload">` and the boot's import reuse the response
132
+ * instead of fetching it again.
133
+ *
134
+ * @param {string[]} urls the hinted boot module urls (`hintedBootModules`)
135
+ * @returns {string[]}
136
+ */
137
+ export function earlyHintLinks(urls) {
138
+ return urls.map((u) => `<${u}>; rel=preload; as=script; crossorigin=anonymous`);
139
+ }
140
+
105
141
  /**
106
142
  * The 103 Early Hints url list for `route`: the SAME modules the boot
107
143
  * imports, through `withBasePath` then `withAssetHash`, so the hint warms
108
144
  * exactly the url the body requests (#243). Memoized beside the assembly.
109
145
  *
110
- * @param {{ file: string, layouts: string[] }} route
146
+ * @param {{ file: string, layouts: string[], loadings?: string[] }} route
111
147
  * @param {ShippedDeps} deps
112
148
  * @param {string} basePath the normalized `webjs.basePath` (`''` at a root mount)
113
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> | 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
 
@@ -221,7 +221,7 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
221
221
  *
222
222
  * @param {Handle} handle
223
223
  * @param {string} path the page to render AND, by default, to submit back to
224
- * @param {Record<string, string> | URLSearchParams | FormData} [fields]
224
+ * @param {Record<string, string | string[]> | URLSearchParams | FormData} [fields]
225
225
  * @param {{
226
226
  * index?: number,
227
227
  * match?: string | RegExp,
@@ -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,25 +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);
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(),
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));
308
353
  };
354
+ // An array value posts once per element, the way a checkbox group submits,
355
+ // so a test can exercise the `name[]` convention (#1664).
356
+ for (const [k, v] of entries) {
357
+ if (Array.isArray(v)) for (const item of v) add(k, item);
358
+ else add(k, v);
359
+ }
360
+
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
+ };
309
369
  return await testRequest(
310
370
  handle,
311
371
  opts.submitPath || path,
@@ -400,6 +460,14 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
400
460
  const res = await testRequest(handle, endpoint, { method: 'POST', headers, body });
401
461
 
402
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
+ }
403
471
  const ct = res.headers.get('content-type') || '';
404
472
  let parsed;
405
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();