@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.
- package/README.md +4 -1
- package/index.d.ts +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +96 -14
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +140 -65
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/ssr/responses.js
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
19
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
306
|
-
else
|
|
357
|
+
if (Array.isArray(v)) for (const item of v) add(k, item);
|
|
358
|
+
else add(k, v);
|
|
307
359
|
}
|
|
308
360
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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);
|
package/src/thenable-params.js
CHANGED
|
@@ -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
|
+
}
|
package/src/vendor/jspm.js
CHANGED
|
@@ -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
|
+
|
package/src/vendor/pins.js
CHANGED
|
@@ -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
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
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
|
|
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);
|
package/src/vendor/scanner.js
CHANGED
|
@@ -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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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
|
|
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]
|
|
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.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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();
|
package/webjs-config.schema.json
CHANGED
|
@@ -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,
|
|
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": {
|