@webjsdev/server 0.9.0 → 0.10.1

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 (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  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 +97 -15
  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 +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. 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
@@ -23,6 +23,23 @@ export declare function toRequest(input: string | Request, init?: RequestInit):
23
23
  /**
24
24
  * Fire a request through the real `handle()` pipeline and return the `Response`.
25
25
  * The documented one-liner: `await testRequest(app.handle, '/about')`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // test/posts/posts.test.ts, run with `webjs test`
30
+ * import { test } from 'node:test';
31
+ * import assert from 'node:assert/strict';
32
+ * import { createRequestHandler } from '@webjsdev/server';
33
+ * import { testRequest } from '@webjsdev/server/testing';
34
+ *
35
+ * test('the posts API creates a post', async () => {
36
+ * const app = await createRequestHandler({ appDir: process.cwd() });
37
+ * const res = await testRequest(app.handle, '/api/posts', { // a bare path is fine
38
+ * method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Hi' }),
39
+ * });
40
+ * assert.equal(res.status, 201);
41
+ * });
42
+ * ```
26
43
  */
27
44
  export declare function testRequest(
28
45
  handle: Handle,
@@ -46,16 +63,32 @@ export declare function withSessionCookie(init: RequestInit | undefined, session
46
63
  * Submit a page's `<form action={action}>` the way a browser with JS OFF does:
47
64
  * render the page, reuse the identity the server put in its hidden
48
65
  * `__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`.
66
+ * omits that field is not a form submission and is answered `405`. Without
67
+ * `match`, it picks among the forms bound to an action, skipping a layout's
68
+ * plain form. When several remain and no `index` is given, it posts the one
69
+ * whose inputs hold every submitted field name, and throws (listing each form
70
+ * and its fields) when none or more than one does. A `File` / `Blob` value
71
+ * posts the form multipart.
72
+ *
73
+ * @example
74
+ * // A layout's bound logout form and the page's task form: the fields pick.
75
+ * const res = await submitForm(app.handle, '/projects/1', { title: 'Ship it' }, { cookies });
76
+ * assert.equal(res.status, 303);
50
77
  */
51
78
  export declare function submitForm(
52
79
  handle: Handle,
53
80
  path: string,
54
- fields?: Record<string, string | string[]> | URLSearchParams | FormData,
81
+ fields?: Record<string, string | string[] | Blob | Blob[]> | URLSearchParams | FormData,
55
82
  opts?: {
56
83
  index?: number;
57
84
  match?: string | RegExp;
58
85
  cookies?: string;
86
+ /**
87
+ * The page's HTML as the test fetched it earlier. The form is taken from it
88
+ * instead of from a fresh render, so a test can submit a form the page no
89
+ * longer shows (a vote on a poll closed since the page loaded).
90
+ */
91
+ html?: string;
59
92
  submitPath?: string;
60
93
  headers?: Record<string, string>;
61
94
  },
@@ -64,6 +97,23 @@ export declare function submitForm(
64
97
  /**
65
98
  * Drive the REAL credentials login (`POST /api/auth/signin/credentials` by
66
99
  * default) and capture the genuine signed session `Set-Cookie`.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * // test/dashboard.test.ts, run with `webjs test`. Needs createAuth's handlers at
104
+ * // app/api/auth/[...path]/route.ts (export const { GET, POST } = handlers).
105
+ * import { test } from 'node:test';
106
+ * import assert from 'node:assert/strict';
107
+ * import { createRequestHandler } from '@webjsdev/server';
108
+ * import { loginAndGetCookies, testRequest, withSessionCookie } from '@webjsdev/server/testing';
109
+ *
110
+ * test('a signed-in user sees the dashboard', async () => {
111
+ * const app = await createRequestHandler({ appDir: process.cwd() });
112
+ * const { cookies } = await loginAndGetCookies(app.handle, { email: 'ada@example.com', password: 'correct horse' });
113
+ * const res = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));
114
+ * assert.equal(res.status, 200); // the user must exist first: seed it or sign up in the test
115
+ * });
116
+ * ```
67
117
  */
68
118
  export declare function loginAndGetCookies(
69
119
  handle: Handle,
@@ -91,6 +141,25 @@ export declare function actionEndpoint(
91
141
  * Round-trip an action through its REAL RPC endpoint (serializer + CSRF + prod
92
142
  * error sanitization) and return the parsed return value. Models a same-origin
93
143
  * browser POST, so it passes the Origin / Sec-Fetch-Site CSRF check.
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * // test/tasks/add-task.test.ts, run with `webjs test`
148
+ * import { test } from 'node:test';
149
+ * import assert from 'node:assert/strict';
150
+ * import { createRequestHandler } from '@webjsdev/server';
151
+ * import { invokeActionForTest, submitForm } from '@webjsdev/server/testing';
152
+ *
153
+ * test('a blank title is refused', async () => {
154
+ * const app = await createRequestHandler({ appDir: process.cwd() });
155
+ * const result = await invokeActionForTest<{ success: boolean }>(
156
+ * app, 'modules/tasks/actions/add-task.server.ts', 'addTask', [{ title: '' }]);
157
+ * assert.equal(result.success, false);
158
+ * // The no-JS form path: renders /tasks, posts its bound form, 422 on failure.
159
+ * const res = await submitForm(app.handle, '/tasks', { title: '' });
160
+ * assert.equal(res.status, 422);
161
+ * });
162
+ * ```
94
163
  */
95
164
  export declare function invokeActionForTest<T = unknown>(
96
165
  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
 
@@ -213,6 +213,10 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
213
213
  * status is wrong, an assertion fails, and a `catch` around it reports a skip.
214
214
  * Hence a helper, so the identity is never the test's business.
215
215
  *
216
+ * Pass `opts.html` (the page's HTML the test fetched earlier) to submit the
217
+ * form as that earlier page load had it, the way a browser tab left open
218
+ * submits a stale form, rather than as the page renders now.
219
+ *
216
220
  * The form is located by INDEX (`opts.index`, default the first on the page),
217
221
  * which is enough for the common one-form page. For a page with several, pass
218
222
  * `opts.match`: a string or RegExp that must appear in the form's markup, so
@@ -226,6 +230,7 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
226
230
  * index?: number,
227
231
  * match?: string | RegExp,
228
232
  * cookies?: string,
233
+ * html?: string,
229
234
  * submitPath?: string,
230
235
  * headers?: Record<string, string>,
231
236
  * }} [opts]
@@ -269,48 +274,117 @@ function findForms(html) {
269
274
  return out;
270
275
  }
271
276
 
277
+ /**
278
+ * The submittable field names a form carries, `[]` suffixes dropped, the
279
+ * framework's own `__webjs_action` left out.
280
+ * @param {string} form
281
+ * @returns {string[]}
282
+ */
283
+ function formFieldNames(form) {
284
+ const names = new Set();
285
+ for (const m of form.matchAll(/<(?:input|select|textarea|button)\b[^>]*?\bname\s*=\s*["']([^"']+)["']/gi)) {
286
+ if (m[1] !== '__webjs_action') names.add(m[1].replace(/\[\]$/, ''));
287
+ }
288
+ return [...names];
289
+ }
290
+
272
291
  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();
292
+ // `html`: the page as the test already fetched it. A form captured before
293
+ // the state changed (a poll closed since, a row deleted) is no longer on a
294
+ // fresh render, and the browser that loaded the page earlier can still
295
+ // submit it, so the test submits that copy instead of re-fetching.
296
+ let status = 'from opts.html';
297
+ let html = opts.html;
298
+ if (html === undefined) {
299
+ const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
300
+ const page = await testRequest(handle, path, getInit);
301
+ status = `status ${page.status}`;
302
+ html = await page.text();
303
+ }
276
304
 
277
305
  const forms = findForms(html);
278
- const candidates = opts.match
306
+ // Without `match`, a bound form is the one a test means: a layout's plain
307
+ // form (a logout POST, a search GET) often comes first on the page, and
308
+ // picking it reported the page's real form as unbound.
309
+ const isBound = (/** @type {string} */ f) => /name="__webjs_action"/.test(f);
310
+ let candidates = opts.match
279
311
  ? forms.filter((f) => (opts.match instanceof RegExp ? opts.match.test(f) : f.includes(opts.match)))
280
- : forms;
312
+ : (forms.some(isBound) ? forms.filter(isBound) : forms);
313
+ // Several candidates and no `index`: the form a test means is the one whose
314
+ // fields it is filling in (a layout's logout form beside a page's task form).
315
+ // When the field names do not single one out, refuse rather than post the
316
+ // first, which used to submit a logout silently and pass a later assertion.
317
+ if (candidates.length > 1 && opts.index === undefined) {
318
+ const keys = typeof fields.keys === 'function' ? [...new Set(fields.keys())] : Object.keys(fields);
319
+ const wanted = keys.map((k) => k.replace(/\[\]$/, ''));
320
+ const covering = candidates.filter((f) => {
321
+ const names = new Set(formFieldNames(f));
322
+ return wanted.every((k) => names.has(k));
323
+ });
324
+ if (covering.length !== 1) {
325
+ const list = candidates
326
+ .map((f) => ` ${f.slice(0, f.indexOf('>') + 1)} fields: ${formFieldNames(f).join(', ') || '(none)'}`)
327
+ .join('\n');
328
+ throw new Error(
329
+ `submitForm: ${candidates.length} ${opts.match ? 'matching' : 'bound'} forms on ${path}, and the submitted `
330
+ + `fields (${wanted.join(', ') || 'none'}) ${covering.length ? 'fit more than one' : 'fit none of them'}. `
331
+ + `Pick one with { match: 'name="title"' } or { index: N }:\n${list}`,
332
+ );
333
+ }
334
+ candidates = covering;
335
+ }
281
336
  const form = candidates[opts.index || 0];
282
337
  if (!form) {
283
338
  throw new Error(
284
339
  `submitForm: no ${opts.match ? 'matching ' : ''}<form> found on ${path} `
285
- + `(status ${page.status}, ${forms.length} form(s) on the page).`,
340
+ + `(${status}, ${forms.length} form(s) on the page).`
341
+ + (opts.html === undefined
342
+ ? ' submitForm renders the page afresh, so a form the page showed earlier and no longer does (a poll closed, '
343
+ + 'a row deleted) is gone. To submit the form as an earlier page load had it, pass that HTML: '
344
+ + 'const html = await (await testRequest(app.handle, path)).text(), then submitForm(app.handle, path, fields, { html }).'
345
+ : ''),
286
346
  );
287
347
  }
288
348
  const id = form.match(/name="__webjs_action"\s+value="([^"]*)"/);
289
349
  if (!id) {
350
+ const openTag = form.slice(0, form.indexOf('>') + 1);
290
351
  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.',
352
+ `submitForm: the <form> on ${path} it picked (${openTag}) carries no __webjs_action `
353
+ + 'field, so it is not bound to a server action. Write it as `<form action=${myAction}>` '
354
+ + 'with the action imported from a `use server` module, or pick another form with '
355
+ + '{ match } / { index }.',
294
356
  );
295
357
  }
296
358
 
297
- const body = new URLSearchParams();
298
- body.set(FORM_ACTION_FIELD, id[1]);
299
- const entries = fields instanceof URLSearchParams || fields instanceof FormData
359
+ const entries = [...(fields instanceof URLSearchParams || fields instanceof FormData
300
360
  ? fields.entries()
301
- : Object.entries(fields);
361
+ : Object.entries(fields))];
362
+ // A File or Blob value posts multipart, the way a browser submits a file
363
+ // input; anything else posts URL-encoded.
364
+ const isBlob = (/** @type {unknown} */ v) => typeof Blob !== 'undefined' && v instanceof Blob;
365
+ const multipart = entries.some(([, v]) => isBlob(v) || (Array.isArray(v) && v.some(isBlob)));
366
+ const body = multipart ? new FormData() : new URLSearchParams();
367
+ body.set(FORM_ACTION_FIELD, id[1]);
368
+ /** @param {string} k @param {unknown} v */
369
+ const add = (k, v) => {
370
+ if (multipart && isBlob(v)) /** @type {FormData} */ (body).append(k, /** @type {Blob} */ (v));
371
+ else body.append(k, String(v));
372
+ };
302
373
  // An array value posts once per element, the way a checkbox group submits,
303
374
  // so a test can exercise the `name[]` convention (#1664).
304
375
  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));
376
+ if (Array.isArray(v)) for (const item of v) add(k, item);
377
+ else add(k, v);
307
378
  }
308
379
 
309
- const init = {
310
- method: 'POST',
311
- headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
312
- body: body.toString(),
313
- };
380
+ // A FormData body sets its own multipart content type with the boundary.
381
+ const init = multipart
382
+ ? { method: 'POST', headers: { ...(opts.headers || {}) }, body }
383
+ : {
384
+ method: 'POST',
385
+ headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
386
+ body: body.toString(),
387
+ };
314
388
  return await testRequest(
315
389
  handle,
316
390
  opts.submitPath || path,
@@ -405,6 +479,14 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
405
479
  const res = await testRequest(handle, endpoint, { method: 'POST', headers, body });
406
480
 
407
481
  const text = await res.text();
482
+ // redirect() in the action: rethrow the sentinel, as the generated client
483
+ // stub rejects with it (the browser navigates instead of returning).
484
+ const rd = res.headers.get('x-webjs-redirect');
485
+ if (rd) {
486
+ const cut = rd.lastIndexOf(';');
487
+ const type = rd.slice(cut + 1) === 'replace' ? 'replace' : 'push';
488
+ redirect(cut === -1 ? rd : rd.slice(0, cut), type);
489
+ }
408
490
  const ct = res.headers.get('content-type') || '';
409
491
  let parsed;
410
492
  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
  }