@webjsdev/server 0.8.13 → 0.8.15

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/src/importmap.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { digestHex } from './crypto-utils.js';
4
4
  import { jsonForScriptTag } from './script-tag-json.js';
5
5
  import { withBasePath } from './base-path.js';
6
+ import { withAssetHash } from './asset-hash.js';
6
7
 
7
8
  // Local attribute escaper. Matches ssr.js's escapeAttr (the source
8
9
  // of truth for HTML attribute escaping in this package). Kept inline
@@ -53,7 +54,7 @@ let _basePath = '';
53
54
  */
54
55
  export async function setBasePath(basePath) {
55
56
  _basePath = basePath || '';
56
- _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
57
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
57
58
  }
58
59
 
59
60
  /**
@@ -87,7 +88,7 @@ let _vendorIntegrity = {};
87
88
  export async function setVendorEntries(entries, integrity) {
88
89
  _extraEntries = entries;
89
90
  _vendorIntegrity = integrity || {};
90
- _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
91
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
91
92
  }
92
93
 
93
94
  /**
@@ -228,7 +229,7 @@ let _coreEntries = {
228
229
  */
229
230
  export async function setCoreInstall(coreDir, distMode) {
230
231
  _coreEntries = buildCoreEntries(coreDir, !!distMode);
231
- _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
232
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
232
233
  }
233
234
 
234
235
  /**
@@ -319,7 +320,21 @@ export function buildCoreEntries(coreDir, distMode) {
319
320
  return out;
320
321
  }
321
322
 
322
- export function buildImportMap() {
323
+ /**
324
+ * Build the import map object.
325
+ *
326
+ * @param {{ fingerprint?: boolean }} [opts] When `fingerprint` is true (the
327
+ * default, used by the SERVED map in `importMapTag`), same-origin targets
328
+ * get a `?v=<content-hash>` suffix for immutable caching (issue #243); a
329
+ * cross-origin CDN target is left untouched (`withAssetHash` skips it, so
330
+ * its SRI key is unchanged). When false (used by the internal
331
+ * `importMapHash()` computation), no `?v` is appended, so the published
332
+ * build id stays a STABLE deploy fingerprint independent of per-file
333
+ * content hashes (the build id only needs to be stable within a deploy).
334
+ * In dev, `withAssetHash` is a no-op regardless, so both forms are equal.
335
+ */
336
+ export function buildImportMap(opts = {}) {
337
+ const fingerprint = opts.fingerprint !== false;
323
338
  const merged = {
324
339
  ..._coreEntries,
325
340
  ..._extraEntries,
@@ -333,11 +348,18 @@ export function buildImportMap() {
333
348
  /** @type {Record<string, string>} */
334
349
  const imports = {};
335
350
  // Prefix every same-origin absolute target with the sub-path base
336
- // (issue #256). `withBasePath` is a no-op when the base path is empty
337
- // (so a root-mounted app's map is byte-identical) and leaves a
338
- // cross-origin `https://` CDN vendor target untouched (only the
339
- // framework's own `/__webjs/*` and same-origin vendor targets move).
340
- for (const k of Object.keys(merged).sort()) imports[k] = withBasePath(merged[k], _basePath);
351
+ // (issue #256), THEN (issue #243) append `?v=<content-hash>` to a
352
+ // same-origin target for immutable caching. The order is basePath then
353
+ // `?v`, so a sub-path app emits `<basePath>/app/foo.js?v=hash`.
354
+ // `withBasePath` is a no-op when the base path is empty and leaves a
355
+ // cross-origin `https://` CDN vendor target untouched; `withAssetHash`
356
+ // is a no-op in dev / when disabled and also leaves a cross-origin target
357
+ // untouched (only the framework's own `/__webjs/*` core + same-origin
358
+ // app/public targets get a `?v`).
359
+ for (const k of Object.keys(merged).sort()) {
360
+ const based = withBasePath(merged[k], _basePath);
361
+ imports[k] = fingerprint ? withAssetHash(based, _basePath) : based;
362
+ }
341
363
 
342
364
  // Emit `integrity` per the importmap-integrity spec (Chrome 132+,
343
365
  // Safari 18.4+, Firefox flagged). Browsers without support ignore
@@ -366,6 +388,43 @@ export function buildImportMap() {
366
388
  return out;
367
389
  }
368
390
 
391
+ /**
392
+ * Derive the cross-origin vendor CDN origins from the resolved vendor
393
+ * importmap, most-common first (issue #243, auto vendor preconnect). For an
394
+ * UNPINNED app resolving vendors live from a cross-origin CDN (ga.jspm.io,
395
+ * or jsdelivr / unpkg / skypack with `--from`), this returns that origin so
396
+ * the head can warm DNS + TLS + TCP before the importmap resolves. A
397
+ * same-origin PINNED app (vendors served from `/__webjs/vendor/*` on the
398
+ * app's own origin), or an app with no cross-origin vendors, returns `[]`.
399
+ *
400
+ * Derived from the resolved targets (`_extraEntries`, the vendor map), NOT a
401
+ * hardcoded string, so a `--from jsdelivr` app preconnects to jsdelivr. The
402
+ * origins are deduped + sorted by frequency (the primary CDN first) and the
403
+ * list is bounded so a map spanning several CDNs cannot emit an unbounded
404
+ * preconnect set.
405
+ *
406
+ * @param {number} [max] cap on the number of origins returned (default 2)
407
+ * @returns {string[]} e.g. `['https://ga.jspm.io']`
408
+ */
409
+ export function vendorPreconnectOrigins(max = 2) {
410
+ /** @type {Map<string, number>} */
411
+ const counts = new Map();
412
+ for (const target of Object.values(_extraEntries)) {
413
+ if (typeof target !== 'string') continue;
414
+ // Only an absolute `scheme://host` cross-origin url has an origin to
415
+ // preconnect. A same-origin `/__webjs/vendor/*` (downloaded-pin) target
416
+ // is `/`-rooted and skipped (no cross-origin to warm).
417
+ if (!/^https?:\/\//i.test(target)) continue;
418
+ let origin;
419
+ try { origin = new URL(target).origin; } catch { continue; }
420
+ counts.set(origin, (counts.get(origin) || 0) + 1);
421
+ }
422
+ return [...counts.entries()]
423
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
424
+ .slice(0, Math.max(0, max))
425
+ .map(([origin]) => origin);
426
+ }
427
+
369
428
  /**
370
429
  * Serialise the import map to an HTML script tag string.
371
430
  *
package/src/ssr.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins } from './importmap.js';
5
5
  import { withBasePath } from './base-path.js';
6
+ import { withAssetHash } from './asset-hash.js';
6
7
  import { jsonForScriptTag } from './script-tag-json.js';
7
8
  import { readToken, newToken, cookieHeader } from './csrf.js';
8
9
  import { transitiveDeps } from './module-graph.js';
@@ -786,8 +787,14 @@ function wrapHead(opts) {
786
787
  // (read from importmap.js's module state), the same value the importmap
787
788
  // targets were prefixed with, so the boot specifiers and the map agree.
788
789
  const bp = basePath();
790
+ // Content-hash asset URLs (issue #243): after base-path-prefixing, append
791
+ // `?v=<hash>` to a same-origin module specifier for immutable caching. A
792
+ // no-op in dev (so the boot script is byte-identical) and for a bare/
793
+ // cross-origin specifier; same compose order as the importmap targets
794
+ // (basePath then `?v`).
795
+ const fp = (u) => withAssetHash(withBasePath(u, bp), bp);
789
796
  const imports = opts.moduleUrls
790
- .map((u) => `import ${jsonForScriptTag(withBasePath(u, bp))};`)
797
+ .map((u) => `import ${jsonForScriptTag(fp(u))};`)
791
798
  .join('\n');
792
799
  const rawLazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
793
800
  ? opts.lazyComponents
@@ -795,9 +802,14 @@ function wrapHead(opts) {
795
802
  // The lazy map's values are same-origin module URLs `observeLazy` will
796
803
  // dynamically import, so prefix them with the base path too (no-op when
797
804
  // empty).
798
- const lazyEntries = rawLazyEntries && bp
805
+ // The lazy map's values are same-origin module URLs `observeLazy` will
806
+ // dynamically import, so base-path-prefix AND content-hash them (#243), the
807
+ // same as the eager boot specifiers. `fp` is a pure no-op in dev and at the
808
+ // root mount with fingerprinting off, so the mapped map equals the raw one
809
+ // byte-for-byte there; only prod fingerprinting / a sub-path mount changes it.
810
+ const lazyEntries = rawLazyEntries
799
811
  ? Object.fromEntries(
800
- Object.entries(rawLazyEntries).map(([tag, u]) => [tag, withBasePath(u, bp)]),
812
+ Object.entries(rawLazyEntries).map(([tag, u]) => [tag, fp(u)]),
801
813
  )
802
814
  : rawLazyEntries;
803
815
  const lazyBoot = lazyEntries
@@ -1102,16 +1114,20 @@ function wrapHead(opts) {
1102
1114
  // decided on the ORIGINAL url, so the integrity lookup still keys on the
1103
1115
  // unprefixed map url and a cross-origin CDN url (never prefixed) keeps its
1104
1116
  // crossorigin attribute.
1105
- const bpPreload = basePath();
1117
+ // Content-hash (#243): the href additionally gets a `?v=<hash>` after the
1118
+ // base-path prefix (a no-op in dev / for a cross-origin url), but
1119
+ // `crossorigin` / `integrity` are still decided on the ORIGINAL url, so the
1120
+ // integrity lookup keys on the unprefixed/unhashed map url and a cross-origin
1121
+ // CDN url (never prefixed, never hashed) keeps its crossorigin attribute.
1106
1122
  for (const url of opts.moduleUrls) {
1107
1123
  linkTags.push(
1108
- `<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
1124
+ `<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
1109
1125
  `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
1110
1126
  );
1111
1127
  }
1112
1128
  for (const url of opts.preloads || []) {
1113
1129
  linkTags.push(
1114
- `<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
1130
+ `<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
1115
1131
  `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
1116
1132
  );
1117
1133
  }
@@ -1125,6 +1141,58 @@ function wrapHead(opts) {
1125
1141
  }
1126
1142
  }
1127
1143
 
1144
+ // preconnect / dns-prefetch hints (issue #243). `metadata.preconnect` and
1145
+ // `metadata.dnsPrefetch` each take a URL string, `{ url, crossorigin? }`, or
1146
+ // an array of those. A preconnect warms DNS + TLS + TCP; dns-prefetch only
1147
+ // resolves DNS (no crossorigin). The framework ALSO auto-emits ONE preconnect
1148
+ // to the resolved vendor CDN origin for an unpinned cross-origin app, so the
1149
+ // browser warms that connection before the importmap resolves. The author's
1150
+ // declared origins are tracked so the auto one is not a duplicate.
1151
+ /** @type {Set<string>} the origins the author already declared a preconnect to */
1152
+ const declaredPreconnectOrigins = new Set();
1153
+ /** @param {unknown} hint @returns {{ url: string, crossorigin?: string|boolean } | null} */
1154
+ const normalizeHint = (hint) => {
1155
+ if (typeof hint === 'string') return hint ? { url: hint } : null;
1156
+ if (hint && typeof hint === 'object' && typeof (/** @type {any} */ (hint).url) === 'string') {
1157
+ return /** @type {any} */ (hint);
1158
+ }
1159
+ return null;
1160
+ };
1161
+ /** @param {unknown} value @returns {Array<{ url: string, crossorigin?: string|boolean }>} */
1162
+ const toHints = (value) => {
1163
+ if (value == null) return [];
1164
+ const list = Array.isArray(value) ? value : [value];
1165
+ const out = [];
1166
+ for (const h of list) {
1167
+ const n = normalizeHint(h);
1168
+ if (n) out.push(n);
1169
+ }
1170
+ return out;
1171
+ };
1172
+ /** @param {string|boolean|undefined} co @returns {string} */
1173
+ const crossoriginAttr = (co) => {
1174
+ if (co === undefined || co === false) return '';
1175
+ if (co === true || co === '') return ' crossorigin';
1176
+ return ` crossorigin="${escapeAttr(String(co))}"`;
1177
+ };
1178
+ for (const h of toHints(m.preconnect)) {
1179
+ try { declaredPreconnectOrigins.add(new URL(h.url).origin); } catch { /* relative / opaque; track by raw href instead */ declaredPreconnectOrigins.add(h.url); }
1180
+ linkTags.push(`<link rel="preconnect" href="${escapeAttr(h.url)}"${crossoriginAttr(h.crossorigin)}>`);
1181
+ }
1182
+ for (const h of toHints(m.dnsPrefetch)) {
1183
+ // dns-prefetch never carries crossorigin (it only resolves DNS).
1184
+ linkTags.push(`<link rel="dns-prefetch" href="${escapeAttr(h.url)}">`);
1185
+ }
1186
+ // Auto vendor preconnect: warm the cross-origin vendor CDN connection for an
1187
+ // unpinned app. Deduped against an author-declared preconnect to the same
1188
+ // origin; emits none for a same-origin pinned app or one with no cross-origin
1189
+ // vendors (vendorPreconnectOrigins returns []). crossorigin is required (the
1190
+ // importmap fetches the module as a CORS request).
1191
+ for (const origin of vendorPreconnectOrigins()) {
1192
+ if (declaredPreconnectOrigins.has(origin)) continue;
1193
+ linkTags.push(`<link rel="preconnect" href="${escapeAttr(origin)}" crossorigin>`);
1194
+ }
1195
+
1128
1196
  // icons: { icon, apple, shortcut, other }. Each entry can be a string
1129
1197
  // (URL), an object { url, sizes?, type? }, or an array of those.
1130
1198
  // - icon → <link rel="icon">
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Type overlay for `@webjsdev/server/testing` (the handle() test harness, #267).
3
+ *
4
+ * The runtime is packages/server/src/testing.js (JSDoc-annotated JavaScript);
5
+ * this overlay exists so a TypeScript app's `import { testRequest, getCsrf }
6
+ * from '@webjsdev/server/testing'` resolves precise types instead of emitting
7
+ * TS7016. The same declarations are re-exported from the package's main
8
+ * `index.d.ts` (the helpers ship from both entry points). Zero runtime cost.
9
+ */
10
+
11
+ /** The `handle(req)` function returned by `createRequestHandler`. */
12
+ export type Handle = (req: Request) => Promise<Response> | Response;
13
+
14
+ /** A `createRequestHandler` return value, or a bare `handle` function. */
15
+ export type AppOrHandle = { handle: Handle; appDir: string } | Handle;
16
+
17
+ /** A CSRF token + cookie + header-name triple minted off the first SSR response. */
18
+ export interface CsrfPair {
19
+ /** The bare token value for the `x-webjs-csrf` request header. */
20
+ token: string;
21
+ /** The header NAME (`x-webjs-csrf`). */
22
+ header: string;
23
+ /** The `name=value` cookie string for the `Cookie` request header. */
24
+ cookie: string;
25
+ }
26
+
27
+ /**
28
+ * Coerce a bare path, full URL string, or pre-built `Request` into a `Request`.
29
+ * A bare path is prefixed with a dummy origin; `init` is merged when provided.
30
+ */
31
+ export declare function toRequest(input: string | Request, init?: RequestInit): Request;
32
+
33
+ /**
34
+ * Fire a request through the real `handle()` pipeline and return the `Response`.
35
+ * The documented one-liner: `await testRequest(app.handle, '/about')`.
36
+ */
37
+ export declare function testRequest(
38
+ handle: Handle,
39
+ input: string | Request,
40
+ init?: RequestInit,
41
+ ): Promise<Response>;
42
+
43
+ /** Read the `webjs_csrf` cookie value off a response's `Set-Cookie` header(s). */
44
+ export declare function readCsrfCookie(res: Response): string | null;
45
+
46
+ /** Collect all `Set-Cookie` header values off a response. */
47
+ export declare function getSetCookies(res: Response): string[];
48
+
49
+ /** Reduce raw `Set-Cookie` strings to a single `name=value; name2=value2` `Cookie` header value. */
50
+ export declare function cookiesToHeader(setCookies: string[]): string;
51
+
52
+ /**
53
+ * Mint a `{ token, header, cookie }` CSRF triple off the first SSR response.
54
+ * `path` is the GET path used to issue the cookie (default `/`).
55
+ */
56
+ export declare function getCsrf(handle: Handle, path?: string): Promise<CsrfPair>;
57
+
58
+ /** Merge an extra cookie string into a `RequestInit`'s `Cookie` header. */
59
+ export declare function withCookies(init: RequestInit | undefined, cookieValue: string): RequestInit;
60
+
61
+ /** Merge a session cookie string into a `RequestInit`'s `Cookie` header. */
62
+ export declare function withSessionCookie(init: RequestInit | undefined, sessionCookie: string): RequestInit;
63
+
64
+ /**
65
+ * Drive the REAL credentials login (`POST /api/auth/signin/credentials` by
66
+ * default) and capture the genuine signed session `Set-Cookie`.
67
+ */
68
+ export declare function loginAndGetCookies(
69
+ handle: Handle,
70
+ credentials: { email: string; password: string },
71
+ opts?: {
72
+ loginPath?: string;
73
+ method?: string;
74
+ body?: BodyInit;
75
+ contentType?: string;
76
+ expectStatuses?: number[];
77
+ },
78
+ ): Promise<{ cookies: string; setCookies: string[]; response: Response }>;
79
+
80
+ /**
81
+ * Compute the `/__webjs/action/<hash>/<fn>` RPC endpoint path for an action,
82
+ * addressing it the same way the generated client stub does.
83
+ */
84
+ export declare function actionEndpoint(
85
+ appDir: string,
86
+ serverFilePath: string,
87
+ fnName: string,
88
+ ): Promise<string>;
89
+
90
+ /**
91
+ * Round-trip an action through its REAL RPC endpoint (serializer + CSRF + prod
92
+ * error sanitization) and return the parsed return value.
93
+ */
94
+ export declare function invokeActionForTest<T = unknown>(
95
+ app: AppOrHandle,
96
+ serverFilePath: string,
97
+ fnName: string,
98
+ args?: unknown[],
99
+ opts?: {
100
+ csrf?: CsrfPair;
101
+ appDir?: string;
102
+ extraCookies?: string;
103
+ throwOnError?: boolean;
104
+ },
105
+ ): Promise<T>;
106
+
107
+ /**
108
+ * Lower-level variant of `invokeActionForTest` that returns the raw `Response`
109
+ * (never throws on a non-2xx), so a test can assert the status directly.
110
+ */
111
+ export declare function rawActionRequest(
112
+ app: AppOrHandle,
113
+ serverFilePath: string,
114
+ fnName: string,
115
+ args?: unknown[],
116
+ opts?: {
117
+ csrf?: CsrfPair | null;
118
+ omitCsrf?: boolean;
119
+ appDir?: string;
120
+ extraCookies?: string;
121
+ contentType?: string;
122
+ },
123
+ ): Promise<Response>;