@nifrajs/web 3.5.0 → 4.0.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/dist/backend-only.d.ts +15 -0
- package/dist/backend-only.d.ts.map +1 -0
- package/dist/backend-only.js +16 -0
- package/dist/backend-only.js.map +1 -0
- package/dist/boundary.d.ts +5 -0
- package/dist/boundary.d.ts.map +1 -1
- package/dist/boundary.js +11 -2
- package/dist/boundary.js.map +1 -1
- package/dist/build-plan.d.ts +18 -6
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +82 -34
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +4 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +68 -13
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +57 -23
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +356 -85
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/cdn.d.ts +74 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.js +132 -0
- package/dist/cdn.js.map +1 -0
- package/dist/client.d.ts +3 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +111 -15
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +4 -3
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +12 -3
- package/dist/conformance.js.map +1 -1
- package/dist/csp.d.ts +93 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +122 -0
- package/dist/csp.js.map +1 -0
- package/dist/dev-client.d.ts +76 -0
- package/dist/dev-client.d.ts.map +1 -0
- package/dist/dev-client.js +280 -0
- package/dist/dev-client.js.map +1 -0
- package/dist/dev-diagnostics.d.ts +26 -2
- package/dist/dev-diagnostics.d.ts.map +1 -1
- package/dist/dev-diagnostics.js +38 -7
- package/dist/dev-diagnostics.js.map +1 -1
- package/dist/dev-error.d.ts +4 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +46 -4
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-feed.d.ts +298 -0
- package/dist/dev-feed.d.ts.map +1 -0
- package/dist/dev-feed.js +797 -0
- package/dist/dev-feed.js.map +1 -0
- package/dist/dev-indicator.d.ts +38 -0
- package/dist/dev-indicator.d.ts.map +1 -0
- package/dist/dev-indicator.js +93 -0
- package/dist/dev-indicator.js.map +1 -0
- package/dist/dev-session.d.ts +77 -0
- package/dist/dev-session.d.ts.map +1 -0
- package/dist/dev-session.js +580 -0
- package/dist/dev-session.js.map +1 -0
- package/dist/dev-sourcemap.d.ts +27 -0
- package/dist/dev-sourcemap.d.ts.map +1 -0
- package/dist/dev-sourcemap.js +235 -0
- package/dist/dev-sourcemap.js.map +1 -0
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +13 -5
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/dev.d.ts +15 -2
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +69 -44
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic-catalog.d.ts +35 -0
- package/dist/diagnostic-catalog.d.ts.map +1 -0
- package/dist/diagnostic-catalog.js +203 -0
- package/dist/diagnostic-catalog.js.map +1 -0
- package/dist/diagnostic-prompt.d.ts +49 -0
- package/dist/diagnostic-prompt.d.ts.map +1 -0
- package/dist/diagnostic-prompt.js +303 -0
- package/dist/diagnostic-prompt.js.map +1 -0
- package/dist/diagnostic.d.ts +17 -23
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +31 -39
- package/dist/diagnostic.js.map +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +4 -1
- package/dist/draft.js.map +1 -1
- package/dist/fn.d.ts +5 -0
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +13 -3
- package/dist/fn.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +4 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +13 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -12
- package/dist/index.js.map +1 -1
- package/dist/internal/cdn/cloudflare.d.ts +48 -0
- package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
- package/dist/internal/cdn/cloudflare.js +94 -0
- package/dist/internal/cdn/cloudflare.js.map +1 -0
- package/dist/internal/cdn/core.d.ts +91 -0
- package/dist/internal/cdn/core.d.ts.map +1 -0
- package/dist/internal/cdn/core.js +220 -0
- package/dist/internal/cdn/core.js.map +1 -0
- package/dist/internal/cdn/fastly.d.ts +17 -0
- package/dist/internal/cdn/fastly.d.ts.map +1 -0
- package/dist/internal/cdn/fastly.js +43 -0
- package/dist/internal/cdn/fastly.js.map +1 -0
- package/dist/internal/cdn/vercel.d.ts +31 -0
- package/dist/internal/cdn/vercel.d.ts.map +1 -0
- package/dist/internal/cdn/vercel.js +56 -0
- package/dist/internal/cdn/vercel.js.map +1 -0
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +141 -25
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/dev-csp.d.ts +24 -0
- package/dist/internal/dev-csp.d.ts.map +1 -0
- package/dist/internal/dev-csp.js +160 -0
- package/dist/internal/dev-csp.js.map +1 -0
- package/dist/internal/dev-reserved.d.ts +25 -0
- package/dist/internal/dev-reserved.d.ts.map +1 -0
- package/dist/internal/dev-reserved.js +39 -0
- package/dist/internal/dev-reserved.js.map +1 -0
- package/dist/internal/html-spans.d.ts +10 -0
- package/dist/internal/html-spans.d.ts.map +1 -0
- package/dist/internal/html-spans.js +46 -0
- package/dist/internal/html-spans.js.map +1 -0
- package/dist/internal/loading-runtime.d.ts +44 -0
- package/dist/internal/loading-runtime.d.ts.map +1 -0
- package/dist/internal/loading-runtime.js +206 -0
- package/dist/internal/loading-runtime.js.map +1 -0
- package/dist/internal/matches-runtime.d.ts +8 -0
- package/dist/internal/matches-runtime.d.ts.map +1 -0
- package/dist/internal/matches-runtime.js +61 -0
- package/dist/internal/matches-runtime.js.map +1 -0
- package/dist/internal/mount-shadow.d.ts +36 -0
- package/dist/internal/mount-shadow.d.ts.map +1 -0
- package/dist/internal/mount-shadow.js +65 -0
- package/dist/internal/mount-shadow.js.map +1 -0
- package/dist/internal/node-builtins.d.ts +3 -0
- package/dist/internal/node-builtins.d.ts.map +1 -0
- package/dist/internal/node-builtins.js +79 -0
- package/dist/internal/node-builtins.js.map +1 -0
- package/dist/internal/output-guard.d.ts +65 -0
- package/dist/internal/output-guard.d.ts.map +1 -0
- package/dist/internal/output-guard.js +630 -0
- package/dist/internal/output-guard.js.map +1 -0
- package/dist/internal/page-execution.d.ts +20 -8
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +571 -105
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts +21 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +104 -22
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +12 -0
- package/dist/internal/private-env.d.ts.map +1 -0
- package/dist/internal/private-env.js +335 -0
- package/dist/internal/private-env.js.map +1 -0
- package/dist/internal/public-url.d.ts +3 -0
- package/dist/internal/public-url.d.ts.map +1 -0
- package/dist/internal/public-url.js +18 -0
- package/dist/internal/public-url.js.map +1 -0
- package/dist/internal/render-document.d.ts +25 -19
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +77 -61
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/response-controls.d.ts +75 -0
- package/dist/internal/response-controls.d.ts.map +1 -0
- package/dist/internal/response-controls.js +256 -0
- package/dist/internal/response-controls.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +11 -16
- package/dist/internal/runtime-contract.d.ts.map +1 -1
- package/dist/internal/runtime-contract.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +104 -0
- package/dist/internal/secret-scan.d.ts.map +1 -0
- package/dist/internal/secret-scan.js +485 -0
- package/dist/internal/secret-scan.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +6 -29
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +6 -66
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/source-map.d.ts +47 -0
- package/dist/internal/source-map.d.ts.map +1 -0
- package/dist/internal/source-map.js +225 -0
- package/dist/internal/source-map.js.map +1 -0
- package/dist/internal/target-compat.d.ts +27 -0
- package/dist/internal/target-compat.d.ts.map +1 -0
- package/dist/internal/target-compat.js +77 -0
- package/dist/internal/target-compat.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +6 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -1
- package/dist/internal/timing-safe-equal.js +10 -0
- package/dist/internal/timing-safe-equal.js.map +1 -1
- package/dist/internal/vitals-runtime.d.ts +18 -0
- package/dist/internal/vitals-runtime.d.ts.map +1 -0
- package/dist/internal/vitals-runtime.js +38 -0
- package/dist/internal/vitals-runtime.js.map +1 -0
- package/dist/internal/web-app.d.ts +38 -9
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +199 -59
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/zone-graph.d.ts +89 -0
- package/dist/internal/zone-graph.d.ts.map +1 -0
- package/dist/internal/zone-graph.js +492 -0
- package/dist/internal/zone-graph.js.map +1 -0
- package/dist/isr.d.ts +83 -6
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +263 -36
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +31 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +220 -13
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +386 -9
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +26 -5
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +17 -7
- package/dist/module-graph.js.map +1 -1
- package/dist/navigation.d.ts +17 -3
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +11 -5
- package/dist/navigation.js.map +1 -1
- package/dist/nonce.d.ts +47 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +63 -0
- package/dist/nonce.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +4 -3
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +9 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +18 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/svg.d.ts +22 -3
- package/dist/plugins/svg.d.ts.map +1 -1
- package/dist/plugins/svg.js +112 -1
- package/dist/plugins/svg.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +107 -3
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +378 -17
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts +61 -0
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
- package/dist/plugins/vite-zone-guard.js +208 -0
- package/dist/plugins/vite-zone-guard.js.map +1 -0
- package/dist/plugins/zone-guard.d.ts +27 -0
- package/dist/plugins/zone-guard.d.ts.map +1 -0
- package/dist/plugins/zone-guard.js +72 -0
- package/dist/plugins/zone-guard.js.map +1 -0
- package/dist/prerender.d.ts +10 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +35 -3
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +9 -0
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +67 -9
- package/dist/public-dir.js.map +1 -1
- package/dist/pwa-manifest.d.ts +68 -0
- package/dist/pwa-manifest.d.ts.map +1 -0
- package/dist/pwa-manifest.js +178 -0
- package/dist/pwa-manifest.js.map +1 -0
- package/dist/query.js +4 -4
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +42 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +6 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -0
- package/dist/route-manifest.d.ts.map +1 -1
- package/dist/route-manifest.js +26 -3
- package/dist/route-manifest.js.map +1 -1
- package/dist/route-types.d.ts +24 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +195 -0
- package/dist/route-types.js.map +1 -0
- package/dist/router.d.ts +28 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +129 -40
- package/dist/router.js.map +1 -1
- package/dist/vitals.d.ts +33 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +16 -0
- package/dist/vitals.js.map +1 -0
- package/dist/vite.d.ts +16 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +187 -36
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +70 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +365 -0
- package/dist/zones.js.map +1 -0
- package/package.json +67 -14
- package/src/backend-only.ts +16 -0
- package/src/boundary.ts +18 -5
- package/src/build-plan.ts +90 -35
- package/src/build-vite.ts +77 -11
- package/src/build.ts +466 -96
- package/src/bun-dev-entry.ts +1 -1
- package/src/cdn.ts +216 -0
- package/src/client.ts +111 -18
- package/src/conformance.ts +16 -3
- package/src/csp.ts +194 -0
- package/src/dev-client.ts +349 -0
- package/src/dev-diagnostics.ts +60 -7
- package/src/dev-error.ts +49 -5
- package/src/dev-feed.ts +1140 -0
- package/src/dev-indicator.ts +113 -0
- package/src/dev-session.ts +729 -0
- package/src/dev-sourcemap.ts +281 -0
- package/src/dev-ssr-graph.ts +16 -5
- package/src/dev.ts +86 -43
- package/src/diagnostic-catalog.ts +243 -0
- package/src/diagnostic-prompt.ts +355 -0
- package/src/diagnostic.ts +63 -66
- package/src/draft.ts +4 -1
- package/src/fn.ts +19 -3
- package/src/fs.ts +13 -2
- package/src/index.ts +42 -10
- package/src/internal/cdn/cloudflare.ts +146 -0
- package/src/internal/cdn/core.ts +316 -0
- package/src/internal/cdn/fastly.ts +62 -0
- package/src/internal/cdn/vercel.ts +95 -0
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/codegen.ts +161 -24
- package/src/internal/dev-csp.ts +176 -0
- package/src/internal/dev-reserved.ts +52 -0
- package/src/internal/html-spans.ts +50 -0
- package/src/internal/loading-runtime.ts +244 -0
- package/src/internal/matches-runtime.ts +62 -0
- package/src/internal/mount-shadow.ts +88 -0
- package/src/internal/node-builtins.ts +79 -0
- package/src/internal/output-guard.ts +701 -0
- package/src/internal/page-execution.ts +795 -185
- package/src/internal/parity.ts +150 -36
- package/src/internal/private-env.ts +334 -0
- package/src/internal/public-url.ts +22 -0
- package/src/internal/render-document.ts +114 -67
- package/src/internal/response-controls.ts +284 -0
- package/src/internal/runtime-contract.ts +10 -15
- package/src/internal/secret-scan.ts +623 -0
- package/src/internal/server-boundary.ts +6 -69
- package/src/internal/source-map.ts +256 -0
- package/src/internal/target-compat.ts +92 -0
- package/src/internal/timing-safe-equal.ts +13 -0
- package/src/internal/vitals-runtime.ts +53 -0
- package/src/internal/web-app.ts +262 -84
- package/src/internal/zone-graph.ts +593 -0
- package/src/isr.ts +325 -37
- package/src/llms-txt.ts +28 -4
- package/src/manifest.ts +670 -16
- package/src/module-graph.ts +53 -9
- package/src/navigation.ts +29 -6
- package/src/nonce.ts +104 -0
- package/src/plugins/css-modules.ts +5 -3
- package/src/plugins/kit.ts +24 -0
- package/src/plugins/svg.ts +135 -4
- package/src/plugins/vite-leak-guard.ts +529 -16
- package/src/plugins/vite-zone-guard.ts +263 -0
- package/src/plugins/zone-guard.ts +110 -0
- package/src/prerender.ts +49 -3
- package/src/public-dir.ts +69 -8
- package/src/pwa-manifest.ts +226 -0
- package/src/query.ts +4 -4
- package/src/render-seam.ts +46 -1
- package/src/route-manifest.ts +15 -3
- package/src/route-types.ts +264 -0
- package/src/router.ts +163 -63
- package/src/vitals.ts +44 -0
- package/src/vite.ts +207 -36
- package/src/zones.ts +430 -0
- package/dist/plugins/vite-server-only.d.ts +0 -49
- package/dist/plugins/vite-server-only.d.ts.map +0 -1
- package/dist/plugins/vite-server-only.js +0 -57
- package/dist/plugins/vite-server-only.js.map +0 -1
- package/dist/server-only.d.ts +0 -21
- package/dist/server-only.d.ts.map +0 -1
- package/dist/server-only.js +0 -25
- package/dist/server-only.js.map +0 -1
- package/src/plugins/vite-server-only.ts +0 -75
- package/src/server-only.ts +0 -25
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dev server's agent session, shared by both dev-server adapters (`./dev.ts`, `./vite.ts`) the way
|
|
3
|
+
* `./dev-diagnostics.ts` is: the feed, its HTTP surface, request tracking, the discovery record, console
|
|
4
|
+
* capture and the crash monitor live here once, so the two servers cannot drift on what an agent sees.
|
|
5
|
+
*
|
|
6
|
+
* Every agent read requires a loopback `Host` (a DNS-rebinding page cannot reach it) AND the token from
|
|
7
|
+
* the discovery record (owner-only file), so a script running in the app's own pages cannot read logs
|
|
8
|
+
* either. `/__nifra/last-error` keeps its older, token-free contract behind the same Host check.
|
|
9
|
+
*/
|
|
10
|
+
import { performance } from "node:perf_hooks"
|
|
11
|
+
import {
|
|
12
|
+
type BrowserError,
|
|
13
|
+
CLIENT_BATCH_MAX_BYTES,
|
|
14
|
+
type ClientEvent,
|
|
15
|
+
type DevClientConfig,
|
|
16
|
+
devClientTag,
|
|
17
|
+
injectIntoHtml,
|
|
18
|
+
injectIntoStream,
|
|
19
|
+
isInjectablePage,
|
|
20
|
+
parseClientBatch,
|
|
21
|
+
} from "./dev-client.ts"
|
|
22
|
+
import { createDevDiagnostics, createSourceGate, type DevDiagnostics } from "./dev-diagnostics.ts"
|
|
23
|
+
import {
|
|
24
|
+
captureInto,
|
|
25
|
+
createDevFeed,
|
|
26
|
+
createDevToken,
|
|
27
|
+
currentDevRequest,
|
|
28
|
+
DEV_ERROR_CATEGORIES,
|
|
29
|
+
DEV_FEED_HEADER,
|
|
30
|
+
DEV_FEED_PATHS,
|
|
31
|
+
DEV_FEED_SCHEMA,
|
|
32
|
+
DEV_LOG_LEVELS,
|
|
33
|
+
DEV_REQUEST_ID_HEADER,
|
|
34
|
+
DEV_TOKEN_HEADER,
|
|
35
|
+
type DevErrorEntry,
|
|
36
|
+
type DevFeed,
|
|
37
|
+
type DevPipeline,
|
|
38
|
+
type DevServerIdentity,
|
|
39
|
+
type LogMeta,
|
|
40
|
+
removeDevServerRecord,
|
|
41
|
+
runWithDevRequest,
|
|
42
|
+
writeDevServerRecord,
|
|
43
|
+
} from "./dev-feed.ts"
|
|
44
|
+
import { DEV_INDICATOR_MAX_ISSUES, DEV_INDICATOR_SOURCE, type DevIssue } from "./dev-indicator.ts"
|
|
45
|
+
import { createSourceMapper } from "./dev-sourcemap.ts"
|
|
46
|
+
import { fixPrompts, isHydrationMismatch, LAST_ERROR_PATH, promptPath } from "./diagnostic.ts"
|
|
47
|
+
import { diagnosticHeadline } from "./diagnostic-prompt.ts"
|
|
48
|
+
import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
49
|
+
import { ISR_STATUS_HEADER } from "./isr.ts"
|
|
50
|
+
import { DATA_HEADER } from "./router.ts"
|
|
51
|
+
|
|
52
|
+
export interface DevSessionOptions {
|
|
53
|
+
readonly root: string
|
|
54
|
+
readonly pipeline: DevPipeline
|
|
55
|
+
readonly publicEnvPrefix?: string | undefined
|
|
56
|
+
/**
|
|
57
|
+
* Write the discovery record and the persisted log under `<root>/.nifra/` (default true). Tools find a
|
|
58
|
+
* running server through the record; turn it off only for a server nothing should discover.
|
|
59
|
+
*/
|
|
60
|
+
readonly record?: boolean | undefined
|
|
61
|
+
/** Show browser errors in an in-page indicator with agent prompts (default true). */
|
|
62
|
+
readonly indicator?: boolean | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What a dev server hands `createApp` so the app reports into the session. */
|
|
66
|
+
export interface DevAppHooks {
|
|
67
|
+
/** Pass to `createWebApp({ onLoaderError })`: failures an `_error` boundary renders reach the feed. */
|
|
68
|
+
readonly onLoaderError: (
|
|
69
|
+
err: unknown,
|
|
70
|
+
ctx: { readonly request: Request; readonly route: string; readonly params?: unknown },
|
|
71
|
+
) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DevSession {
|
|
75
|
+
readonly feed: DevFeed
|
|
76
|
+
/** The agent token tools present as `x-nifra-dev-token`. */
|
|
77
|
+
readonly token: string
|
|
78
|
+
readonly diagnostics: DevDiagnostics
|
|
79
|
+
/** Answer a `/__nifra/` agent path, or `undefined` for anything else. */
|
|
80
|
+
handle(request: Request): Promise<Response | undefined>
|
|
81
|
+
/**
|
|
82
|
+
* Run one app request as a tracked request: id header, trace, and entries tagged with its id. A page
|
|
83
|
+
* load the app answers with a bare 5xx after recording an error comes back as the overlay instead.
|
|
84
|
+
*/
|
|
85
|
+
track(request: Request, handler: () => Promise<Response>): Promise<Response>
|
|
86
|
+
/** True for an overlay {@link track} returned: serve it as is, with no dev scripts added. */
|
|
87
|
+
isOverlay(response: Response): boolean
|
|
88
|
+
/**
|
|
89
|
+
* The page response with the browser-capture script first in its `<head>` (streamed, not buffered)
|
|
90
|
+
* and its CSP admitting the script. Anything that is not an HTML page comes back untouched.
|
|
91
|
+
*/
|
|
92
|
+
decoratePage(request: Request, response: Response): Response
|
|
93
|
+
/**
|
|
94
|
+
* {@link decoratePage} for a page already in memory (the Vite pipeline, which nonces every script
|
|
95
|
+
* after this): rewrites `headers` in place and leaves script admission to that nonce.
|
|
96
|
+
*/
|
|
97
|
+
decorateHtml(request: Request, html: string, headers: Headers, requestId?: string | null): string
|
|
98
|
+
/** Record a failure that escaped the app and return the overlay HTML for it. */
|
|
99
|
+
failure(err: unknown, request: { readonly method: string; readonly url: string }): string
|
|
100
|
+
/** For `createWebApp({ onLoaderError })`: loader/action/render failures a boundary answered. */
|
|
101
|
+
onLoaderError(
|
|
102
|
+
err: unknown,
|
|
103
|
+
ctx: { readonly request: Request; readonly route: string; readonly params?: unknown },
|
|
104
|
+
): void
|
|
105
|
+
buildFailed(err: unknown, label: string): void
|
|
106
|
+
buildPassed(): void
|
|
107
|
+
markChange(): void
|
|
108
|
+
listening(port: number): void
|
|
109
|
+
stop(): void
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const JSON_HEADERS: Readonly<Record<string, string>> = {
|
|
113
|
+
"content-type": "application/json; charset=utf-8",
|
|
114
|
+
"cache-control": "no-store",
|
|
115
|
+
"x-content-type-options": "nosniff",
|
|
116
|
+
[DEV_FEED_HEADER]: "true",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A Host header naming this machine: localhost, `*.localhost`, 127.0.0.0/8, or `[::1]`. */
|
|
120
|
+
export function isLoopbackHost(host: string | null | undefined): boolean {
|
|
121
|
+
if (host === null || host === undefined || host.length === 0) return false
|
|
122
|
+
let name = host.trim().toLowerCase()
|
|
123
|
+
if (name.startsWith("[")) {
|
|
124
|
+
const end = name.indexOf("]")
|
|
125
|
+
if (end === -1) return false
|
|
126
|
+
name = name.slice(1, end)
|
|
127
|
+
} else {
|
|
128
|
+
const colon = name.lastIndexOf(":")
|
|
129
|
+
if (colon !== -1) name = name.slice(0, colon)
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
name === "localhost" ||
|
|
133
|
+
name.endsWith(".localhost") ||
|
|
134
|
+
name === "::1" ||
|
|
135
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const AGENT_PATHS: ReadonlySet<string> = new Set([
|
|
140
|
+
DEV_FEED_PATHS.identity,
|
|
141
|
+
DEV_FEED_PATHS.errors,
|
|
142
|
+
DEV_FEED_PATHS.logs,
|
|
143
|
+
DEV_FEED_PATHS.requests,
|
|
144
|
+
DEV_FEED_PATHS.clientEvent,
|
|
145
|
+
DEV_FEED_PATHS.indicator,
|
|
146
|
+
LAST_ERROR_PATH,
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
/** Whether `pathname` is one the session answers (so an adapter hands it over before its own routing). */
|
|
150
|
+
export function isDevAgentPath(pathname: string): boolean {
|
|
151
|
+
return AGENT_PATHS.has(pathname)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const INDICATOR_HEADERS: Readonly<Record<string, string>> = {
|
|
155
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
156
|
+
"cache-control": "no-store",
|
|
157
|
+
"x-content-type-options": "nosniff",
|
|
158
|
+
[DEV_FEED_HEADER]: "true",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const json = (body: unknown, status = 200): Response =>
|
|
162
|
+
new Response(JSON.stringify(body), { status, headers: JSON_HEADERS })
|
|
163
|
+
|
|
164
|
+
const intParam = (url: URL, name: string): number | undefined => {
|
|
165
|
+
const raw = url.searchParams.get(name)
|
|
166
|
+
if (raw === null || raw === "") return undefined
|
|
167
|
+
const value = Number(raw)
|
|
168
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const textParam = (url: URL, name: string): string | undefined => {
|
|
172
|
+
const raw = url.searchParams.get(name)
|
|
173
|
+
return raw === null || raw === "" ? undefined : raw
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const listParam = <T extends string>(
|
|
177
|
+
url: URL,
|
|
178
|
+
name: string,
|
|
179
|
+
allowed: readonly T[],
|
|
180
|
+
): T[] | undefined => {
|
|
181
|
+
const raw = url.searchParams.get(name)
|
|
182
|
+
if (raw === null || raw === "") return undefined
|
|
183
|
+
return raw
|
|
184
|
+
.split(",")
|
|
185
|
+
.map((part) => part.trim())
|
|
186
|
+
.filter((part): part is T => allowed.some((value) => value === part))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const messageOf = (part: unknown): string => {
|
|
190
|
+
if (part instanceof Error) return part.message
|
|
191
|
+
if (typeof part === "object" && part !== null && "message" in part) {
|
|
192
|
+
if (typeof part.message === "string") return part.message
|
|
193
|
+
}
|
|
194
|
+
return String(part)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A Bun BuildMessage's `position` (file, 1-based line, 0-based column), when it carries one. */
|
|
198
|
+
const positionOf = (
|
|
199
|
+
part: unknown,
|
|
200
|
+
): { readonly file: string; readonly line: number; readonly column: number } | undefined => {
|
|
201
|
+
if (typeof part !== "object" || part === null || !("position" in part)) return undefined
|
|
202
|
+
const position: unknown = part.position
|
|
203
|
+
if (typeof position !== "object" || position === null) return undefined
|
|
204
|
+
if (!("file" in position) || typeof position.file !== "string") return undefined
|
|
205
|
+
if (!("line" in position) || typeof position.line !== "number") return undefined
|
|
206
|
+
const column = "column" in position && typeof position.column === "number" ? position.column : 0
|
|
207
|
+
return { file: position.file, line: position.line, column }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A bundler failure as an Error a Diagnostic can locate. Bun rejects with an AggregateError of
|
|
212
|
+
* BuildMessages whose `position` names the file; that position becomes the top frame so the codeframe
|
|
213
|
+
* points at the offending line.
|
|
214
|
+
*/
|
|
215
|
+
export function buildFailureError(err: unknown, label: string): Error {
|
|
216
|
+
const parts: unknown[] =
|
|
217
|
+
err instanceof AggregateError && err.errors.length > 0 ? err.errors : [err]
|
|
218
|
+
const messages = parts.map(messageOf)
|
|
219
|
+
const position = parts.map(positionOf).find((pos) => pos !== undefined)
|
|
220
|
+
const error = new Error(`${label}\n${messages.map((m) => ` ${m}`).join("\n")}`)
|
|
221
|
+
error.name = "BuildError"
|
|
222
|
+
const frames =
|
|
223
|
+
position !== undefined
|
|
224
|
+
? ` at ${position.file}:${position.line}:${position.column + 1}`
|
|
225
|
+
: err instanceof Error && typeof err.stack === "string"
|
|
226
|
+
? err.stack
|
|
227
|
+
.split("\n")
|
|
228
|
+
.filter((line) => /^\s*at\s/.test(line))
|
|
229
|
+
.join("\n")
|
|
230
|
+
: ""
|
|
231
|
+
error.stack = `BuildError: ${error.message}${frames === "" ? "" : `\n${frames}`}`
|
|
232
|
+
return error
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The origin the browser used for this request (its own Host, which the session already vetted). */
|
|
236
|
+
const originOf = (request: Request): string =>
|
|
237
|
+
`http://${request.headers.get("host") ?? new URL(request.url).host}`
|
|
238
|
+
|
|
239
|
+
/** A request from a page on this same origin: its Origin names the Host it was sent to. */
|
|
240
|
+
function sameOrigin(request: Request): boolean {
|
|
241
|
+
const origin = request.headers.get("origin")
|
|
242
|
+
const host = request.headers.get("host")
|
|
243
|
+
if (origin === null || host === null) return false
|
|
244
|
+
const site = request.headers.get("sec-fetch-site")
|
|
245
|
+
if (site !== null && site !== "same-origin") return false
|
|
246
|
+
try {
|
|
247
|
+
const url = new URL(origin)
|
|
248
|
+
return url.protocol === "http:" && url.host.toLowerCase() === host.toLowerCase()
|
|
249
|
+
} catch {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The body as text, or undefined once it passes `max` bytes (declared or actual). */
|
|
255
|
+
async function readCapped(request: Request, max: number): Promise<string | undefined> {
|
|
256
|
+
const declared = Number(request.headers.get("content-length") ?? "0")
|
|
257
|
+
if (declared > max) return undefined
|
|
258
|
+
if (request.body === null) return ""
|
|
259
|
+
const reader = request.body.getReader()
|
|
260
|
+
const chunks: Uint8Array[] = []
|
|
261
|
+
let total = 0
|
|
262
|
+
for (;;) {
|
|
263
|
+
const { done, value } = await reader.read()
|
|
264
|
+
if (done) break
|
|
265
|
+
total += value.byteLength
|
|
266
|
+
if (total > max) {
|
|
267
|
+
await reader.cancel()
|
|
268
|
+
return undefined
|
|
269
|
+
}
|
|
270
|
+
chunks.push(value)
|
|
271
|
+
}
|
|
272
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Create the session a dev server owns from construction to `stop()`. */
|
|
276
|
+
export function createDevSession(options: DevSessionOptions): DevSession {
|
|
277
|
+
const { root, pipeline } = options
|
|
278
|
+
const record = options.record !== false
|
|
279
|
+
const indicator = options.indicator !== false
|
|
280
|
+
const feed = createDevFeed({
|
|
281
|
+
root,
|
|
282
|
+
persist: record,
|
|
283
|
+
showSource: createSourceGate(root),
|
|
284
|
+
publicEnvPrefix: options.publicEnvPrefix,
|
|
285
|
+
})
|
|
286
|
+
const token = createDevToken()
|
|
287
|
+
// Embedded in every page, so it proves only "a page this server served": it admits event batches
|
|
288
|
+
// and nothing else. The agent token never leaves the discovery record.
|
|
289
|
+
const pageToken = createDevToken()
|
|
290
|
+
const startedAt = new Date().toISOString()
|
|
291
|
+
let port = 0
|
|
292
|
+
let requestCounter = 0
|
|
293
|
+
let stopped = false
|
|
294
|
+
|
|
295
|
+
const diagnostics = createDevDiagnostics(root, (err, request) => {
|
|
296
|
+
const entry = feed.recordError(err, { category: "ssr", request })
|
|
297
|
+
return {
|
|
298
|
+
diagnostic: entry.diagnostic,
|
|
299
|
+
entry: { id: entry.id, seq: entry.seq },
|
|
300
|
+
requestId: entry.requestId,
|
|
301
|
+
category: entry.category,
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const detachCapture = captureInto(feed)
|
|
306
|
+
const mapper = createSourceMapper({
|
|
307
|
+
root,
|
|
308
|
+
origin: () => (port === 0 ? undefined : `http://127.0.0.1:${port}`),
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// Observes without changing what a crash does: the process still dies exactly as it would have.
|
|
312
|
+
const onCrash = (err: unknown): void => {
|
|
313
|
+
try {
|
|
314
|
+
feed.recordError(err, { category: "process" })
|
|
315
|
+
feed.flush()
|
|
316
|
+
} catch {
|
|
317
|
+
// Recording a crash must never mask it.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
process.on("uncaughtExceptionMonitor", onCrash)
|
|
321
|
+
|
|
322
|
+
const identity = (): DevServerIdentity => ({
|
|
323
|
+
schema: DEV_FEED_SCHEMA,
|
|
324
|
+
pid: process.pid,
|
|
325
|
+
port,
|
|
326
|
+
pipeline,
|
|
327
|
+
root,
|
|
328
|
+
startedAt,
|
|
329
|
+
cursor: feed.cursor,
|
|
330
|
+
generation: feed.generation,
|
|
331
|
+
counts: feed.counts(),
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
const authorized = (request: Request): boolean =>
|
|
335
|
+
timingSafeEqual(request.headers.get(DEV_TOKEN_HEADER) ?? "", token)
|
|
336
|
+
|
|
337
|
+
const handle = async (request: Request): Promise<Response | undefined> => {
|
|
338
|
+
const url = new URL(request.url)
|
|
339
|
+
const path = url.pathname
|
|
340
|
+
if (!AGENT_PATHS.has(path)) return undefined
|
|
341
|
+
if (!isLoopbackHost(request.headers.get("host") ?? url.host)) {
|
|
342
|
+
return json(
|
|
343
|
+
{ code: "NIFRA_DEV_FORBIDDEN", message: "dev endpoints answer loopback hosts only" },
|
|
344
|
+
403,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
if (path === LAST_ERROR_PATH) {
|
|
348
|
+
const { body, headers } = diagnostics.lastError()
|
|
349
|
+
return new Response(body, { headers })
|
|
350
|
+
}
|
|
351
|
+
if (path === DEV_FEED_PATHS.clientEvent) return ingestClientEvents(request)
|
|
352
|
+
if (path === DEV_FEED_PATHS.indicator) {
|
|
353
|
+
if (!indicator) return json({ code: "NIFRA_DEV_NOT_FOUND", message: "indicator off" }, 404)
|
|
354
|
+
return new Response(DEV_INDICATOR_SOURCE, { headers: INDICATOR_HEADERS })
|
|
355
|
+
}
|
|
356
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
357
|
+
return json({ code: "NIFRA_DEV_METHOD", message: "use GET" }, 405)
|
|
358
|
+
}
|
|
359
|
+
if (!authorized(request)) {
|
|
360
|
+
return json(
|
|
361
|
+
{
|
|
362
|
+
code: "NIFRA_DEV_UNAUTHORIZED",
|
|
363
|
+
message: `send the token from ${root}/.nifra/dev-server.json as ${DEV_TOKEN_HEADER}`,
|
|
364
|
+
},
|
|
365
|
+
401,
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
if (path === DEV_FEED_PATHS.identity) return json(identity())
|
|
369
|
+
const since = intParam(url, "since")
|
|
370
|
+
const limit = intParam(url, "limit")
|
|
371
|
+
const requestId = textParam(url, "requestId")
|
|
372
|
+
if (path === DEV_FEED_PATHS.errors) {
|
|
373
|
+
return json(
|
|
374
|
+
feed.errors({
|
|
375
|
+
since,
|
|
376
|
+
limit,
|
|
377
|
+
requestId,
|
|
378
|
+
categories: listParam(url, "category", DEV_ERROR_CATEGORIES),
|
|
379
|
+
includeStale: url.searchParams.get("stale") !== "false",
|
|
380
|
+
includeResolved: url.searchParams.get("resolved") === "true",
|
|
381
|
+
}),
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
if (path === DEV_FEED_PATHS.logs) {
|
|
385
|
+
const source = url.searchParams.get("source")
|
|
386
|
+
return json(
|
|
387
|
+
feed.logs({
|
|
388
|
+
since,
|
|
389
|
+
limit,
|
|
390
|
+
requestId,
|
|
391
|
+
levels: listParam(url, "level", DEV_LOG_LEVELS),
|
|
392
|
+
source: source === "server" || source === "browser" ? source : undefined,
|
|
393
|
+
grep: textParam(url, "grep"),
|
|
394
|
+
}),
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
return json(feed.requests({ since, limit, requestId, path: textParam(url, "path") }))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// A page can loop on an error (a render that throws every frame): the bucket keeps it from flooding
|
|
401
|
+
// the feed. Refills at 30 events a second, holds 300.
|
|
402
|
+
const bucket = { tokens: 300, at: performance.now() }
|
|
403
|
+
let lastDropNotice = Number.NEGATIVE_INFINITY
|
|
404
|
+
const admit = (wanted: number): number => {
|
|
405
|
+
const now = performance.now()
|
|
406
|
+
bucket.tokens = Math.min(300, bucket.tokens + ((now - bucket.at) / 1000) * 30)
|
|
407
|
+
bucket.at = now
|
|
408
|
+
const granted = Math.min(wanted, Math.floor(bucket.tokens))
|
|
409
|
+
bucket.tokens -= granted
|
|
410
|
+
return granted
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A browser error as an entry, its stack mapped back to source. */
|
|
414
|
+
const recordBrowserError = async (
|
|
415
|
+
error: BrowserError,
|
|
416
|
+
meta: LogMeta,
|
|
417
|
+
): Promise<{ readonly head: string; readonly entry: DevErrorEntry }> => {
|
|
418
|
+
const head = `${error.name}: ${error.message}`
|
|
419
|
+
// Chrome leads a stack with its message; Firefox and Safari send frames only. Rebuild one shape.
|
|
420
|
+
const mapped = error.stack === "" ? "" : await mapper.mapStack(error.stack)
|
|
421
|
+
const frames = mapped
|
|
422
|
+
.split("\n")
|
|
423
|
+
.filter((line) => /^\s*at\s/.test(line))
|
|
424
|
+
.join("\n")
|
|
425
|
+
const rebuilt = new Error(error.message)
|
|
426
|
+
rebuilt.name = error.name
|
|
427
|
+
rebuilt.stack = frames === "" ? head : `${head}\n${frames}`
|
|
428
|
+
const entry = feed.recordError(rebuilt, {
|
|
429
|
+
...meta,
|
|
430
|
+
category: isHydrationMismatch(error.message) ? "hydration" : "browser",
|
|
431
|
+
})
|
|
432
|
+
return { head, entry }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Record one browser event; returns the error entry it produced, if any. */
|
|
436
|
+
const recordClientEvent = async (event: ClientEvent): Promise<DevErrorEntry | undefined> => {
|
|
437
|
+
const meta: LogMeta = { source: "browser", page: event.page, requestId: event.requestId }
|
|
438
|
+
if (event.kind === "console") {
|
|
439
|
+
feed.recordLog(event.level, event.message, meta)
|
|
440
|
+
if (event.error !== undefined) return (await recordBrowserError(event.error, meta)).entry
|
|
441
|
+
// React 18, Vue and Svelte report a mismatch through the console rather than by throwing.
|
|
442
|
+
if (
|
|
443
|
+
(event.level === "error" || event.level === "warn") &&
|
|
444
|
+
isHydrationMismatch(event.message)
|
|
445
|
+
) {
|
|
446
|
+
const recorded = await recordBrowserError(
|
|
447
|
+
{ name: "HydrationMismatch", message: event.message, stack: "" },
|
|
448
|
+
meta,
|
|
449
|
+
)
|
|
450
|
+
return recorded.entry
|
|
451
|
+
}
|
|
452
|
+
return undefined
|
|
453
|
+
}
|
|
454
|
+
if (event.kind === "resource") {
|
|
455
|
+
const { head, entry } = await recordBrowserError(
|
|
456
|
+
{ name: "ResourceError", message: `failed to load <${event.tag}> ${event.url}`, stack: "" },
|
|
457
|
+
meta,
|
|
458
|
+
)
|
|
459
|
+
feed.recordLog("error", head, meta)
|
|
460
|
+
return entry
|
|
461
|
+
}
|
|
462
|
+
const { head, entry } = await recordBrowserError(event, meta)
|
|
463
|
+
feed.recordLog(
|
|
464
|
+
"error",
|
|
465
|
+
`Uncaught ${event.kind === "rejection" ? "(in promise) " : ""}${head}`,
|
|
466
|
+
meta,
|
|
467
|
+
)
|
|
468
|
+
return entry
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** What the indicator shows for an entry: the redacted diagnostic, paths relative, and prompts. */
|
|
472
|
+
const issueOf = (entry: DevErrorEntry): DevIssue => {
|
|
473
|
+
const { diagnostic } = entry
|
|
474
|
+
const top = diagnostic.codeframe ?? diagnostic.frames.find((f) => f.file !== undefined)
|
|
475
|
+
return {
|
|
476
|
+
id: entry.id,
|
|
477
|
+
seq: entry.seq,
|
|
478
|
+
count: entry.count,
|
|
479
|
+
code: diagnostic.code,
|
|
480
|
+
category: entry.category,
|
|
481
|
+
message: diagnosticHeadline(diagnostic).slice(0, 2000),
|
|
482
|
+
page: entry.page,
|
|
483
|
+
at:
|
|
484
|
+
top?.file === undefined
|
|
485
|
+
? undefined
|
|
486
|
+
: `${promptPath(top.file, root)}:${top.line ?? 0}${top.column === undefined ? "" : `:${top.column}`}`,
|
|
487
|
+
codeframe:
|
|
488
|
+
diagnostic.codeframe === undefined ? undefined : { lines: diagnostic.codeframe.lines },
|
|
489
|
+
cause: diagnostic.cause,
|
|
490
|
+
fix: diagnostic.fix,
|
|
491
|
+
docs:
|
|
492
|
+
diagnostic.docsAnchor === undefined
|
|
493
|
+
? undefined
|
|
494
|
+
: `https://nifra.dev/docs/${diagnostic.docsAnchor}`,
|
|
495
|
+
prompts: fixPrompts(diagnostic, {
|
|
496
|
+
surface: "indicator",
|
|
497
|
+
root,
|
|
498
|
+
entry: { id: entry.id, seq: entry.seq },
|
|
499
|
+
requestId: entry.requestId,
|
|
500
|
+
page: entry.page,
|
|
501
|
+
category: entry.category,
|
|
502
|
+
}),
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const ingestClientEvents = async (request: Request): Promise<Response> => {
|
|
507
|
+
if (request.method !== "POST")
|
|
508
|
+
return json({ code: "NIFRA_DEV_METHOD", message: "use POST" }, 405)
|
|
509
|
+
// Only this server's own pages: a cross-site form or `fetch` POST carries a foreign Origin.
|
|
510
|
+
if (!sameOrigin(request))
|
|
511
|
+
return json({ code: "NIFRA_DEV_FORBIDDEN", message: "same-origin pages only" }, 403)
|
|
512
|
+
const text = await readCapped(request, CLIENT_BATCH_MAX_BYTES)
|
|
513
|
+
if (text === undefined)
|
|
514
|
+
return json({ code: "NIFRA_DEV_TOO_LARGE", message: "batch too large" }, 413)
|
|
515
|
+
let value: unknown
|
|
516
|
+
try {
|
|
517
|
+
value = JSON.parse(text)
|
|
518
|
+
} catch {
|
|
519
|
+
return json({ code: "NIFRA_DEV_BAD_BATCH", message: "batch is not JSON" }, 400)
|
|
520
|
+
}
|
|
521
|
+
const batch = parseClientBatch(value)
|
|
522
|
+
if (batch === undefined)
|
|
523
|
+
return json({ code: "NIFRA_DEV_BAD_BATCH", message: "batch has the wrong shape" }, 400)
|
|
524
|
+
if (!timingSafeEqual(batch.token, pageToken))
|
|
525
|
+
return json({ code: "NIFRA_DEV_UNAUTHORIZED", message: "unknown page token" }, 401)
|
|
526
|
+
const granted = admit(batch.events.length)
|
|
527
|
+
const recorded: DevErrorEntry[] = []
|
|
528
|
+
for (const event of batch.events.slice(0, granted)) {
|
|
529
|
+
const entry = await recordClientEvent(event)
|
|
530
|
+
if (entry !== undefined) recorded.push(entry)
|
|
531
|
+
}
|
|
532
|
+
if (granted < batch.events.length) {
|
|
533
|
+
const now = performance.now()
|
|
534
|
+
if (now - lastDropNotice > 10_000) {
|
|
535
|
+
lastDropNotice = now
|
|
536
|
+
feed.recordLog(
|
|
537
|
+
"warn",
|
|
538
|
+
"[nifra] the browser is reporting faster than 30 events a second; dropping the excess",
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
return json({ code: "NIFRA_DEV_RATE_LIMITED", message: "slow down" }, 429)
|
|
542
|
+
}
|
|
543
|
+
// The page gets back only what it just reported, so this answer reveals nothing it did not send.
|
|
544
|
+
if (indicator && recorded.length > 0) {
|
|
545
|
+
return json({ issues: recorded.slice(-DEV_INDICATOR_MAX_ISSUES).map(issueOf) })
|
|
546
|
+
}
|
|
547
|
+
return new Response(null, { status: 204, headers: { [DEV_FEED_HEADER]: "true" } })
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const clientConfig = (
|
|
551
|
+
request: Request,
|
|
552
|
+
requestId: string | null | undefined,
|
|
553
|
+
): DevClientConfig => {
|
|
554
|
+
const url = new URL(request.url)
|
|
555
|
+
return {
|
|
556
|
+
ingestPath: DEV_FEED_PATHS.clientEvent,
|
|
557
|
+
pageToken,
|
|
558
|
+
indicatorPath: indicator ? DEV_FEED_PATHS.indicator : undefined,
|
|
559
|
+
requestId: requestId ?? undefined,
|
|
560
|
+
documentPath: `${url.pathname}${url.search}`,
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const decoratePage = (request: Request, response: Response): Response => {
|
|
565
|
+
const body = response.body
|
|
566
|
+
if (body === null || !isInjectablePage(response, request.method)) return response
|
|
567
|
+
const headers = new Headers(response.headers)
|
|
568
|
+
const requestId = currentDevRequest()?.requestId ?? response.headers.get(DEV_REQUEST_ID_HEADER)
|
|
569
|
+
const tag = devClientTag(clientConfig(request, requestId), headers, originOf(request))
|
|
570
|
+
if (tag === undefined) return response
|
|
571
|
+
headers.delete("content-length") // the body grows by the script
|
|
572
|
+
return new Response(injectIntoStream(body, tag), {
|
|
573
|
+
status: response.status,
|
|
574
|
+
statusText: response.statusText,
|
|
575
|
+
headers,
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const decorateHtml = (
|
|
580
|
+
request: Request,
|
|
581
|
+
html: string,
|
|
582
|
+
headers: Headers,
|
|
583
|
+
requestId?: string | null,
|
|
584
|
+
): string => {
|
|
585
|
+
const tag = devClientTag(clientConfig(request, requestId), headers, originOf(request), true)
|
|
586
|
+
return tag === undefined ? html : injectIntoHtml(html, tag)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const isDocumentRequest = (request: Request): boolean => {
|
|
590
|
+
if (request.method !== "GET" && request.method !== "HEAD") return false
|
|
591
|
+
if (request.headers.has(DATA_HEADER)) return false
|
|
592
|
+
const dest = request.headers.get("sec-fetch-dest")
|
|
593
|
+
if (dest !== null) return dest === "document" || dest === "iframe"
|
|
594
|
+
return (request.headers.get("accept") ?? "").includes("text/html")
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const overlays = new WeakSet<Response>()
|
|
598
|
+
// Core answers a page render that throws with a bare JSON 500, and its log line has already put the
|
|
599
|
+
// error in the feed under this request. A browser loading that page gets the overlay for it instead.
|
|
600
|
+
const overlayFor = async (
|
|
601
|
+
request: Request,
|
|
602
|
+
requestId: string,
|
|
603
|
+
response: Response,
|
|
604
|
+
): Promise<Response | undefined> => {
|
|
605
|
+
if (response.status < 500 || !isDocumentRequest(request)) return undefined
|
|
606
|
+
if ((response.headers.get("content-type") ?? "").includes("text/html")) return undefined
|
|
607
|
+
const entry = feed.errors({ requestId }).errors.at(-1)
|
|
608
|
+
if (entry === undefined) return undefined
|
|
609
|
+
await response.body?.cancel()
|
|
610
|
+
const html = diagnostics.show({
|
|
611
|
+
diagnostic: entry.diagnostic,
|
|
612
|
+
entry: { id: entry.id, seq: entry.seq },
|
|
613
|
+
requestId,
|
|
614
|
+
category: entry.category,
|
|
615
|
+
})
|
|
616
|
+
const overlay = new Response(html, {
|
|
617
|
+
status: response.status,
|
|
618
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
619
|
+
})
|
|
620
|
+
overlays.add(overlay)
|
|
621
|
+
return overlay
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const track = async (request: Request, handler: () => Promise<Response>): Promise<Response> => {
|
|
625
|
+
requestCounter += 1
|
|
626
|
+
const requestId = `r${requestCounter}`
|
|
627
|
+
const url = new URL(request.url)
|
|
628
|
+
const path = `${url.pathname}${url.search}`
|
|
629
|
+
feed.startRequest(requestId, request.method, path)
|
|
630
|
+
const started = performance.now()
|
|
631
|
+
let response: Response
|
|
632
|
+
try {
|
|
633
|
+
response = await runWithDevRequest(
|
|
634
|
+
{ feedId: feed.id, requestId, method: request.method, path },
|
|
635
|
+
handler,
|
|
636
|
+
)
|
|
637
|
+
} catch (err) {
|
|
638
|
+
feed.finishRequest(requestId, { status: 500 }, performance.now() - started)
|
|
639
|
+
throw err
|
|
640
|
+
}
|
|
641
|
+
response = (await overlayFor(request, requestId, response)) ?? response
|
|
642
|
+
const length = response.headers.get("content-length")
|
|
643
|
+
feed.finishRequest(
|
|
644
|
+
requestId,
|
|
645
|
+
{
|
|
646
|
+
status: response.status,
|
|
647
|
+
bytes: length !== null && /^\d+$/.test(length) ? Number(length) : undefined,
|
|
648
|
+
isr: response.headers.get(ISR_STATUS_HEADER) ?? undefined,
|
|
649
|
+
},
|
|
650
|
+
performance.now() - started,
|
|
651
|
+
)
|
|
652
|
+
try {
|
|
653
|
+
response.headers.set(DEV_REQUEST_ID_HEADER, requestId)
|
|
654
|
+
return response
|
|
655
|
+
} catch {
|
|
656
|
+
// `Response.redirect()` and fetched responses carry immutable headers on some runtimes.
|
|
657
|
+
const headers = new Headers(response.headers)
|
|
658
|
+
headers.set(DEV_REQUEST_ID_HEADER, requestId)
|
|
659
|
+
return new Response(response.body, {
|
|
660
|
+
status: response.status,
|
|
661
|
+
statusText: response.statusText,
|
|
662
|
+
headers,
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
feed,
|
|
669
|
+
token,
|
|
670
|
+
diagnostics,
|
|
671
|
+
handle,
|
|
672
|
+
track,
|
|
673
|
+
isOverlay: (response) => overlays.has(response),
|
|
674
|
+
decoratePage,
|
|
675
|
+
decorateHtml,
|
|
676
|
+
failure: (err, request) => diagnostics.capture(err, request),
|
|
677
|
+
onLoaderError: (err, ctx) => {
|
|
678
|
+
const url = new URL(ctx.request.url)
|
|
679
|
+
feed.recordError(err, {
|
|
680
|
+
category: "page",
|
|
681
|
+
route: ctx.route,
|
|
682
|
+
request: { method: ctx.request.method, url: `${url.pathname}${url.search}` },
|
|
683
|
+
})
|
|
684
|
+
},
|
|
685
|
+
buildFailed: (err, label) => {
|
|
686
|
+
feed.recordError(buildFailureError(err, label), { category: "build" })
|
|
687
|
+
},
|
|
688
|
+
buildPassed: () => feed.resolve("build"),
|
|
689
|
+
markChange: () => {
|
|
690
|
+
feed.markChange()
|
|
691
|
+
mapper.clear()
|
|
692
|
+
},
|
|
693
|
+
listening: (bound) => {
|
|
694
|
+
port = bound
|
|
695
|
+
if (!record) return
|
|
696
|
+
try {
|
|
697
|
+
writeDevServerRecord(root, {
|
|
698
|
+
schema: DEV_FEED_SCHEMA,
|
|
699
|
+
pid: process.pid,
|
|
700
|
+
port: bound,
|
|
701
|
+
url: `http://127.0.0.1:${bound}`,
|
|
702
|
+
pipeline,
|
|
703
|
+
root,
|
|
704
|
+
startedAt,
|
|
705
|
+
token,
|
|
706
|
+
})
|
|
707
|
+
} catch (err) {
|
|
708
|
+
feed.recordLog(
|
|
709
|
+
"warn",
|
|
710
|
+
`[nifra] could not write ${root}/.nifra/dev-server.json; tools will need an explicit port: ${err instanceof Error ? err.message : String(err)}`,
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
stop: () => {
|
|
715
|
+
if (stopped) return
|
|
716
|
+
stopped = true
|
|
717
|
+
detachCapture()
|
|
718
|
+
process.off("uncaughtExceptionMonitor", onCrash)
|
|
719
|
+
if (record) {
|
|
720
|
+
try {
|
|
721
|
+
removeDevServerRecord(root, token)
|
|
722
|
+
} catch {
|
|
723
|
+
// Already gone, or the directory is; nothing to clean.
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
feed.close()
|
|
727
|
+
},
|
|
728
|
+
}
|
|
729
|
+
}
|