@webjsdev/server 0.8.62 → 0.8.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. package/src/vendor.js +29 -2371
@@ -0,0 +1,201 @@
1
+ import { cspNonce, renderToString } from '@webjsdev/core';
2
+ import { BUFFERED_MARKER, STREAM_MARKER } from '../conditional-get.js';
3
+ import { publishedBuildId, appSourceId } from '../importmap.js';
4
+ import { escapeAttr, escapeHtml } from './escape.js';
5
+
6
+ /**
7
+ * Turning rendered HTML into a Response: the per-request CSP nonce read and
8
+ * the three builders (cached, buffered and streaming).
9
+ *
10
+ * Split off render.js, which owns the RENDER. Nothing here renders anything;
11
+ * it decides status, headers and framing for bytes it is handed.
12
+ */
13
+
14
+ /**
15
+ * The CSP nonce for the in-flight request, or undefined if none is in
16
+ * scope. Delegates to `cspNonce()`, which returns the per-request nonce
17
+ * the handler MINTED when CSP is enabled (issue #233), or, as a fallback,
18
+ * the nonce parsed from an inbound `Content-Security-Policy` request
19
+ * header (the legacy consume-only path). Using the same source as the
20
+ * `Content-Security-Policy` response header is what guarantees the inline
21
+ * boot script, the importmap, the modulepreload hints, and the header all
22
+ * carry the EXACT same nonce: one minted value, no drift.
23
+ *
24
+ * `req` is accepted (and ignored) so existing call sites stay unchanged;
25
+ * the value comes from the request-scoped AsyncLocalStorage store, not
26
+ * the argument.
27
+ *
28
+ * @param {Request} [_req]
29
+ * @returns {string | undefined}
30
+ */
31
+ export function getNonce(_req) {
32
+ return cspNonce() || undefined;
33
+ }
34
+
35
+ /**
36
+ * Rebuild a Response from a cached HTML record (#241). The stored body is
37
+ * the stable per-page HTML; the per-response varying bits are re-minted
38
+ * here so a new visitor still gets them: the published build id is re-read so
39
+ * a post-deploy client sees the current id. No cookie is set (action CSRF is
40
+ * an Origin / Sec-Fetch-Site check), which is what keeps a cached page
41
+ * cookieless and shareable. The BUFFERED marker opts the cached body into
42
+ * the conditional-GET funnel exactly as a fresh render does, so a cached
43
+ * PUBLIC-cacheable page still 304s. Output is observably identical to the
44
+ * fresh render of the same route within the window.
45
+ *
46
+ * @param {{ body: string, contentType: string, cacheControl: string, status: number }} rec
47
+ * @param {Request | undefined} req
48
+ * @param {URL | undefined} url
49
+ */
50
+ export function cachedHtmlResponse(rec, req, url) {
51
+ const headers = new Headers({ 'content-type': rec.contentType });
52
+ headers.set('cache-control', rec.cacheControl);
53
+ headers.set('x-webjs-build', publishedBuildId());
54
+ headers.set('x-webjs-src', appSourceId());
55
+ headers.set(BUFFERED_MARKER, '1');
56
+ return new Response(rec.body, { status: rec.status, headers });
57
+ }
58
+
59
+ /**
60
+ * Build an HTML Response. Sets no cookie: action CSRF is an Origin /
61
+ * Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
62
+ * @param {string} html
63
+ * @param {number} status
64
+ * @param {Request | undefined} req
65
+ * @param {URL | undefined} url
66
+ * @param {Record<string, any>} [metadata]
67
+ */
68
+ export function htmlResponse(html, status, req, url, metadata) {
69
+ const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
70
+ // Default: no caching. Pages are dynamic by default: the developer opts in
71
+ // explicitly via metadata.cacheControl. No non-200 guard here, unlike
72
+ // streamingHtmlResponse: every caller of THIS builder passes no metadata, so
73
+ // the value is already the no-store default and a guard would be dead code.
74
+ headers.set('cache-control', metadata?.cacheControl || 'no-store');
75
+ // X-Webjs-Build carries the published build id so the client
76
+ // router can detect post-deploy importmap changes on EVERY
77
+ // response, including the X-Webjs-Have partial responses that
78
+ // omit the head entirely. Empty until the map is authoritatively
79
+ // final, so a warming response is reload-safe. See router-client.js
80
+ // applySwap and publishedBuildId() in importmap.js.
81
+ headers.set('x-webjs-build', publishedBuildId());
82
+ headers.set('x-webjs-src', appSourceId());
83
+ // Buffered (string) body: opt into the conditional-GET funnel.
84
+ // A cacheable page (metadata.cacheControl) gets a weak ETag + 304. The
85
+ // funnel excludes only the no-store default; a `private` page IS validated,
86
+ // which is what keeps the router's partial responses cheap (#1140).
87
+ // See conditional-get.js.
88
+ headers.set(BUFFERED_MARKER, '1');
89
+ return new Response(html, { status, headers });
90
+ }
91
+
92
+ /**
93
+ * Build a streaming Response. Degrades to a single-flush response when
94
+ * there are no pending Suspense boundaries.
95
+ *
96
+ * @param {string} prefix
97
+ * @param {string} bodyHtml
98
+ * @param {string} closer
99
+ * @param {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} ctx
100
+ * @param {number} status
101
+ * @param {Request | undefined} req
102
+ * @param {URL | undefined} url
103
+ * @param {Record<string, any>} [metadata]
104
+ * @param {string} [nonce]
105
+ * @param {boolean} [dev] dev surfaces a streamed-boundary error message; prod stays silent
106
+ */
107
+ export function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce, dev) {
108
+ const encoder = new TextEncoder();
109
+ const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
110
+ // Default: no caching. Pages are dynamic by default: the developer
111
+ // opts in to caching explicitly via metadata.cacheControl. A non-200 does
112
+ // NOT inherit it (#1140): the form-action re-render is a 422 carrying the
113
+ // submitter's own field values and errors, which must never be handed to a
114
+ // shared cache just because the page opted into public caching.
115
+ headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
116
+ // See htmlResponse: published build id on every response for the
117
+ // client router's importmap-mismatch detection on partial swaps.
118
+ headers.set('x-webjs-build', publishedBuildId());
119
+ headers.set('x-webjs-src', appSourceId());
120
+
121
+ if (!ctx.pending.length) {
122
+ // No pending boundaries: this degrades to a single buffered (string)
123
+ // flush, so opt it into the conditional-GET funnel like htmlResponse.
124
+ headers.set(BUFFERED_MARKER, '1');
125
+ return new Response(prefix + bodyHtml + closer, { status, headers });
126
+ }
127
+
128
+ // Flag a genuinely streamed body so the conditional-GET funnel skips it
129
+ // (an unflushed stream cannot be hashed without buffering, which would
130
+ // defeat streaming). The marker is internal and stripped at the funnel
131
+ // before the response reaches the client. See conditional-get.js.
132
+ headers.set(STREAM_MARKER, '1');
133
+
134
+ const stream = new ReadableStream({
135
+ async start(controller) {
136
+ // Flush the shell (prefix + body with fallbacks) immediately, followed by
137
+ // a shell-ready sentinel comment IN THE SAME chunk. The resolved boundary
138
+ // templates and the `</body></html>` closer are emitted LATER (after the
139
+ // slow data settles), so without this sentinel a streaming soft-nav client
140
+ // could not tell "shell complete, awaiting the slow boundary" from "shell
141
+ // still arriving" and would block its progressive swap until the slow
142
+ // boundary (#473). The comment is inert for the native initial-load parse.
143
+ controller.enqueue(encoder.encode(prefix + bodyHtml + '<!--wj-stream-shell-->'));
144
+ try {
145
+ // Loop: resolve all currently-pending promises in parallel; nested
146
+ // Suspense inside resolved content adds more pending entries.
147
+ while (ctx.pending.length) {
148
+ const batch = ctx.pending.slice();
149
+ ctx.pending.length = 0;
150
+ const settled = await Promise.all(
151
+ batch.map(async (p) => {
152
+ try {
153
+ const resolved = await p.promise;
154
+ const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
155
+ // A fresh scan that cannot see the shell the boundary sits in.
156
+ // That used to require carrying the boundary's form scope
157
+ // (#1207), or a `<button formaction=${fn}>` inside a bound
158
+ // form's boundary read as form-less, was refused, and the catch
159
+ // below turned it into an empty boundary on a 200 in
160
+ // production. #1307 made a bound submitter self-sufficient, so
161
+ // there is nothing left to carry.
162
+ const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
163
+ ctx.nextId = sub.nextId;
164
+ for (const n of sub.pending) ctx.pending.push(n);
165
+ return { id: p.id, html };
166
+ } catch (e) {
167
+ // Match the SSR error-isolation policy (render-server.js's
168
+ // defaultSSRErrorTemplate): dev surfaces the message so the
169
+ // failure is obvious, prod stays SILENT so no internal detail
170
+ // (a DB error, a stack-derived path) leaks to the client (#478).
171
+ const msg = e instanceof Error ? e.message : String(e);
172
+ const html = dev ? `<p>error: ${escapeHtml(msg)}</p>` : '';
173
+ return { id: p.id, html };
174
+ }
175
+ })
176
+ );
177
+ for (const r of settled) {
178
+ // Emit just the <template>: the MutationObserver-based resolver
179
+ // in the boot script detects it and swaps it into the placeholder.
180
+ // Falls back to the __webjsResolve global for browsers without MO.
181
+ // The fallback <script> carries the request's CSP nonce so
182
+ // strict-CSP enforcement passes. Browsers that support
183
+ // MutationObserver (all evergreen) handle the swap via the
184
+ // boot script's observer and skip this fallback; the
185
+ // <script> is here for legacy / extremely-restrictive
186
+ // environments. Either way it must be nonce-signed.
187
+ const scriptNonce = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
188
+ const chunk =
189
+ `<template data-webjs-resolve="${r.id}">${r.html}</template>` +
190
+ `<script${scriptNonce}>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
191
+ controller.enqueue(encoder.encode(chunk));
192
+ }
193
+ }
194
+ } finally {
195
+ controller.enqueue(encoder.encode(closer));
196
+ controller.close();
197
+ }
198
+ },
199
+ });
200
+ return new Response(stream, { status, headers });
201
+ }