@webjsdev/server 0.8.58 → 0.8.60
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/index.d.ts +125 -12
- package/index.js +2 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +438 -70
- package/src/actions.js +10 -2
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/component-elision.js +75 -16
- package/src/component-scanner.js +82 -26
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +151 -33
- package/src/elision-differential.js +101 -0
- package/src/elision-report.js +133 -30
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +666 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/js-scan.js +41 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +93 -17
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/ssr.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
|
|
4
|
-
import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
|
|
4
|
+
import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets, buildImportMap } from './importmap.js';
|
|
5
5
|
import { withBasePath } from './base-path.js';
|
|
6
6
|
import { withAssetHash } from './asset-hash.js';
|
|
7
7
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
8
8
|
import { transitiveDeps, bareImports } from './module-graph.js';
|
|
9
|
-
import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
|
|
9
|
+
import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from './action-seed.js';
|
|
10
10
|
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
11
11
|
import {
|
|
12
12
|
readRevalidate,
|
|
@@ -41,7 +41,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
41
41
|
// and is threaded back through `opts.pageModule` so renderChain reuses the
|
|
42
42
|
// same evaluation (no double-load). A cache HIT serves the stored HTML
|
|
43
43
|
// without re-running the page function. Skipped entirely (no opt-in read,
|
|
44
|
-
// no double behaviour) for the
|
|
44
|
+
// no double behaviour) for the form-action re-render (actionData / a non-200
|
|
45
45
|
// status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
|
|
46
46
|
// on the request and must not be shared under the full-URL key.
|
|
47
47
|
const cacheEligible =
|
|
@@ -57,7 +57,15 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
57
57
|
revalidateSeconds = readRevalidate(pageMod);
|
|
58
58
|
if (revalidateSeconds !== null) {
|
|
59
59
|
const hit = await readHtmlCache(url);
|
|
60
|
-
if (hit)
|
|
60
|
+
if (hit) {
|
|
61
|
+
const cached = cachedHtmlResponse(hit, opts.req, url);
|
|
62
|
+
// A cache hit returns before any seed work runs, so it would otherwise
|
|
63
|
+
// report nothing at all, which reads as "seeding is broken" when it is
|
|
64
|
+
// really the cache answering (#1309). The seed block rides INSIDE the
|
|
65
|
+
// cached bytes, so the seeds are exactly as fresh as the HTML.
|
|
66
|
+
if (opts.dev) cached.headers.set('X-Webjs-Seed', 'html-cache');
|
|
67
|
+
return cached;
|
|
68
|
+
}
|
|
61
69
|
}
|
|
62
70
|
} catch {
|
|
63
71
|
// A load / store failure falls through to a normal fresh render: the
|
|
@@ -288,16 +296,40 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
288
296
|
// block (those slow regions keep the stale-while-revalidate refetch). An
|
|
289
297
|
// empty collector yields '' so the output stays byte-identical.
|
|
290
298
|
let outBody = streamBody;
|
|
291
|
-
|
|
292
|
-
|
|
299
|
+
// Dev-only seeding diagnostics (#1309). `off` (seeding disabled) is kept
|
|
300
|
+
// DISTINCT from `collected=0` on purpose: the counting lives with the
|
|
301
|
+
// collector rather than behind the seed gate, so a seeding-DISABLED app
|
|
302
|
+
// never looks like a seeding-BROKEN one.
|
|
303
|
+
let seedHeader = 'off';
|
|
304
|
+
const streamed = suspenseCtx.pending.length > 0;
|
|
305
|
+
if (seedCollector && streamed) {
|
|
306
|
+
// A streamed render's deferred boundaries resolve AFTER the first flush,
|
|
307
|
+
// so their results cannot ride this block and none is emitted in prod. In
|
|
308
|
+
// DEV emit the marker alone, so the client reports the CAUSE instead of
|
|
309
|
+
// leaving the developer to guess why every action call went to the network.
|
|
310
|
+
seedHeader = `collected=${seedCollector.size}, emitted=0, streamed`;
|
|
311
|
+
if (opts.dev) {
|
|
312
|
+
const marker = await buildSeedScript(null, { dev: true, reason: 'streamed' });
|
|
313
|
+
if (marker) outBody = streamBody + marker;
|
|
314
|
+
}
|
|
315
|
+
} else if (seedCollector) {
|
|
316
|
+
const seedScript = await buildSeedScript(seedCollector, { dev: opts.dev });
|
|
293
317
|
if (seedScript) outBody = streamBody + seedScript;
|
|
318
|
+
// `emitted` differs from `collected` exactly when the serializer threw and
|
|
319
|
+
// dropped the whole block, which is otherwise a completely invisible
|
|
320
|
+
// failure. In DEV that throw still emits a marker-only block, so the
|
|
321
|
+
// browser can name the cause; it carries no seeds, so it must NOT count as
|
|
322
|
+
// emitted or the header would report a total success on the very failure
|
|
323
|
+
// it exists to expose.
|
|
324
|
+
const dropped = seedScript === SEED_DROP_BLOCK;
|
|
325
|
+
seedHeader = `collected=${seedCollector.size}, emitted=${seedScript && !dropped ? seedCollector.size : 0}`;
|
|
294
326
|
}
|
|
295
327
|
const res = streamingHtmlResponse(
|
|
296
328
|
prefix,
|
|
297
329
|
outBody,
|
|
298
330
|
closer,
|
|
299
331
|
suspenseCtx,
|
|
300
|
-
// Normally 200. After a failed
|
|
332
|
+
// Normally 200. After a failed form-action submission the caller passes
|
|
301
333
|
// 422 (or another 4xx) so the re-rendered page with field errors carries
|
|
302
334
|
// the right status for both the no-JS reload and the enhanced swap (#244).
|
|
303
335
|
opts.status || 200,
|
|
@@ -307,6 +339,11 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
307
339
|
nonce,
|
|
308
340
|
opts.dev,
|
|
309
341
|
);
|
|
342
|
+
// Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a
|
|
343
|
+
// hit from the outside, so an app whose seeding silently broke looks exactly
|
|
344
|
+
// like one where it works. Dev only: a production header would publish how
|
|
345
|
+
// many server calls a page made, for no benefit.
|
|
346
|
+
if (opts.dev) res.headers.set('X-Webjs-Seed', seedHeader);
|
|
310
347
|
// REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
|
|
311
348
|
// outer-layout chrome, so these bytes are only valid for a request that
|
|
312
349
|
// sent a matching `have`. Left shared-cacheable, a CDN edge could store the
|
|
@@ -346,7 +383,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
346
383
|
// navigation (an auth bounce, a gate). 302 Found is the conventional
|
|
347
384
|
// code there, so it is the default when the caller did not pick one. An
|
|
348
385
|
// explicit `redirect(url, status)` overrides it. (Action redirects, a
|
|
349
|
-
// POST, default to 307 in
|
|
386
|
+
// POST, default to 307 in form-dispatch.js so the method is preserved.)
|
|
350
387
|
return new Response(null, { status: e.status || 302, headers: { location: e.url } });
|
|
351
388
|
}
|
|
352
389
|
if (isNotFound(err)) {
|
|
@@ -459,7 +496,7 @@ export async function ssrNotFound(notFoundFile, opts) {
|
|
|
459
496
|
/**
|
|
460
497
|
* 403 response for a thrown forbidden() (#848). Renders the nearest
|
|
461
498
|
* forbidden.{js,ts} in the page's chain, else a default 403 page. Shared by the
|
|
462
|
-
* page-render catch (ssr.js) and the
|
|
499
|
+
* page-render catch (ssr.js) and the form-action write path (form-dispatch.js) so
|
|
463
500
|
* both behave identically.
|
|
464
501
|
* @param {{ forbiddens?: string[] }} route
|
|
465
502
|
* @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
|
|
@@ -674,10 +711,13 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
674
711
|
}
|
|
675
712
|
|
|
676
713
|
async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
|
|
677
|
-
// Reuse a caller-supplied page module when present
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
714
|
+
// Reuse a caller-supplied page module when present. The only producer is the
|
|
715
|
+
// HTML-cache path above, which loads the module to read `export const
|
|
716
|
+
// revalidate` and threads it back so this does not load it a second time.
|
|
717
|
+
// (The removed page `action` export used to be a second producer: it ran in
|
|
718
|
+
// the page module, so the 422 re-render could share that evaluation. A form
|
|
719
|
+
// action lives in a `.server.*` module instead, so the re-render loads the
|
|
720
|
+
// page module itself, exactly as a plain render does.)
|
|
681
721
|
const page = pageModule || await loadModule(route.file, dev);
|
|
682
722
|
if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
|
|
683
723
|
let tree = await page.default(ctx);
|
|
@@ -1657,6 +1697,36 @@ function wrapHead(opts) {
|
|
|
1657
1697
|
// importmap-rails) applies nonce on every modulepreload tag for
|
|
1658
1698
|
// the same reason.
|
|
1659
1699
|
const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
1700
|
+
// Core runtime modulepreload (#1118). Every module the boot imports pulls
|
|
1701
|
+
// `@webjsdev/core`, but the boot script names only page and component URLs,
|
|
1702
|
+
// so without this hint the browser discovers core only after one of those is
|
|
1703
|
+
// fetched AND parsed: one full round trip into the load, which is exactly
|
|
1704
|
+
// where the pre-boot click window lives on a cold, throttled connection.
|
|
1705
|
+
//
|
|
1706
|
+
// The href comes STRAIGHT from the importmap target (no `fp()` rewrite: the
|
|
1707
|
+
// map's targets are already base-path-prefixed and content-hashed), so it is
|
|
1708
|
+
// byte-identical to what the import resolves to. A differing href makes the
|
|
1709
|
+
// browser treat the preload and the import as two resources and fetch core
|
|
1710
|
+
// twice. That is why this copies the vendor loop below, not the module loop
|
|
1711
|
+
// above. `vendorPreloadTargets` still excludes core deliberately; this hint
|
|
1712
|
+
// is emitted from the head builder, not the vendor path.
|
|
1713
|
+
//
|
|
1714
|
+
// Gated on the boot actually shipping something. A fully elided page ships no
|
|
1715
|
+
// boot module and must not be handed a preload for a runtime it never loads
|
|
1716
|
+
// (#780), which also keeps it off `global-error.{js,ts}`, whose document is
|
|
1717
|
+
// returned verbatim with no importmap and no boot script.
|
|
1718
|
+
if (opts.moduleUrls.length || lazyEntries) {
|
|
1719
|
+
const coreMap = buildImportMap();
|
|
1720
|
+
const coreHref = coreMap.imports['@webjsdev/core'];
|
|
1721
|
+
if (coreHref) {
|
|
1722
|
+
const raw = coreMap.integrity ? coreMap.integrity[coreHref] : undefined;
|
|
1723
|
+
const coreIntegrity = raw ? ` integrity="${escapeAttr(raw)}"` : '';
|
|
1724
|
+
linkTags.push(
|
|
1725
|
+
`<link rel="modulepreload" href="${escapeAttr(coreHref)}"` +
|
|
1726
|
+
`${preloadCrossOriginAttr(coreHref)}${coreIntegrity}${noncePreload}>`,
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1660
1730
|
// Sub-path deployment (issue #256): the modulepreload href is prefixed with
|
|
1661
1731
|
// the base path (a no-op when empty), but `crossorigin` / `integrity` are
|
|
1662
1732
|
// decided on the ORIGINAL url, so the integrity lookup still keys on the
|
|
@@ -2052,7 +2122,7 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
2052
2122
|
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
|
|
2053
2123
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
2054
2124
|
// opts in to caching explicitly via metadata.cacheControl. A non-200 does
|
|
2055
|
-
// NOT inherit it (#1140): the
|
|
2125
|
+
// NOT inherit it (#1140): the form-action re-render is a 422 carrying the
|
|
2056
2126
|
// submitter's own field values and errors, which must never be handed to a
|
|
2057
2127
|
// shared cache just because the page opted into public caching.
|
|
2058
2128
|
headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
|
|
@@ -2095,6 +2165,13 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
2095
2165
|
try {
|
|
2096
2166
|
const resolved = await p.promise;
|
|
2097
2167
|
const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
|
|
2168
|
+
// A fresh scan that cannot see the shell the boundary sits in.
|
|
2169
|
+
// That used to require carrying the boundary's form scope
|
|
2170
|
+
// (#1207), or a `<button formaction=${fn}>` inside a bound
|
|
2171
|
+
// form's boundary read as form-less, was refused, and the catch
|
|
2172
|
+
// below turned it into an empty boundary on a 200 in
|
|
2173
|
+
// production. #1307 made a bound submitter self-sufficient, so
|
|
2174
|
+
// there is nothing left to carry.
|
|
2098
2175
|
const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
|
|
2099
2176
|
ctx.nextId = sub.nextId;
|
|
2100
2177
|
for (const n of sub.pending) ctx.pending.push(n);
|
|
@@ -2141,13 +2218,12 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
2141
2218
|
* serves a single evaluation; in dev a cache-bust query forces a fresh
|
|
2142
2219
|
* evaluation so source edits take effect (which also re-runs the module's
|
|
2143
2220
|
* top-level side effects, the reason pages/layouts must keep their top level
|
|
2144
|
-
* side-effect-free).
|
|
2145
|
-
* way the SSR re-render does.
|
|
2221
|
+
* side-effect-free).
|
|
2146
2222
|
*
|
|
2147
2223
|
* @param {string} file
|
|
2148
2224
|
* @param {boolean} dev
|
|
2149
2225
|
*/
|
|
2150
|
-
|
|
2226
|
+
async function loadModule(file, dev) {
|
|
2151
2227
|
const url = pathToFileURL(file).toString();
|
|
2152
2228
|
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
2153
2229
|
return import(url + bust);
|
package/src/stream.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* The payload is plain HTML, a `<webjs-stream action target>` wrapping one
|
|
7
7
|
* `<template>`. These helpers compose that string for the two delivery paths,
|
|
8
8
|
* a content-negotiated HTTP form response and a `broadcast()` / WS message, so
|
|
9
|
-
* an app never hand-writes the markup. A
|
|
9
|
+
* an app never hand-writes the markup. A form-bound action or a `route` handler
|
|
10
10
|
* branches on `acceptsStream(request)`, returning `streamResponse(...)` when the
|
|
11
11
|
* router asked for a stream and a normal render otherwise (the JS-off degrade).
|
|
12
12
|
*
|
package/src/testing.d.ts
CHANGED
|
@@ -42,6 +42,25 @@ export declare function withCookies(init: RequestInit | undefined, cookieValue:
|
|
|
42
42
|
/** Merge a session cookie string into a `RequestInit`'s `Cookie` header. */
|
|
43
43
|
export declare function withSessionCookie(init: RequestInit | undefined, sessionCookie: string): RequestInit;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Submit a page's `<form action={action}>` the way a browser with JS OFF does:
|
|
47
|
+
* render the page, reuse the identity the server put in its hidden
|
|
48
|
+
* `__webjs_action` field, and POST it back with the given fields. A POST that
|
|
49
|
+
* omits that field is not a form submission and is answered `405`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function submitForm(
|
|
52
|
+
handle: Handle,
|
|
53
|
+
path: string,
|
|
54
|
+
fields?: Record<string, string> | URLSearchParams | FormData,
|
|
55
|
+
opts?: {
|
|
56
|
+
index?: number;
|
|
57
|
+
match?: string | RegExp;
|
|
58
|
+
cookies?: string;
|
|
59
|
+
submitPath?: string;
|
|
60
|
+
headers?: Record<string, string>;
|
|
61
|
+
},
|
|
62
|
+
): Promise<Response>;
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Drive the REAL credentials login (`POST /api/auth/signin/credentials` by
|
|
47
66
|
* default) and capture the genuine signed session `Set-Cookie`.
|
package/src/testing.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
|
|
36
|
+
import { FORM_ACTION_FIELD } from '@webjsdev/core';
|
|
36
37
|
import { getSerializer } from './serializer.js';
|
|
37
38
|
import { join, sep } from 'node:path';
|
|
38
39
|
|
|
@@ -200,6 +201,118 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
|
|
|
200
201
|
return { cookies, setCookies, response: res };
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Submit a page's `<form action=${action}>` the way a browser with JS OFF does
|
|
206
|
+
* (#1155): render the page, take the identity the server put in its hidden
|
|
207
|
+
* `__webjs_action` field, and POST it back with the author's fields.
|
|
208
|
+
*
|
|
209
|
+
* A bound form carries that field because the identity is what tells the
|
|
210
|
+
* dispatcher WHICH action to run, so a hand-written POST that omits it is not a
|
|
211
|
+
* form submission at all and is answered `405`. Reproducing the scrape in every
|
|
212
|
+
* app test is how that ends up looking like a database problem instead: the
|
|
213
|
+
* status is wrong, an assertion fails, and a `catch` around it reports a skip.
|
|
214
|
+
* Hence a helper, so the identity is never the test's business.
|
|
215
|
+
*
|
|
216
|
+
* The form is located by INDEX (`opts.index`, default the first on the page),
|
|
217
|
+
* which is enough for the common one-form page. For a page with several, pass
|
|
218
|
+
* `opts.match`: a string or RegExp that must appear in the form's markup, so
|
|
219
|
+
* the test names the form by something an author recognizes (a heading, a field
|
|
220
|
+
* name, an id) rather than by counting.
|
|
221
|
+
*
|
|
222
|
+
* @param {Handle} handle
|
|
223
|
+
* @param {string} path the page to render AND, by default, to submit back to
|
|
224
|
+
* @param {Record<string, string> | URLSearchParams | FormData} [fields]
|
|
225
|
+
* @param {{
|
|
226
|
+
* index?: number,
|
|
227
|
+
* match?: string | RegExp,
|
|
228
|
+
* cookies?: string,
|
|
229
|
+
* submitPath?: string,
|
|
230
|
+
* headers?: Record<string, string>,
|
|
231
|
+
* }} [opts]
|
|
232
|
+
* @returns {Promise<Response>} the submission response (a `303` on success, a
|
|
233
|
+
* `422` re-render on a failure result)
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* The `<form>...</form>` blocks of a rendered page.
|
|
237
|
+
*
|
|
238
|
+
* A bare `/<form[\s\S]*?<\/form>/g` gets two things wrong that a test helper
|
|
239
|
+
* must not, because both fail SILENTLY: a form inside an HTML comment is
|
|
240
|
+
* matched (so `opts.index` counts a form that is not on the page), and a
|
|
241
|
+
* `</form>` sitting inside an ATTRIBUTE VALUE ends the match early (so the
|
|
242
|
+
* identity field falls outside it and the helper reports an unbound form).
|
|
243
|
+
* Comments are stripped first, and the scan skips quoted attribute values while
|
|
244
|
+
* looking for the end of the start tag.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} html
|
|
247
|
+
* @returns {string[]}
|
|
248
|
+
*/
|
|
249
|
+
function findForms(html) {
|
|
250
|
+
const src = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
251
|
+
const out = [];
|
|
252
|
+
const open = /<form\b/gi;
|
|
253
|
+
let m;
|
|
254
|
+
while ((m = open.exec(src))) {
|
|
255
|
+
// Walk the start tag, skipping quoted values so `data-x="<form>"` is inert.
|
|
256
|
+
let i = m.index + m[0].length;
|
|
257
|
+
let quote = '';
|
|
258
|
+
for (; i < src.length; i++) {
|
|
259
|
+
const c = src[i];
|
|
260
|
+
if (quote) { if (c === quote) quote = ''; continue; }
|
|
261
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
262
|
+
if (c === '>') break;
|
|
263
|
+
}
|
|
264
|
+
const close = src.toLowerCase().indexOf('</form>', i);
|
|
265
|
+
if (close === -1) continue;
|
|
266
|
+
out.push(src.slice(m.index, close + '</form>'.length));
|
|
267
|
+
open.lastIndex = close;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function submitForm(handle, path, fields = {}, opts = {}) {
|
|
273
|
+
const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
|
|
274
|
+
const page = await testRequest(handle, path, getInit);
|
|
275
|
+
const html = await page.text();
|
|
276
|
+
|
|
277
|
+
const forms = findForms(html);
|
|
278
|
+
const candidates = opts.match
|
|
279
|
+
? forms.filter((f) => (opts.match instanceof RegExp ? opts.match.test(f) : f.includes(opts.match)))
|
|
280
|
+
: forms;
|
|
281
|
+
const form = candidates[opts.index || 0];
|
|
282
|
+
if (!form) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`submitForm: no ${opts.match ? 'matching ' : ''}<form> found on ${path} `
|
|
285
|
+
+ `(status ${page.status}, ${forms.length} form(s) on the page).`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const id = form.match(/name="__webjs_action"\s+value="([^"]*)"/);
|
|
289
|
+
if (!id) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`submitForm: the <form> on ${path} carries no __webjs_action field, so it `
|
|
292
|
+
+ 'is not bound to a server action. Write it as `<form action=${myAction}>` '
|
|
293
|
+
+ 'with the action imported from a `use server` module.',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const body = new URLSearchParams();
|
|
298
|
+
body.set(FORM_ACTION_FIELD, id[1]);
|
|
299
|
+
const entries = fields instanceof URLSearchParams || fields instanceof FormData
|
|
300
|
+
? fields.entries()
|
|
301
|
+
: Object.entries(fields);
|
|
302
|
+
for (const [k, v] of entries) body.append(k, String(v));
|
|
303
|
+
|
|
304
|
+
const init = {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
|
|
307
|
+
body: body.toString(),
|
|
308
|
+
};
|
|
309
|
+
return await testRequest(
|
|
310
|
+
handle,
|
|
311
|
+
opts.submitPath || path,
|
|
312
|
+
opts.cookies ? withCookies(init, opts.cookies) : init,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
203
316
|
/**
|
|
204
317
|
* Compute the RPC endpoint path for an action, addressing it the same way the
|
|
205
318
|
* generated client stub does: by the SHA-256 hash of the action's ABSOLUTE file
|
package/src/vendor.js
CHANGED
|
@@ -338,6 +338,27 @@ let lastLiveResolveFailed = false;
|
|
|
338
338
|
|
|
339
339
|
const JSPM_GENERATE_ENDPOINT = 'https://api.jspm.io/generate';
|
|
340
340
|
const JSPM_GENERATE_TIMEOUT_MS = 10_000;
|
|
341
|
+
// Bounds a single bundle GET during the SERVER's warmup live-integrity pass
|
|
342
|
+
// (`fetchLiveIntegrity`). Short on purpose: that pass gates readiness, so a
|
|
343
|
+
// stalled CDN must not hold the first request, and it is additionally capped
|
|
344
|
+
// by INTEGRITY_TOTAL_BUDGET_MS across all URLs.
|
|
345
|
+
const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
|
|
346
|
+
|
|
347
|
+
// Bounds a single bundle GET made by the pin command, which either writes the
|
|
348
|
+
// bytes to disk (`downloadBundle`) or fetches them to hash
|
|
349
|
+
// (`fetchIntegrity`). Deliberately six times the warmup budget, because the
|
|
350
|
+
// two are not the same situation: a pin is a one-shot command a person ran and
|
|
351
|
+
// is waiting on, with a whole multi-megabyte package to transfer, while the
|
|
352
|
+
// warmup is a server holding a request. Ten seconds is generous for the
|
|
353
|
+
// latter and tight for the former on a slow link.
|
|
354
|
+
//
|
|
355
|
+
// 60s matches what importmap-rails effectively allows. It sets no timeout at
|
|
356
|
+
// all, but Ruby's Net::HTTP defaults open_timeout and read_timeout to 60s, so
|
|
357
|
+
// a Rails pin is bounded at a minute without asking. JavaScript's fetch() has
|
|
358
|
+
// no default whatsoever, which is why this has to be explicit: without it a
|
|
359
|
+
// CDN that accepts the connection and then stalls hangs the pin forever, with
|
|
360
|
+
// no ambient deadline on a CLI run to cut it short.
|
|
361
|
+
const PIN_BUNDLE_TIMEOUT_MS = 60_000;
|
|
341
362
|
|
|
342
363
|
/**
|
|
343
364
|
* Provider names accepted by `webjs vendor pin --from <provider>`.
|
|
@@ -387,12 +408,15 @@ export function normalizeProvider(name) {
|
|
|
387
408
|
*
|
|
388
409
|
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', '@codemirror/lint@6.9.6']
|
|
389
410
|
* @param {string} provider one of SUPPORTED_PROVIDERS
|
|
411
|
+
* @param {number} [timeoutMs] defaults to the SERVER budget; a CLI caller
|
|
412
|
+
* passes the longer one, since it is a command someone is waiting on rather
|
|
413
|
+
* than a request being held open.
|
|
390
414
|
* @returns {Promise<JspmCallResult>}
|
|
391
415
|
*/
|
|
392
|
-
async function jspmCall(installs, provider) {
|
|
416
|
+
async function jspmCall(installs, provider, timeoutMs = JSPM_GENERATE_TIMEOUT_MS) {
|
|
393
417
|
const label = installs.length === 1 ? `'${installs[0]}'` : `${installs.length} packages`;
|
|
394
418
|
const controller = new AbortController();
|
|
395
|
-
const timer = setTimeout(() => controller.abort(),
|
|
419
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
396
420
|
try {
|
|
397
421
|
const response = await fetch(JSPM_GENERATE_ENDPOINT, {
|
|
398
422
|
method: 'POST',
|
|
@@ -440,7 +464,7 @@ async function jspmCall(installs, provider) {
|
|
|
440
464
|
return { ok: true, imports, transient: false };
|
|
441
465
|
} catch (e) {
|
|
442
466
|
const msg = e && e.name === 'AbortError'
|
|
443
|
-
? `timed out after ${
|
|
467
|
+
? `timed out after ${timeoutMs}ms`
|
|
444
468
|
: `${e && e.message}`;
|
|
445
469
|
console.error(`[webjs] could not vendor ${label} via ${provider}: ${msg}`);
|
|
446
470
|
return { ok: false, imports: {}, transient: true };
|
|
@@ -466,8 +490,8 @@ async function jspmCall(installs, provider) {
|
|
|
466
490
|
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
467
491
|
* @returns {Promise<Record<string, string>>}
|
|
468
492
|
*/
|
|
469
|
-
async function jspmResolveOne(install, provider = 'jspm') {
|
|
470
|
-
const { ok, imports, transient } = await jspmProbeOne(install, provider);
|
|
493
|
+
async function jspmResolveOne(install, provider = 'jspm', timeoutMs) {
|
|
494
|
+
const { ok, imports, transient } = await jspmProbeOne(install, provider, timeoutMs);
|
|
471
495
|
// Preserve the public contract: an empty map on any failure, and the
|
|
472
496
|
// module-global retry flag set ONLY on a transient one (a permanent 401
|
|
473
497
|
// for an unresolvable private/server-only dep is tolerated).
|
|
@@ -492,13 +516,13 @@ async function jspmResolveOne(install, provider = 'jspm') {
|
|
|
492
516
|
* @param {string} provider
|
|
493
517
|
* @returns {Promise<JspmCallResult>}
|
|
494
518
|
*/
|
|
495
|
-
function jspmProbeOne(install, provider) {
|
|
519
|
+
function jspmProbeOne(install, provider, timeoutMs) {
|
|
496
520
|
const cacheKey = `${provider}::probe::${install}`;
|
|
497
521
|
const existing = jspmCache.get(cacheKey);
|
|
498
522
|
if (existing) return existing;
|
|
499
523
|
|
|
500
524
|
const promise = (async () => {
|
|
501
|
-
const result = await jspmCall([install], provider);
|
|
525
|
+
const result = await jspmCall([install], provider, timeoutMs);
|
|
502
526
|
// Do not cache a failure: a transient one must be re-attempted on the
|
|
503
527
|
// next resolve, and a permanent one is cheap to re-confirm and must not
|
|
504
528
|
// pin a stale "unresolvable" verdict across a dependency change.
|
|
@@ -543,14 +567,18 @@ function jspmProbeOne(install, provider) {
|
|
|
543
567
|
*
|
|
544
568
|
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
|
|
545
569
|
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
570
|
+
* @param {number} [timeoutMs] per-call budget. Defaults to the SERVER one,
|
|
571
|
+
* because this runs on a cold first request as well as from the CLI; the two
|
|
572
|
+
* pin commands pass PIN_BUNDLE_TIMEOUT_MS instead. importmap-rails only ever
|
|
573
|
+
* resolves from the CLI, so its flat 60s has no request path to slow down.
|
|
546
574
|
* @returns {Promise<Record<string, string>>}
|
|
547
575
|
*/
|
|
548
|
-
export async function jspmGenerate(installs, provider = 'jspm') {
|
|
576
|
+
export async function jspmGenerate(installs, provider = 'jspm', timeoutMs) {
|
|
549
577
|
if (installs.length === 0) return {};
|
|
550
578
|
|
|
551
579
|
// A single install has no cross-package graph to reconcile, so the
|
|
552
580
|
// isolated path IS the coherent path; reuse the per-install cache.
|
|
553
|
-
if (installs.length === 1) return jspmResolveOne(installs[0], provider);
|
|
581
|
+
if (installs.length === 1) return jspmResolveOne(installs[0], provider, timeoutMs);
|
|
554
582
|
|
|
555
583
|
// Stable key regardless of scan order so the same dep set hits cache.
|
|
556
584
|
const unifiedKey = `${provider}::unified::${[...installs].sort().join('\n')}`;
|
|
@@ -558,7 +586,7 @@ export async function jspmGenerate(installs, provider = 'jspm') {
|
|
|
558
586
|
if (cached) return cached;
|
|
559
587
|
|
|
560
588
|
const promise = (async () => {
|
|
561
|
-
const unified = await jspmCall(installs, provider);
|
|
589
|
+
const unified = await jspmCall(installs, provider, timeoutMs);
|
|
562
590
|
if (unified.ok) return unified.imports;
|
|
563
591
|
|
|
564
592
|
// The unified call failed. Drop the cached failure so a later retry
|
|
@@ -570,14 +598,14 @@ export async function jspmGenerate(installs, provider = 'jspm') {
|
|
|
570
598
|
// per-install fragments (each may still be cached / reachable) so we
|
|
571
599
|
// serve whatever we can, and flag the transient failure for retry.
|
|
572
600
|
lastLiveResolveFailed = true;
|
|
573
|
-
return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider))));
|
|
601
|
+
return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider, timeoutMs))));
|
|
574
602
|
}
|
|
575
603
|
|
|
576
604
|
// Permanent failure: at least one install is unresolvable. Probe each
|
|
577
605
|
// in isolation to learn which ones jspm can resolve, then re-run the
|
|
578
606
|
// unified call over only those so the survivors form one consistent
|
|
579
607
|
// graph (restores #446 coherence for the resolvable subset).
|
|
580
|
-
const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider)));
|
|
608
|
+
const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider, timeoutMs)));
|
|
581
609
|
|
|
582
610
|
// A GOOD package whose isolated probe failed TRANSIENTLY (a network blip
|
|
583
611
|
// mid-probe) must NOT be classified as unresolvable and dropped. Only a
|
|
@@ -612,11 +640,11 @@ export async function jspmGenerate(installs, provider = 'jspm') {
|
|
|
612
640
|
return mergePerInstall(probes.map(p => p.imports));
|
|
613
641
|
}
|
|
614
642
|
if (resolvable.length === 0) return {};
|
|
615
|
-
if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider);
|
|
643
|
+
if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider, timeoutMs);
|
|
616
644
|
|
|
617
645
|
// Re-run unified over the resolvable subset. If even that fails (a
|
|
618
646
|
// conflict among the survivors), fall back to their merged fragments.
|
|
619
|
-
const retry = await jspmCall(resolvable, provider);
|
|
647
|
+
const retry = await jspmCall(resolvable, provider, timeoutMs);
|
|
620
648
|
if (retry.ok) return retry.imports;
|
|
621
649
|
return mergePerInstall(resolvable.map(i => probes[installs.indexOf(i)].imports));
|
|
622
650
|
})();
|
|
@@ -679,6 +707,12 @@ export async function vendorImportMapEntries(bareImports, appDir) {
|
|
|
679
707
|
export function clearVendorCache() {
|
|
680
708
|
jspmCache.clear();
|
|
681
709
|
liveIntegrityCache.clear();
|
|
710
|
+
// Deliberately does NOT touch `lastLiveResolveFailed`, which looks like an
|
|
711
|
+
// omission and is not. `resolveVendorImports` is the flag's only reader and
|
|
712
|
+
// it resets the flag on entry, while the pinned short-circuit above that
|
|
713
|
+
// returns `ok: true` outright, so a value left behind by an earlier
|
|
714
|
+
// `pinAll` or `vendorImportMapEntries` can never be observed. Clearing it
|
|
715
|
+
// here would be a change nothing could write a failing test for (#1150).
|
|
682
716
|
}
|
|
683
717
|
|
|
684
718
|
/**
|
|
@@ -1071,14 +1105,21 @@ async function writePinFile(appDir, imports, integrity, provider) {
|
|
|
1071
1105
|
* success or null on failure. The integrity hash is computed from the
|
|
1072
1106
|
* downloaded bytes so it's always consistent with what's on disk.
|
|
1073
1107
|
*
|
|
1108
|
+
* Bounded by PIN_BUNDLE_TIMEOUT_MS. `pinAll(dir, { download: true })`
|
|
1109
|
+
* runs this once per resolved URL on a CLI run with no ambient
|
|
1110
|
+
* deadline, so a CDN that accepts the connection and then stalls would
|
|
1111
|
+
* otherwise hang the pin with nothing to interrupt it (#1150).
|
|
1112
|
+
*
|
|
1074
1113
|
* @param {string} url
|
|
1075
1114
|
* @param {string} appDir
|
|
1076
1115
|
* @param {string} filename
|
|
1077
1116
|
* @returns {Promise<{ bytes: number, integrity: string } | null>}
|
|
1078
1117
|
*/
|
|
1079
1118
|
async function downloadBundle(url, appDir, filename) {
|
|
1119
|
+
const controller = new AbortController();
|
|
1120
|
+
const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
|
|
1080
1121
|
try {
|
|
1081
|
-
const response = await fetch(url);
|
|
1122
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1082
1123
|
if (!response.ok) {
|
|
1083
1124
|
console.error(`[webjs] download ${url} returned ${response.status}`);
|
|
1084
1125
|
return null;
|
|
@@ -1094,8 +1135,13 @@ async function downloadBundle(url, appDir, filename) {
|
|
|
1094
1135
|
await writeFile(join(pinDir(appDir), filename), buf);
|
|
1095
1136
|
return { bytes: buf.byteLength, integrity: await sha384Integrity(buf) };
|
|
1096
1137
|
} catch (e) {
|
|
1097
|
-
|
|
1138
|
+
const why = e && e.name === 'AbortError'
|
|
1139
|
+
? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
|
|
1140
|
+
: e && e.message;
|
|
1141
|
+
console.error(`[webjs] download ${url} failed: ${why}`);
|
|
1098
1142
|
return null;
|
|
1143
|
+
} finally {
|
|
1144
|
+
clearTimeout(timer);
|
|
1099
1145
|
}
|
|
1100
1146
|
}
|
|
1101
1147
|
|
|
@@ -1105,12 +1151,21 @@ async function downloadBundle(url, appDir, filename) {
|
|
|
1105
1151
|
* so the importmap can carry SRI hashes even when bundles aren't
|
|
1106
1152
|
* locally vendored.
|
|
1107
1153
|
*
|
|
1154
|
+
* Bounded by PIN_BUNDLE_TIMEOUT_MS, the same budget `downloadBundle`
|
|
1155
|
+
* gets, since it transfers the same bytes and differs only in whether
|
|
1156
|
+
* they are written to disk. Default-mode `pinAll` runs this once per
|
|
1157
|
+
* resolved URL, so a CDN that accepts the connection and then stalls
|
|
1158
|
+
* would otherwise hang the pin with nothing to interrupt it: there is
|
|
1159
|
+
* no ambient deadline on a CLI run (#1150).
|
|
1160
|
+
*
|
|
1108
1161
|
* @param {string} url
|
|
1109
1162
|
* @returns {Promise<string | null>} the integrity string, or null on failure
|
|
1110
1163
|
*/
|
|
1111
1164
|
async function fetchIntegrity(url) {
|
|
1165
|
+
const controller = new AbortController();
|
|
1166
|
+
const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
|
|
1112
1167
|
try {
|
|
1113
|
-
const response = await fetch(url);
|
|
1168
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1114
1169
|
if (!response.ok) {
|
|
1115
1170
|
console.error(`[webjs] hash ${url} returned ${response.status}`);
|
|
1116
1171
|
return null;
|
|
@@ -1121,8 +1176,13 @@ async function fetchIntegrity(url) {
|
|
|
1121
1176
|
const buf = new Uint8Array(await response.arrayBuffer());
|
|
1122
1177
|
return await sha384Integrity(buf);
|
|
1123
1178
|
} catch (e) {
|
|
1124
|
-
|
|
1179
|
+
const why = e && e.name === 'AbortError'
|
|
1180
|
+
? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
|
|
1181
|
+
: e && e.message;
|
|
1182
|
+
console.error(`[webjs] hash ${url} failed: ${why}`);
|
|
1125
1183
|
return null;
|
|
1184
|
+
} finally {
|
|
1185
|
+
clearTimeout(timer);
|
|
1126
1186
|
}
|
|
1127
1187
|
}
|
|
1128
1188
|
|
|
@@ -1254,7 +1314,7 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
1254
1314
|
installs.push(install);
|
|
1255
1315
|
partsByInstall.set(spec, { pkg, version, subpath });
|
|
1256
1316
|
}
|
|
1257
|
-
const resolved = await jspmGenerate(installs, from);
|
|
1317
|
+
const resolved = await jspmGenerate(installs, from, PIN_BUNDLE_TIMEOUT_MS);
|
|
1258
1318
|
|
|
1259
1319
|
/** @type {Record<string, string>} */
|
|
1260
1320
|
const importmap = {};
|
|
@@ -1496,7 +1556,11 @@ export async function listPinned(appDir) {
|
|
|
1496
1556
|
// ---------------------------------------------------------------------------
|
|
1497
1557
|
|
|
1498
1558
|
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
1499
|
-
|
|
1559
|
+
// The npm registry is reached only by `audit`, `outdated`, and `update`,
|
|
1560
|
+
// all CLI commands, so it takes the same generous budget the pin bundle
|
|
1561
|
+
// fetch does rather than the server's. importmap-rails makes these same two
|
|
1562
|
+
// calls with Ruby's 60s Net::HTTP default.
|
|
1563
|
+
const NPM_TIMEOUT_MS = 60_000;
|
|
1500
1564
|
|
|
1501
1565
|
/**
|
|
1502
1566
|
* Fetch one URL from registry.npmjs.org with a small timeout. Returns
|
|
@@ -1696,7 +1760,7 @@ export async function updatePinned(appDir, opts = {}) {
|
|
|
1696
1760
|
if (specPkg !== pkg) continue;
|
|
1697
1761
|
const subpath = spec.slice(specPkg.length);
|
|
1698
1762
|
const install = `${pkg}@${latest}${subpath}`;
|
|
1699
|
-
const resolved = await jspmGenerate([install], from);
|
|
1763
|
+
const resolved = await jspmGenerate([install], from, PIN_BUNDLE_TIMEOUT_MS);
|
|
1700
1764
|
const newUrl = resolved[spec];
|
|
1701
1765
|
if (!newUrl) continue;
|
|
1702
1766
|
newImports[spec] = newUrl;
|
|
@@ -2169,7 +2233,6 @@ export async function checkImportmapCoherence(imports, opts) {
|
|
|
2169
2233
|
*/
|
|
2170
2234
|
const liveIntegrityCache = new Map();
|
|
2171
2235
|
|
|
2172
|
-
const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
|
|
2173
2236
|
// Cap concurrent bundle fetches so a large dep set does not open dozens of
|
|
2174
2237
|
// sockets at once during warmup. Matches the bounded posture of the rest of
|
|
2175
2238
|
// vendor.js (the jspm resolve is per-package but the network is the shared
|