@nifrajs/web 3.3.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 +35 -6
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +89 -35
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +18 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +97 -17
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +72 -23
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +411 -98
- 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 +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +226 -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/css-contract.d.ts +21 -0
- package/dist/css-contract.d.ts.map +1 -0
- package/dist/css-contract.js +28 -0
- package/dist/css-contract.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 +15 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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 +3 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +151 -30
- 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 +69 -0
- package/dist/internal/page-execution.d.ts.map +1 -0
- package/dist/internal/page-execution.js +1021 -0
- package/dist/internal/page-execution.js.map +1 -0
- 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 +37 -21
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +104 -65
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +3 -0
- package/dist/internal/request-url.d.ts.map +1 -0
- package/dist/internal/request-url.js +14 -0
- package/dist/internal/request-url.js.map +1 -0
- 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 +56 -8
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +215 -772
- 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 +301 -39
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +37 -9
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +221 -14
- 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 +131 -48
- 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/trusted-html.d.ts +34 -0
- package/dist/trusted-html.d.ts.map +1 -0
- package/dist/trusted-html.js +35 -0
- package/dist/trusted-html.js.map +1 -0
- 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 +113 -36
- package/src/build-vite.ts +124 -15
- package/src/build.ts +535 -102
- package/src/bun-dev-entry.ts +1 -1
- package/src/cdn.ts +216 -0
- package/src/client.ts +251 -18
- package/src/conformance.ts +16 -3
- package/src/csp.ts +194 -0
- package/src/css-contract.ts +44 -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 +57 -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 +180 -29
- 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 +1371 -0
- 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 +153 -73
- package/src/internal/request-url.ts +14 -0
- 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 +290 -879
- package/src/internal/zone-graph.ts +593 -0
- package/src/isr.ts +366 -39
- package/src/llms-txt.ts +44 -8
- package/src/manifest.ts +671 -17
- 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 +135 -49
- 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/trusted-html.ts +44 -0
- 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
package/src/dev-feed.ts
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/dev-feed` - what a running dev server saw, kept so the agent driving it can ask.
|
|
3
|
+
*
|
|
4
|
+
* Both dev servers (`./dev.ts`, `./vite.ts`) record into one {@link DevFeed}: every error with its
|
|
5
|
+
* structured {@link Diagnostic} (failures a `_error` boundary rendered, backend 500s, build and guard
|
|
6
|
+
* failures, browser errors, crashes), every console line, and every request. `nifra errors`,
|
|
7
|
+
* `nifra logs` and `nifra inspect` (and their MCP tools) read it back over the dev server's own HTTP
|
|
8
|
+
* surface, found through the discovery record this module also owns.
|
|
9
|
+
*
|
|
10
|
+
* Three properties hold for everything stored here:
|
|
11
|
+
* - Redacted on the way in. Secret formats and non-public env values are scrubbed before an entry
|
|
12
|
+
* exists, so nothing unredacted is ever served, persisted, or handed to an agent.
|
|
13
|
+
* - Correlated. An entry recorded while a request is in flight carries that request's id.
|
|
14
|
+
* - Edit-aware. Every entry has a monotonic `seq` (query with `since`) and the `generation` it was last
|
|
15
|
+
* seen in; a file change bumps the generation, so an error recorded before the latest edit reads as
|
|
16
|
+
* `stale` instead of current.
|
|
17
|
+
*
|
|
18
|
+
* Dev-only by construction: no production entry imports this module.
|
|
19
|
+
*/
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
21
|
+
import { randomBytes } from "node:crypto"
|
|
22
|
+
import {
|
|
23
|
+
appendFileSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
statSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from "node:fs"
|
|
32
|
+
import { dirname, join } from "node:path"
|
|
33
|
+
import { formatWithOptions, stripVTControlCharacters } from "node:util"
|
|
34
|
+
import {
|
|
35
|
+
type BuildDiagnosticOptions,
|
|
36
|
+
buildDiagnostic,
|
|
37
|
+
type Diagnostic,
|
|
38
|
+
type DiagnosticFrame,
|
|
39
|
+
} from "./diagnostic.ts"
|
|
40
|
+
import { createRedactor } from "./internal/secret-scan.ts"
|
|
41
|
+
import { originalPosition, ssrSourceMaps } from "./internal/source-map.ts"
|
|
42
|
+
|
|
43
|
+
/** Version of the HTTP + record contract between a dev server and the tools that read it. */
|
|
44
|
+
export const DEV_FEED_SCHEMA = 1
|
|
45
|
+
|
|
46
|
+
/** The dev server's agent-facing endpoints. All live under `/__nifra/`, which no route can produce. */
|
|
47
|
+
export const DEV_FEED_PATHS = Object.freeze({
|
|
48
|
+
identity: "/__nifra/dev",
|
|
49
|
+
errors: "/__nifra/errors",
|
|
50
|
+
logs: "/__nifra/logs",
|
|
51
|
+
requests: "/__nifra/requests",
|
|
52
|
+
clientEvent: "/__nifra/client-event",
|
|
53
|
+
indicator: "/__nifra/dev-indicator.js",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Request header carrying the agent token from the discovery record. */
|
|
57
|
+
export const DEV_TOKEN_HEADER = "x-nifra-dev-token"
|
|
58
|
+
/** Response header naming the request id every entry recorded during that request carries. */
|
|
59
|
+
export const DEV_REQUEST_ID_HEADER = "x-nifra-request-id"
|
|
60
|
+
/** Response header marking a body as dev-feed JSON (so a tool can tell it from an app route). */
|
|
61
|
+
export const DEV_FEED_HEADER = "x-nifra-dev-feed"
|
|
62
|
+
|
|
63
|
+
/** Discovery record and persisted log, relative to the project root. `.nifra/` is gitignored. */
|
|
64
|
+
export const DEV_SERVER_RECORD_FILE = ".nifra/dev-server.json"
|
|
65
|
+
export const DEV_SERVER_LOG_FILE = ".nifra/dev-server.log"
|
|
66
|
+
/**
|
|
67
|
+
* Every file the feed writes (record, its temp file, log, rotated log): what a file watcher skips. A
|
|
68
|
+
* RegExp, not a glob: a glob's `**` does not cross a dot-directory such as the project's own path.
|
|
69
|
+
*/
|
|
70
|
+
export const DEV_SERVER_FILES = /[\\/]\.nifra[\\/]dev-server\./
|
|
71
|
+
|
|
72
|
+
export type DevErrorCategory =
|
|
73
|
+
/** A failure that escaped the app entirely; the dev overlay rendered it. */
|
|
74
|
+
| "ssr"
|
|
75
|
+
/** A loader, action or render failure an `_error` boundary (or a soft-nav 500) answered. */
|
|
76
|
+
| "page"
|
|
77
|
+
/** An unhandled backend route error (a bare 500). */
|
|
78
|
+
| "api"
|
|
79
|
+
/** A client bundle, leak-guard, transform or app-construction failure. */
|
|
80
|
+
| "build"
|
|
81
|
+
/** An uncaught error or rejection in a browser tab. */
|
|
82
|
+
| "browser"
|
|
83
|
+
/** A hydration mismatch reported by the browser. */
|
|
84
|
+
| "hydration"
|
|
85
|
+
/** The dev server process itself crashed. */
|
|
86
|
+
| "process"
|
|
87
|
+
|
|
88
|
+
export const DEV_ERROR_CATEGORIES: readonly DevErrorCategory[] = [
|
|
89
|
+
"ssr",
|
|
90
|
+
"page",
|
|
91
|
+
"api",
|
|
92
|
+
"build",
|
|
93
|
+
"browser",
|
|
94
|
+
"hydration",
|
|
95
|
+
"process",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
export type DevEntrySource = "server" | "browser"
|
|
99
|
+
export type DevLogLevel = "debug" | "info" | "log" | "warn" | "error"
|
|
100
|
+
export const DEV_LOG_LEVELS: readonly DevLogLevel[] = ["debug", "info", "log", "warn", "error"]
|
|
101
|
+
|
|
102
|
+
/** Narrow a string to a {@link DevLogLevel}. */
|
|
103
|
+
export function isDevLogLevel(value: unknown): value is DevLogLevel {
|
|
104
|
+
return DEV_LOG_LEVELS.some((level) => level === value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Narrow a string to a {@link DevErrorCategory}. */
|
|
108
|
+
export function isDevErrorCategory(value: unknown): value is DevErrorCategory {
|
|
109
|
+
return DEV_ERROR_CATEGORIES.some((category) => category === value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface DevErrorEntry {
|
|
113
|
+
/** Sequence number of the latest occurrence; query with `since` to see only newer entries. */
|
|
114
|
+
readonly seq: number
|
|
115
|
+
/** Stable across repeats of the same failure. */
|
|
116
|
+
readonly id: string
|
|
117
|
+
readonly category: DevErrorCategory
|
|
118
|
+
readonly source: DevEntrySource
|
|
119
|
+
readonly diagnostic: Diagnostic
|
|
120
|
+
readonly count: number
|
|
121
|
+
readonly firstAt: string
|
|
122
|
+
readonly lastAt: string
|
|
123
|
+
/** The file-change generation of the latest occurrence. */
|
|
124
|
+
readonly generation: number
|
|
125
|
+
/** True when a file changed after the latest occurrence: re-run the request to confirm it persists. */
|
|
126
|
+
readonly stale: boolean
|
|
127
|
+
readonly requestId?: string
|
|
128
|
+
/** The route pattern, when a page loader/action/render failed. */
|
|
129
|
+
readonly route?: string
|
|
130
|
+
/** The page path a browser entry came from. */
|
|
131
|
+
readonly page?: string
|
|
132
|
+
/** Set when a later success cleared it (build errors). */
|
|
133
|
+
readonly resolvedAt?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface DevLogEntry {
|
|
137
|
+
readonly seq: number
|
|
138
|
+
readonly at: string
|
|
139
|
+
readonly level: DevLogLevel
|
|
140
|
+
readonly source: DevEntrySource
|
|
141
|
+
readonly message: string
|
|
142
|
+
readonly requestId?: string
|
|
143
|
+
readonly page?: string
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface DevRequestTrace {
|
|
147
|
+
readonly seq: number
|
|
148
|
+
readonly requestId: string
|
|
149
|
+
readonly method: string
|
|
150
|
+
readonly path: string
|
|
151
|
+
readonly at: string
|
|
152
|
+
/** Undefined while the request has not produced its response yet. */
|
|
153
|
+
readonly status?: number
|
|
154
|
+
/** Until the response started (a streamed body keeps flowing after). */
|
|
155
|
+
readonly durationMs?: number
|
|
156
|
+
readonly bytes?: number
|
|
157
|
+
readonly isr?: string
|
|
158
|
+
readonly errorIds: readonly string[]
|
|
159
|
+
readonly logCount: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface DevErrorsQuery {
|
|
163
|
+
readonly since?: number | undefined
|
|
164
|
+
readonly categories?: readonly DevErrorCategory[] | undefined
|
|
165
|
+
/** Default true. */
|
|
166
|
+
readonly includeStale?: boolean | undefined
|
|
167
|
+
/** Default false. */
|
|
168
|
+
readonly includeResolved?: boolean | undefined
|
|
169
|
+
readonly requestId?: string | undefined
|
|
170
|
+
readonly limit?: number | undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface DevLogsQuery {
|
|
174
|
+
readonly since?: number | undefined
|
|
175
|
+
readonly levels?: readonly DevLogLevel[] | undefined
|
|
176
|
+
readonly source?: DevEntrySource | undefined
|
|
177
|
+
readonly requestId?: string | undefined
|
|
178
|
+
/** Case-insensitive substring. */
|
|
179
|
+
readonly grep?: string | undefined
|
|
180
|
+
readonly limit?: number | undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface DevRequestsQuery {
|
|
184
|
+
readonly since?: number | undefined
|
|
185
|
+
/** Path prefix. */
|
|
186
|
+
readonly path?: string | undefined
|
|
187
|
+
readonly requestId?: string | undefined
|
|
188
|
+
readonly limit?: number | undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface PageInfo {
|
|
192
|
+
/** Highest seq in the feed: pass it back as `since` to see only what happens next. */
|
|
193
|
+
readonly cursor: number
|
|
194
|
+
readonly generation: number
|
|
195
|
+
}
|
|
196
|
+
export interface DevErrorsResult extends PageInfo {
|
|
197
|
+
readonly errors: readonly DevErrorEntry[]
|
|
198
|
+
}
|
|
199
|
+
export interface DevLogsResult extends PageInfo {
|
|
200
|
+
readonly logs: readonly DevLogEntry[]
|
|
201
|
+
/** Lines evicted from the ring since the server started. */
|
|
202
|
+
readonly dropped: number
|
|
203
|
+
}
|
|
204
|
+
export interface DevRequestsResult extends PageInfo {
|
|
205
|
+
readonly requests: readonly DevRequestTrace[]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type DevPipeline = "bun" | "vite"
|
|
209
|
+
|
|
210
|
+
/** The identity a dev server reports at {@link DEV_FEED_PATHS.identity}. */
|
|
211
|
+
export interface DevServerIdentity {
|
|
212
|
+
readonly schema: number
|
|
213
|
+
readonly pid: number
|
|
214
|
+
readonly port: number
|
|
215
|
+
readonly pipeline: DevPipeline
|
|
216
|
+
readonly root: string
|
|
217
|
+
readonly startedAt: string
|
|
218
|
+
readonly cursor: number
|
|
219
|
+
readonly generation: number
|
|
220
|
+
readonly counts: { readonly errors: number; readonly logs: number; readonly requests: number }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface ErrorMeta {
|
|
224
|
+
readonly category: DevErrorCategory
|
|
225
|
+
readonly source?: DevEntrySource | undefined
|
|
226
|
+
readonly requestId?: string | undefined
|
|
227
|
+
readonly route?: string | undefined
|
|
228
|
+
readonly page?: string | undefined
|
|
229
|
+
readonly request?: { readonly method: string; readonly url: string } | undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface LogMeta {
|
|
233
|
+
readonly source?: DevEntrySource | undefined
|
|
234
|
+
readonly requestId?: string | undefined
|
|
235
|
+
readonly page?: string | undefined
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface DevFeedLimits {
|
|
239
|
+
readonly errors: number
|
|
240
|
+
readonly logs: number
|
|
241
|
+
readonly logBytes: number
|
|
242
|
+
readonly requests: number
|
|
243
|
+
readonly message: number
|
|
244
|
+
readonly stack: number
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const DEFAULT_DEV_FEED_LIMITS: DevFeedLimits = Object.freeze({
|
|
248
|
+
errors: 100,
|
|
249
|
+
logs: 2000,
|
|
250
|
+
logBytes: 1024 * 1024,
|
|
251
|
+
requests: 500,
|
|
252
|
+
message: 8 * 1024,
|
|
253
|
+
stack: 16 * 1024,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
export interface DevFeedOptions {
|
|
257
|
+
readonly root: string
|
|
258
|
+
readonly publicEnvPrefix?: string | undefined
|
|
259
|
+
/** The environment whose non-public values are redacted (default `process.env`). */
|
|
260
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined
|
|
261
|
+
/** Append redacted entries to {@link DEV_SERVER_LOG_FILE} (default true). */
|
|
262
|
+
readonly persist?: boolean | undefined
|
|
263
|
+
/** Whether a file's source may appear in a codeframe. Dev servers pass the zone check. */
|
|
264
|
+
readonly showSource?: ((file: string) => boolean) | undefined
|
|
265
|
+
readonly limits?: Partial<DevFeedLimits> | undefined
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface DevFeed {
|
|
269
|
+
/** Identifies this feed's requests to the process-wide capture. */
|
|
270
|
+
readonly id: string
|
|
271
|
+
readonly root: string
|
|
272
|
+
readonly generation: number
|
|
273
|
+
readonly cursor: number
|
|
274
|
+
recordError(error: unknown, meta: ErrorMeta): DevErrorEntry
|
|
275
|
+
recordDiagnostic(diagnostic: Diagnostic, meta: ErrorMeta): DevErrorEntry
|
|
276
|
+
recordLog(level: DevLogLevel, message: string, meta?: LogMeta): void
|
|
277
|
+
startRequest(requestId: string, method: string, path: string): void
|
|
278
|
+
finishRequest(
|
|
279
|
+
requestId: string,
|
|
280
|
+
response: {
|
|
281
|
+
readonly status: number
|
|
282
|
+
readonly bytes?: number | undefined
|
|
283
|
+
readonly isr?: string | undefined
|
|
284
|
+
},
|
|
285
|
+
durationMs: number,
|
|
286
|
+
): void
|
|
287
|
+
/** A watched file changed: everything recorded so far becomes stale. */
|
|
288
|
+
markChange(): void
|
|
289
|
+
/** A later success cleared every open error of `category`. */
|
|
290
|
+
resolve(category: DevErrorCategory): void
|
|
291
|
+
errors(query?: DevErrorsQuery): DevErrorsResult
|
|
292
|
+
logs(query?: DevLogsQuery): DevLogsResult
|
|
293
|
+
requests(query?: DevRequestsQuery): DevRequestsResult
|
|
294
|
+
counts(): DevServerIdentity["counts"]
|
|
295
|
+
redact(text: string): string
|
|
296
|
+
/** Flush persisted entries; the feed stays usable. */
|
|
297
|
+
flush(): void
|
|
298
|
+
/** Flush and stop persisting. */
|
|
299
|
+
close(): void
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type Writable<T> = { -readonly [K in keyof T]: T[K] }
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------------------------------
|
|
305
|
+
// Request context
|
|
306
|
+
// ---------------------------------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
export interface DevRequestContext {
|
|
309
|
+
readonly feedId: string
|
|
310
|
+
readonly requestId: string
|
|
311
|
+
readonly method: string
|
|
312
|
+
readonly path: string
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// One store per process: a second copy of this module (a dual install) must still see the request the
|
|
316
|
+
// first one started, or its log lines would land untagged.
|
|
317
|
+
const ALS_KEY = Symbol.for("nifra.devFeed.requestContext")
|
|
318
|
+
const requestContext: AsyncLocalStorage<DevRequestContext> = sharedRequestContext()
|
|
319
|
+
|
|
320
|
+
function sharedRequestContext(): AsyncLocalStorage<DevRequestContext> {
|
|
321
|
+
const existing: unknown = Reflect.get(globalThis, ALS_KEY)
|
|
322
|
+
if (existing instanceof AsyncLocalStorage) return existing
|
|
323
|
+
const created = new AsyncLocalStorage<DevRequestContext>()
|
|
324
|
+
Reflect.set(globalThis, ALS_KEY, created)
|
|
325
|
+
return created
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Run `fn` as the handling of one request: entries recorded inside it carry `ctx.requestId`. */
|
|
329
|
+
export function runWithDevRequest<T>(ctx: DevRequestContext, fn: () => T): T {
|
|
330
|
+
return requestContext.run(ctx, fn)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The request being handled on this async path, if any. */
|
|
334
|
+
export function currentDevRequest(): DevRequestContext | undefined {
|
|
335
|
+
return requestContext.getStore()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The request in flight on this async path, when it belongs to the feed `feedId`. */
|
|
339
|
+
function currentRequestFor(feedId: string): DevRequestContext | undefined {
|
|
340
|
+
const ctx = requestContext.getStore()
|
|
341
|
+
return ctx?.feedId === feedId ? ctx : undefined
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------------------------------
|
|
345
|
+
// Store
|
|
346
|
+
// ---------------------------------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
interface ErrorRecord {
|
|
349
|
+
seq: number
|
|
350
|
+
readonly id: string
|
|
351
|
+
readonly category: DevErrorCategory
|
|
352
|
+
readonly source: DevEntrySource
|
|
353
|
+
diagnostic: Diagnostic
|
|
354
|
+
count: number
|
|
355
|
+
readonly firstAt: string
|
|
356
|
+
lastAt: string
|
|
357
|
+
generation: number
|
|
358
|
+
requestId: string | undefined
|
|
359
|
+
route: string | undefined
|
|
360
|
+
page: string | undefined
|
|
361
|
+
resolvedAt: string | undefined
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
interface TraceRecord {
|
|
365
|
+
readonly seq: number
|
|
366
|
+
readonly requestId: string
|
|
367
|
+
readonly method: string
|
|
368
|
+
readonly path: string
|
|
369
|
+
readonly at: string
|
|
370
|
+
status: number | undefined
|
|
371
|
+
durationMs: number | undefined
|
|
372
|
+
bytes: number | undefined
|
|
373
|
+
isr: string | undefined
|
|
374
|
+
readonly errorIds: string[]
|
|
375
|
+
logCount: number
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const nowIso = (): string => new Date().toISOString()
|
|
379
|
+
|
|
380
|
+
const clampLimit = (value: number | undefined, fallback: number, max: number): number =>
|
|
381
|
+
value === undefined || !Number.isFinite(value) || value < 1
|
|
382
|
+
? fallback
|
|
383
|
+
: Math.min(Math.floor(value), max)
|
|
384
|
+
|
|
385
|
+
/** FNV-1a: a stable short id for a failure fingerprint. Not a security boundary. */
|
|
386
|
+
function fnv1a(text: string): string {
|
|
387
|
+
let hash = 0x811c9dc5
|
|
388
|
+
for (let i = 0; i < text.length; i++) {
|
|
389
|
+
hash ^= text.charCodeAt(i)
|
|
390
|
+
hash = Math.imul(hash, 0x01000193)
|
|
391
|
+
}
|
|
392
|
+
return (hash >>> 0).toString(16).padStart(8, "0")
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const cap = (text: string, max: number): string =>
|
|
396
|
+
text.length <= max ? text : `${text.slice(0, max)}... [${text.length - max} more chars]`
|
|
397
|
+
|
|
398
|
+
const firstLine = (text: string): string => {
|
|
399
|
+
const end = text.indexOf("\n")
|
|
400
|
+
return end === -1 ? text : text.slice(0, end)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function snapshotError(record: ErrorRecord, currentGeneration: number): DevErrorEntry {
|
|
404
|
+
const entry: Writable<DevErrorEntry> = {
|
|
405
|
+
seq: record.seq,
|
|
406
|
+
id: record.id,
|
|
407
|
+
category: record.category,
|
|
408
|
+
source: record.source,
|
|
409
|
+
diagnostic: record.diagnostic,
|
|
410
|
+
count: record.count,
|
|
411
|
+
firstAt: record.firstAt,
|
|
412
|
+
lastAt: record.lastAt,
|
|
413
|
+
generation: record.generation,
|
|
414
|
+
stale: record.generation < currentGeneration,
|
|
415
|
+
}
|
|
416
|
+
if (record.requestId !== undefined) entry.requestId = record.requestId
|
|
417
|
+
if (record.route !== undefined) entry.route = record.route
|
|
418
|
+
if (record.page !== undefined) entry.page = record.page
|
|
419
|
+
if (record.resolvedAt !== undefined) entry.resolvedAt = record.resolvedAt
|
|
420
|
+
return entry
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function snapshotTrace(trace: TraceRecord): DevRequestTrace {
|
|
424
|
+
const entry: Writable<DevRequestTrace> = {
|
|
425
|
+
seq: trace.seq,
|
|
426
|
+
requestId: trace.requestId,
|
|
427
|
+
method: trace.method,
|
|
428
|
+
path: trace.path,
|
|
429
|
+
at: trace.at,
|
|
430
|
+
errorIds: [...trace.errorIds],
|
|
431
|
+
logCount: trace.logCount,
|
|
432
|
+
}
|
|
433
|
+
if (trace.status !== undefined) entry.status = trace.status
|
|
434
|
+
if (trace.durationMs !== undefined) entry.durationMs = trace.durationMs
|
|
435
|
+
if (trace.bytes !== undefined) entry.bytes = trace.bytes
|
|
436
|
+
if (trace.isr !== undefined) entry.isr = trace.isr
|
|
437
|
+
return entry
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function evictOldest<V>(map: Map<string, V>, max: number): void {
|
|
441
|
+
while (map.size > max) {
|
|
442
|
+
const oldest = map.keys().next()
|
|
443
|
+
if (oldest.done === true) return
|
|
444
|
+
map.delete(oldest.value)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let feedCounter = 0
|
|
449
|
+
|
|
450
|
+
/** Create the store a dev server records into. */
|
|
451
|
+
export function createDevFeed(options: DevFeedOptions): DevFeed {
|
|
452
|
+
const limits: DevFeedLimits = { ...DEFAULT_DEV_FEED_LIMITS, ...options.limits }
|
|
453
|
+
const root = options.root
|
|
454
|
+
const redactText = createRedactor(options.env ?? process.env, options.publicEnvPrefix)
|
|
455
|
+
const persist = options.persist === false ? undefined : createPersistence(root)
|
|
456
|
+
feedCounter += 1
|
|
457
|
+
const id = `feed${feedCounter}-${randomBytes(3).toString("hex")}`
|
|
458
|
+
const errors = new Map<string, ErrorRecord>()
|
|
459
|
+
const traces = new Map<string, TraceRecord>()
|
|
460
|
+
let logs: DevLogEntry[] = []
|
|
461
|
+
let logBytes = 0
|
|
462
|
+
let dropped = 0
|
|
463
|
+
let seq = 0
|
|
464
|
+
let generation = 0
|
|
465
|
+
|
|
466
|
+
const redactFrame = (frame: DiagnosticFrame): DiagnosticFrame => ({
|
|
467
|
+
...frame,
|
|
468
|
+
raw: cap(redactText(frame.raw), 1024),
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const redactDiagnostic = (diagnostic: Diagnostic): Diagnostic => {
|
|
472
|
+
const redacted: Writable<Diagnostic> = {
|
|
473
|
+
...diagnostic,
|
|
474
|
+
message: cap(redactText(diagnostic.message), limits.message),
|
|
475
|
+
frames: diagnostic.frames.slice(0, 50).map(redactFrame),
|
|
476
|
+
}
|
|
477
|
+
if (diagnostic.request !== undefined) {
|
|
478
|
+
redacted.request = {
|
|
479
|
+
method: diagnostic.request.method,
|
|
480
|
+
url: cap(redactText(diagnostic.request.url), 2048),
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (diagnostic.codeframe !== undefined) {
|
|
484
|
+
redacted.codeframe = {
|
|
485
|
+
...diagnostic.codeframe,
|
|
486
|
+
lines: diagnostic.codeframe.lines.map((line) => ({
|
|
487
|
+
...line,
|
|
488
|
+
text: cap(redactText(line.text), 400),
|
|
489
|
+
})),
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return redacted
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const recordDiagnostic = (raw: Diagnostic, meta: ErrorMeta): DevErrorEntry => {
|
|
496
|
+
const diagnostic = redactDiagnostic(raw)
|
|
497
|
+
const requestId = meta.requestId ?? currentRequestFor(id)?.requestId
|
|
498
|
+
const top = diagnostic.frames.find((frame) => frame.file !== undefined)
|
|
499
|
+
const fingerprint = [
|
|
500
|
+
meta.category,
|
|
501
|
+
diagnostic.code,
|
|
502
|
+
diagnostic.name,
|
|
503
|
+
firstLine(diagnostic.message).slice(0, 300),
|
|
504
|
+
top === undefined ? "" : `${top.file}:${top.line}`,
|
|
505
|
+
].join("|")
|
|
506
|
+
const key = `e_${fnv1a(fingerprint)}`
|
|
507
|
+
const at = nowIso()
|
|
508
|
+
seq += 1
|
|
509
|
+
const existing = errors.get(key)
|
|
510
|
+
const record: ErrorRecord = existing ?? {
|
|
511
|
+
seq,
|
|
512
|
+
id: key,
|
|
513
|
+
category: meta.category,
|
|
514
|
+
source: meta.source ?? "server",
|
|
515
|
+
diagnostic,
|
|
516
|
+
count: 0,
|
|
517
|
+
firstAt: at,
|
|
518
|
+
lastAt: at,
|
|
519
|
+
generation,
|
|
520
|
+
requestId: undefined,
|
|
521
|
+
route: undefined,
|
|
522
|
+
page: undefined,
|
|
523
|
+
resolvedAt: undefined,
|
|
524
|
+
}
|
|
525
|
+
record.seq = seq
|
|
526
|
+
record.diagnostic = diagnostic
|
|
527
|
+
record.count += 1
|
|
528
|
+
record.lastAt = at
|
|
529
|
+
record.generation = generation
|
|
530
|
+
record.resolvedAt = undefined
|
|
531
|
+
if (requestId !== undefined) record.requestId = requestId
|
|
532
|
+
if (meta.route !== undefined) record.route = meta.route
|
|
533
|
+
if (meta.page !== undefined) record.page = meta.page
|
|
534
|
+
// Re-insert: Map order is recency order, which eviction relies on.
|
|
535
|
+
errors.delete(key)
|
|
536
|
+
errors.set(key, record)
|
|
537
|
+
evictOldest(errors, limits.errors)
|
|
538
|
+
if (requestId !== undefined) {
|
|
539
|
+
const trace = traces.get(requestId)
|
|
540
|
+
if (trace !== undefined && !trace.errorIds.includes(key)) trace.errorIds.push(key)
|
|
541
|
+
}
|
|
542
|
+
const entry = snapshotError(record, generation)
|
|
543
|
+
persist?.write({ t: "error", ...entry }, true)
|
|
544
|
+
return entry
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const recordLog = (level: DevLogLevel, message: string, meta: LogMeta = {}): void => {
|
|
548
|
+
const text = cap(redactText(message), limits.message)
|
|
549
|
+
if (text.length === 0) return
|
|
550
|
+
const requestId = meta.requestId ?? currentRequestFor(id)?.requestId
|
|
551
|
+
seq += 1
|
|
552
|
+
const entry: Writable<DevLogEntry> = {
|
|
553
|
+
seq,
|
|
554
|
+
at: nowIso(),
|
|
555
|
+
level,
|
|
556
|
+
source: meta.source ?? "server",
|
|
557
|
+
message: text,
|
|
558
|
+
}
|
|
559
|
+
if (requestId !== undefined) entry.requestId = requestId
|
|
560
|
+
if (meta.page !== undefined) entry.page = meta.page
|
|
561
|
+
logs.push(entry)
|
|
562
|
+
logBytes += text.length
|
|
563
|
+
if (logs.length > limits.logs || logBytes > limits.logBytes) {
|
|
564
|
+
// Past the count cap, evict an extra tenth so a full ring does not re-slice on every line.
|
|
565
|
+
const byCount =
|
|
566
|
+
logs.length > limits.logs
|
|
567
|
+
? Math.min(logs.length - 1, logs.length - limits.logs + Math.floor(limits.logs / 10))
|
|
568
|
+
: 0
|
|
569
|
+
let cut = 0
|
|
570
|
+
let freed = 0
|
|
571
|
+
while (cut < logs.length - 1 && (cut < byCount || logBytes - freed > limits.logBytes)) {
|
|
572
|
+
freed += logs[cut]?.message.length ?? 0
|
|
573
|
+
cut += 1
|
|
574
|
+
}
|
|
575
|
+
logs = logs.slice(cut)
|
|
576
|
+
logBytes -= freed
|
|
577
|
+
dropped += cut
|
|
578
|
+
}
|
|
579
|
+
if (requestId !== undefined) {
|
|
580
|
+
const trace = traces.get(requestId)
|
|
581
|
+
if (trace !== undefined) trace.logCount += 1
|
|
582
|
+
}
|
|
583
|
+
persist?.write({ t: "log", ...entry }, level === "error")
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
id,
|
|
588
|
+
root,
|
|
589
|
+
get generation() {
|
|
590
|
+
return generation
|
|
591
|
+
},
|
|
592
|
+
get cursor() {
|
|
593
|
+
return seq
|
|
594
|
+
},
|
|
595
|
+
recordError(error, meta) {
|
|
596
|
+
const diagnosticOptions: Writable<BuildDiagnosticOptions> = { root }
|
|
597
|
+
if (meta.request !== undefined) diagnosticOptions.request = meta.request
|
|
598
|
+
if (options.showSource !== undefined) diagnosticOptions.showSource = options.showSource
|
|
599
|
+
if (meta.source !== "browser") diagnosticOptions.remap = ssrRemap
|
|
600
|
+
return recordDiagnostic(buildDiagnostic(error, diagnosticOptions), meta)
|
|
601
|
+
},
|
|
602
|
+
recordDiagnostic,
|
|
603
|
+
recordLog,
|
|
604
|
+
startRequest(requestId, method, path) {
|
|
605
|
+
seq += 1
|
|
606
|
+
traces.set(requestId, {
|
|
607
|
+
seq,
|
|
608
|
+
requestId,
|
|
609
|
+
method,
|
|
610
|
+
path: cap(redactText(path), 2048),
|
|
611
|
+
at: nowIso(),
|
|
612
|
+
status: undefined,
|
|
613
|
+
durationMs: undefined,
|
|
614
|
+
bytes: undefined,
|
|
615
|
+
isr: undefined,
|
|
616
|
+
errorIds: [],
|
|
617
|
+
logCount: 0,
|
|
618
|
+
})
|
|
619
|
+
evictOldest(traces, limits.requests)
|
|
620
|
+
},
|
|
621
|
+
finishRequest(requestId, response, durationMs) {
|
|
622
|
+
const trace = traces.get(requestId)
|
|
623
|
+
if (trace === undefined) return
|
|
624
|
+
trace.status = response.status
|
|
625
|
+
trace.durationMs = Math.round(durationMs * 100) / 100
|
|
626
|
+
trace.bytes = response.bytes
|
|
627
|
+
trace.isr = response.isr
|
|
628
|
+
},
|
|
629
|
+
markChange() {
|
|
630
|
+
generation += 1
|
|
631
|
+
},
|
|
632
|
+
resolve(category) {
|
|
633
|
+
const at = nowIso()
|
|
634
|
+
for (const record of errors.values()) {
|
|
635
|
+
if (record.category === category && record.resolvedAt === undefined) record.resolvedAt = at
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
errors(query = {}) {
|
|
639
|
+
const since = query.since ?? 0
|
|
640
|
+
const selected: DevErrorEntry[] = []
|
|
641
|
+
for (const record of errors.values()) {
|
|
642
|
+
if (record.seq <= since) continue
|
|
643
|
+
if (query.categories !== undefined && !query.categories.includes(record.category)) continue
|
|
644
|
+
if (query.includeResolved !== true && record.resolvedAt !== undefined) continue
|
|
645
|
+
if (query.includeStale === false && record.generation < generation) continue
|
|
646
|
+
if (query.requestId !== undefined && record.requestId !== query.requestId) continue
|
|
647
|
+
selected.push(snapshotError(record, generation))
|
|
648
|
+
}
|
|
649
|
+
const limit = clampLimit(query.limit, 50, limits.errors)
|
|
650
|
+
return { errors: selected.slice(-limit), cursor: seq, generation }
|
|
651
|
+
},
|
|
652
|
+
logs(query = {}) {
|
|
653
|
+
const since = query.since ?? 0
|
|
654
|
+
const needle = query.grep?.toLowerCase()
|
|
655
|
+
const selected = logs.filter(
|
|
656
|
+
(entry) =>
|
|
657
|
+
entry.seq > since &&
|
|
658
|
+
(query.levels === undefined || query.levels.includes(entry.level)) &&
|
|
659
|
+
(query.source === undefined || entry.source === query.source) &&
|
|
660
|
+
(query.requestId === undefined || entry.requestId === query.requestId) &&
|
|
661
|
+
(needle === undefined || entry.message.toLowerCase().includes(needle)),
|
|
662
|
+
)
|
|
663
|
+
const limit = clampLimit(query.limit, 200, limits.logs)
|
|
664
|
+
return { logs: selected.slice(-limit), cursor: seq, generation, dropped }
|
|
665
|
+
},
|
|
666
|
+
requests(query = {}) {
|
|
667
|
+
const since = query.since ?? 0
|
|
668
|
+
const selected: DevRequestTrace[] = []
|
|
669
|
+
for (const trace of traces.values()) {
|
|
670
|
+
if (trace.seq <= since) continue
|
|
671
|
+
if (query.path !== undefined && !trace.path.startsWith(query.path)) continue
|
|
672
|
+
if (query.requestId !== undefined && trace.requestId !== query.requestId) continue
|
|
673
|
+
selected.push(snapshotTrace(trace))
|
|
674
|
+
}
|
|
675
|
+
const limit = clampLimit(query.limit, 50, limits.requests)
|
|
676
|
+
return { requests: selected.slice(-limit), cursor: seq, generation }
|
|
677
|
+
},
|
|
678
|
+
counts: () => ({ errors: errors.size, logs: logs.length, requests: traces.size }),
|
|
679
|
+
redact: redactText,
|
|
680
|
+
flush: () => persist?.flush(),
|
|
681
|
+
close: () => persist?.close(),
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------------------------------
|
|
686
|
+
// Persistence
|
|
687
|
+
// ---------------------------------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
const LOG_ROTATE_BYTES = 2 * 1024 * 1024
|
|
690
|
+
const LOG_FLUSH_MS = 250
|
|
691
|
+
|
|
692
|
+
interface Persistence {
|
|
693
|
+
write(entry: Readonly<Record<string, unknown>>, sync: boolean): void
|
|
694
|
+
flush(): void
|
|
695
|
+
close(): void
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* NDJSON on disk so a crashed dev server still leaves a record. Error-level writes are synchronous (the
|
|
700
|
+
* line most worth keeping is the one written just before a crash); the rest is buffered briefly. One
|
|
701
|
+
* rotation (`.1`) bounds the footprint. A write failure disables persistence rather than breaking dev.
|
|
702
|
+
*/
|
|
703
|
+
function createPersistence(root: string): Persistence {
|
|
704
|
+
const path = join(root, DEV_SERVER_LOG_FILE)
|
|
705
|
+
let buffer: string[] = []
|
|
706
|
+
let size = 0
|
|
707
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
708
|
+
let disabled = false
|
|
709
|
+
try {
|
|
710
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
711
|
+
size = existsSync(path) ? statSync(path).size : 0
|
|
712
|
+
} catch {
|
|
713
|
+
disabled = true
|
|
714
|
+
}
|
|
715
|
+
const flush = (): void => {
|
|
716
|
+
if (timer !== undefined) {
|
|
717
|
+
clearTimeout(timer)
|
|
718
|
+
timer = undefined
|
|
719
|
+
}
|
|
720
|
+
if (disabled || buffer.length === 0) return
|
|
721
|
+
const chunk = buffer.join("")
|
|
722
|
+
buffer = []
|
|
723
|
+
try {
|
|
724
|
+
if (size + chunk.length > LOG_ROTATE_BYTES) {
|
|
725
|
+
renameSync(path, `${path}.1`)
|
|
726
|
+
size = 0
|
|
727
|
+
}
|
|
728
|
+
appendFileSync(path, chunk)
|
|
729
|
+
size += chunk.length
|
|
730
|
+
} catch {
|
|
731
|
+
disabled = true
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
write(entry, sync) {
|
|
736
|
+
if (disabled) return
|
|
737
|
+
buffer.push(`${JSON.stringify(entry)}\n`)
|
|
738
|
+
if (sync) flush()
|
|
739
|
+
else if (timer === undefined) {
|
|
740
|
+
timer = setTimeout(flush, LOG_FLUSH_MS)
|
|
741
|
+
timer.unref?.()
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
flush,
|
|
745
|
+
close: () => {
|
|
746
|
+
flush()
|
|
747
|
+
disabled = true
|
|
748
|
+
},
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
753
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
754
|
+
|
|
755
|
+
function isPersistedError(value: unknown): value is DevErrorEntry {
|
|
756
|
+
return (
|
|
757
|
+
isRecord(value) &&
|
|
758
|
+
typeof value.id === "string" &&
|
|
759
|
+
typeof value.seq === "number" &&
|
|
760
|
+
isDevErrorCategory(value.category) &&
|
|
761
|
+
isRecord(value.diagnostic)
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function isPersistedLog(value: unknown): value is DevLogEntry {
|
|
766
|
+
return (
|
|
767
|
+
isRecord(value) &&
|
|
768
|
+
typeof value.message === "string" &&
|
|
769
|
+
typeof value.seq === "number" &&
|
|
770
|
+
isDevLogLevel(value.level)
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The persisted record of a dev server that may no longer be running: newest last. */
|
|
775
|
+
export function readPersistedFeed(
|
|
776
|
+
root: string,
|
|
777
|
+
limit = 200,
|
|
778
|
+
): { readonly errors: DevErrorEntry[]; readonly logs: DevLogEntry[] } {
|
|
779
|
+
const path = join(root, DEV_SERVER_LOG_FILE)
|
|
780
|
+
const errors = new Map<string, DevErrorEntry>()
|
|
781
|
+
const logs: DevLogEntry[] = []
|
|
782
|
+
for (const file of [`${path}.1`, path]) {
|
|
783
|
+
let text: string
|
|
784
|
+
try {
|
|
785
|
+
text = readFileSync(file, "utf8")
|
|
786
|
+
} catch {
|
|
787
|
+
continue
|
|
788
|
+
}
|
|
789
|
+
for (const line of text.split("\n")) {
|
|
790
|
+
if (line.length === 0) continue
|
|
791
|
+
let value: unknown
|
|
792
|
+
try {
|
|
793
|
+
value = JSON.parse(line)
|
|
794
|
+
} catch {
|
|
795
|
+
continue
|
|
796
|
+
}
|
|
797
|
+
if (!isRecord(value)) continue
|
|
798
|
+
const { t, ...entry } = value
|
|
799
|
+
if (t === "error" && isPersistedError(entry)) {
|
|
800
|
+
errors.delete(entry.id)
|
|
801
|
+
errors.set(entry.id, entry)
|
|
802
|
+
} else if (t === "log" && isPersistedLog(entry)) {
|
|
803
|
+
logs.push(entry)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return { errors: [...errors.values()].slice(-limit), logs: logs.slice(-limit) }
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Record a dev server process that died, from the process that supervised it (`nifra dev` re-execs the
|
|
812
|
+
* Bun pipeline and pipes the child's stderr). The child's own monitor never sees an unhandled rejection
|
|
813
|
+
* on Bun, and a native crash print bypasses the console, so the supervisor's view of stderr is the
|
|
814
|
+
* record that survives. Persisted only: there is no live server left to serve it.
|
|
815
|
+
*/
|
|
816
|
+
export function recordDevCrash(root: string, exitCode: number | null, stderrTail: string): void {
|
|
817
|
+
const feed = createDevFeed({ root })
|
|
818
|
+
const summary = `the dev server exited with code ${exitCode ?? "unknown"}`
|
|
819
|
+
const error = new Error(summary)
|
|
820
|
+
error.name = "DevServerExit"
|
|
821
|
+
error.stack = `DevServerExit: ${summary}\n${stderrTail.slice(-DEFAULT_DEV_FEED_LIMITS.stack)}`
|
|
822
|
+
feed.recordError(error, { category: "process" })
|
|
823
|
+
feed.close()
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------------------------------
|
|
827
|
+
// Discovery
|
|
828
|
+
// ---------------------------------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
/** What a running dev server writes to {@link DEV_SERVER_RECORD_FILE} so tools can find it. */
|
|
831
|
+
export interface DevServerRecord {
|
|
832
|
+
readonly schema: number
|
|
833
|
+
readonly pid: number
|
|
834
|
+
readonly port: number
|
|
835
|
+
readonly url: string
|
|
836
|
+
readonly pipeline: DevPipeline
|
|
837
|
+
readonly root: string
|
|
838
|
+
readonly startedAt: string
|
|
839
|
+
/** Sent as {@link DEV_TOKEN_HEADER}. Only a reader of this file (mode 0600) can present it. */
|
|
840
|
+
readonly token: string
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** A fresh 256-bit token. */
|
|
844
|
+
export function createDevToken(): string {
|
|
845
|
+
return randomBytes(32).toString("base64url")
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Write the record atomically (temp file + rename) and owner-only. */
|
|
849
|
+
export function writeDevServerRecord(root: string, record: DevServerRecord): void {
|
|
850
|
+
const path = join(root, DEV_SERVER_RECORD_FILE)
|
|
851
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
852
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
853
|
+
writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
854
|
+
renameSync(temp, path)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Remove the record only while it is still this server's: a newer server may have replaced it. */
|
|
858
|
+
export function removeDevServerRecord(root: string, token: string): void {
|
|
859
|
+
// @nifra-gate-reviewed: the server comparing its own token with a file on this machine; no request
|
|
860
|
+
// reaches this comparison.
|
|
861
|
+
if (readDevServerRecord(root)?.token !== token) return
|
|
862
|
+
rmSync(join(root, DEV_SERVER_RECORD_FILE), { force: true })
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const isPort = (value: unknown): value is number =>
|
|
866
|
+
typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value < 65536
|
|
867
|
+
|
|
868
|
+
function isDevServerRecord(value: unknown): value is DevServerRecord {
|
|
869
|
+
return (
|
|
870
|
+
isRecord(value) &&
|
|
871
|
+
typeof value.schema === "number" &&
|
|
872
|
+
typeof value.pid === "number" &&
|
|
873
|
+
Number.isSafeInteger(value.pid) &&
|
|
874
|
+
isPort(value.port) &&
|
|
875
|
+
typeof value.url === "string" &&
|
|
876
|
+
(value.pipeline === "bun" || value.pipeline === "vite") &&
|
|
877
|
+
typeof value.root === "string" &&
|
|
878
|
+
typeof value.startedAt === "string" &&
|
|
879
|
+
typeof value.token === "string" &&
|
|
880
|
+
value.token.length >= 32
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** The record at `root`, or undefined when absent or malformed. Says nothing about liveness. */
|
|
885
|
+
export function readDevServerRecord(root: string): DevServerRecord | undefined {
|
|
886
|
+
let value: unknown
|
|
887
|
+
try {
|
|
888
|
+
value = JSON.parse(readFileSync(join(root, DEV_SERVER_RECORD_FILE), "utf8"))
|
|
889
|
+
} catch {
|
|
890
|
+
return undefined
|
|
891
|
+
}
|
|
892
|
+
return isDevServerRecord(value) ? value : undefined
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Whether a process with `pid` exists (EPERM means it exists but is not ours). */
|
|
896
|
+
export function isProcessAlive(pid: number): boolean {
|
|
897
|
+
try {
|
|
898
|
+
process.kill(pid, 0)
|
|
899
|
+
return true
|
|
900
|
+
} catch (error) {
|
|
901
|
+
return error instanceof Error && "code" in error && error.code === "EPERM"
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ---------------------------------------------------------------------------------------------------
|
|
906
|
+
// Console + stream capture
|
|
907
|
+
// ---------------------------------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
export interface CaptureSink {
|
|
910
|
+
/** The feed this sink records into; a line logged inside one of its requests reaches only it. */
|
|
911
|
+
readonly feedId: string
|
|
912
|
+
line(level: DevLogLevel, message: string, request: DevRequestContext | undefined): void
|
|
913
|
+
/** A structured line from core's JSON logger (`{ level, message, ... }`). */
|
|
914
|
+
structured?(entry: CoreLogEntry, request: DevRequestContext | undefined): void
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export interface CoreLogEntry {
|
|
918
|
+
readonly level: string
|
|
919
|
+
readonly message: string
|
|
920
|
+
readonly [field: string]: unknown
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
type ConsoleMethod = "debug" | "info" | "log" | "warn" | "error" | "trace"
|
|
924
|
+
const CONSOLE_METHODS: readonly ConsoleMethod[] = ["debug", "info", "log", "warn", "error", "trace"]
|
|
925
|
+
const CONSOLE_LEVELS: Readonly<Record<ConsoleMethod, DevLogLevel>> = {
|
|
926
|
+
debug: "debug",
|
|
927
|
+
info: "info",
|
|
928
|
+
log: "log",
|
|
929
|
+
warn: "warn",
|
|
930
|
+
error: "error",
|
|
931
|
+
trace: "log",
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
type ConsoleFn = (...args: unknown[]) => void
|
|
935
|
+
type WriteFn = (chunk: unknown, ...rest: unknown[]) => boolean
|
|
936
|
+
|
|
937
|
+
interface CaptureState {
|
|
938
|
+
readonly sinks: Set<CaptureSink>
|
|
939
|
+
/** Inside a patched console call: a stream write it makes (Node) is the same line, not a new one. */
|
|
940
|
+
depth: number
|
|
941
|
+
restore: () => void
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Structural, not `instanceof`: a second copy of this module must join the first one's patch rather
|
|
945
|
+
// than layer another on top and capture every line twice.
|
|
946
|
+
const isCaptureState = (value: unknown): value is CaptureState =>
|
|
947
|
+
isRecord(value) &&
|
|
948
|
+
value.sinks instanceof Set &&
|
|
949
|
+
typeof value.depth === "number" &&
|
|
950
|
+
typeof value.restore === "function"
|
|
951
|
+
|
|
952
|
+
const CAPTURE_KEY = Symbol.for("nifra.devFeed.capture")
|
|
953
|
+
const MAX_PARTIAL_LINE = 16 * 1024
|
|
954
|
+
|
|
955
|
+
/** Format console arguments the way the console itself would, without color. */
|
|
956
|
+
export function formatConsoleArgs(args: readonly unknown[]): string {
|
|
957
|
+
try {
|
|
958
|
+
return formatWithOptions(
|
|
959
|
+
{ colors: false, depth: 4, breakLength: Number.POSITIVE_INFINITY, maxArrayLength: 50 },
|
|
960
|
+
...args,
|
|
961
|
+
)
|
|
962
|
+
} catch {
|
|
963
|
+
return args.map((arg) => String(arg)).join(" ")
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const isCoreLogEntry = (value: unknown): value is CoreLogEntry =>
|
|
968
|
+
isRecord(value) && typeof value.level === "string" && typeof value.message === "string"
|
|
969
|
+
|
|
970
|
+
/** Parse a line core's default JSON logger wrote, or undefined for anything else. */
|
|
971
|
+
export function parseCoreLogLine(line: string): CoreLogEntry | undefined {
|
|
972
|
+
if (!line.startsWith("{") || !line.endsWith("}")) return undefined
|
|
973
|
+
try {
|
|
974
|
+
const value: unknown = JSON.parse(line)
|
|
975
|
+
return isCoreLogEntry(value) ? value : undefined
|
|
976
|
+
} catch {
|
|
977
|
+
return undefined
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** The thrown error core's `unhandled request error` log line describes, rebuilt for a Diagnostic. */
|
|
982
|
+
export function errorFromCoreLog(entry: CoreLogEntry): Error | undefined {
|
|
983
|
+
if (entry.message !== "unhandled request error") return undefined
|
|
984
|
+
const name = typeof entry.name === "string" ? entry.name : "Error"
|
|
985
|
+
const detail = typeof entry.detail === "string" ? entry.detail : name
|
|
986
|
+
const error = new Error(detail)
|
|
987
|
+
error.name = name
|
|
988
|
+
error.stack = typeof entry.stack === "string" ? entry.stack : `${name}: ${detail}`
|
|
989
|
+
return error
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Tee the process's console and stdout/stderr into `sink`, leaving the real output untouched. Global
|
|
994
|
+
* and reference-counted: several dev servers (tests start many) share one patch, and the originals come
|
|
995
|
+
* back when the last sink detaches. Returns the detach function.
|
|
996
|
+
*/
|
|
997
|
+
export function installCapture(sink: CaptureSink): () => void {
|
|
998
|
+
const existing: unknown = Reflect.get(globalThis, CAPTURE_KEY)
|
|
999
|
+
const state = isCaptureState(existing) ? existing : patchProcess()
|
|
1000
|
+
Reflect.set(globalThis, CAPTURE_KEY, state)
|
|
1001
|
+
state.sinks.add(sink)
|
|
1002
|
+
let attached = true
|
|
1003
|
+
return () => {
|
|
1004
|
+
if (!attached) return
|
|
1005
|
+
attached = false
|
|
1006
|
+
state.sinks.delete(sink)
|
|
1007
|
+
if (state.sinks.size === 0 && Reflect.get(globalThis, CAPTURE_KEY) === state) {
|
|
1008
|
+
state.restore()
|
|
1009
|
+
Reflect.deleteProperty(globalThis, CAPTURE_KEY)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function patchProcess(): CaptureState {
|
|
1015
|
+
const state: CaptureState = { sinks: new Set(), depth: 0, restore: () => {} }
|
|
1016
|
+
const dispatch = (level: DevLogLevel, message: string, structured?: CoreLogEntry): void => {
|
|
1017
|
+
const request = requestContext.getStore()
|
|
1018
|
+
for (const sink of state.sinks) {
|
|
1019
|
+
if (request !== undefined && request.feedId !== sink.feedId) continue
|
|
1020
|
+
try {
|
|
1021
|
+
if (structured !== undefined && sink.structured !== undefined) {
|
|
1022
|
+
sink.structured(structured, request)
|
|
1023
|
+
} else sink.line(level, message, request)
|
|
1024
|
+
} catch {
|
|
1025
|
+
// A sink must never break the console it observes.
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const consoleOriginals = new Map<ConsoleMethod, { original: ConsoleFn; patched: ConsoleFn }>()
|
|
1031
|
+
for (const method of CONSOLE_METHODS) {
|
|
1032
|
+
const original: ConsoleFn = console[method]
|
|
1033
|
+
const patched: ConsoleFn = function (this: unknown, ...args: unknown[]): void {
|
|
1034
|
+
state.depth += 1
|
|
1035
|
+
try {
|
|
1036
|
+
original.apply(this, args)
|
|
1037
|
+
} finally {
|
|
1038
|
+
state.depth -= 1
|
|
1039
|
+
}
|
|
1040
|
+
dispatch(CONSOLE_LEVELS[method], stripVTControlCharacters(formatConsoleArgs(args)))
|
|
1041
|
+
}
|
|
1042
|
+
consoleOriginals.set(method, { original, patched })
|
|
1043
|
+
console[method] = patched
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const streamOriginals: Array<{ stream: object; original: unknown; patched: WriteFn }> = []
|
|
1047
|
+
for (const [stream, level] of [
|
|
1048
|
+
[process.stdout, "log"],
|
|
1049
|
+
[process.stderr, "error"],
|
|
1050
|
+
] as const) {
|
|
1051
|
+
const original: unknown = Reflect.get(stream, "write")
|
|
1052
|
+
if (typeof original !== "function") continue
|
|
1053
|
+
let partial = ""
|
|
1054
|
+
const patched: WriteFn = function (this: unknown, chunk, ...rest) {
|
|
1055
|
+
if (state.depth === 0) {
|
|
1056
|
+
partial +=
|
|
1057
|
+
typeof chunk === "string"
|
|
1058
|
+
? chunk
|
|
1059
|
+
: chunk instanceof Uint8Array
|
|
1060
|
+
? new TextDecoder().decode(chunk)
|
|
1061
|
+
: ""
|
|
1062
|
+
let newline = partial.indexOf("\n")
|
|
1063
|
+
while (newline !== -1) {
|
|
1064
|
+
const line = stripVTControlCharacters(partial.slice(0, newline)).trimEnd()
|
|
1065
|
+
partial = partial.slice(newline + 1)
|
|
1066
|
+
if (line.length > 0) {
|
|
1067
|
+
const structured = parseCoreLogLine(line)
|
|
1068
|
+
dispatch(
|
|
1069
|
+
structured !== undefined && isDevLogLevel(structured.level)
|
|
1070
|
+
? structured.level
|
|
1071
|
+
: level,
|
|
1072
|
+
line,
|
|
1073
|
+
structured,
|
|
1074
|
+
)
|
|
1075
|
+
}
|
|
1076
|
+
newline = partial.indexOf("\n")
|
|
1077
|
+
}
|
|
1078
|
+
if (partial.length > MAX_PARTIAL_LINE) {
|
|
1079
|
+
dispatch(level, stripVTControlCharacters(partial))
|
|
1080
|
+
partial = ""
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return Reflect.apply(original, this ?? stream, [chunk, ...rest]) !== false
|
|
1084
|
+
}
|
|
1085
|
+
streamOriginals.push({ stream, original, patched })
|
|
1086
|
+
Reflect.set(stream, "write", patched)
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
state.restore = () => {
|
|
1090
|
+
for (const [method, { original, patched }] of consoleOriginals) {
|
|
1091
|
+
// Only undo our own patch: a later patch layered on top stays, still forwarding to ours.
|
|
1092
|
+
if (console[method] === patched) console[method] = original
|
|
1093
|
+
}
|
|
1094
|
+
for (const { stream, original, patched } of streamOriginals) {
|
|
1095
|
+
if (Reflect.get(stream, "write") === patched) Reflect.set(stream, "write", original)
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return state
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const structuredText = (entry: CoreLogEntry): string => {
|
|
1102
|
+
const { level: _level, message, time: _time, ...fields } = entry
|
|
1103
|
+
return Object.keys(fields).length === 0 ? message : `${message} ${JSON.stringify(fields)}`
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** A server frame's authored position, through the map a dev plugin registered for the file it compiled. */
|
|
1107
|
+
const ssrRemap = (
|
|
1108
|
+
file: string,
|
|
1109
|
+
line: number,
|
|
1110
|
+
column: number,
|
|
1111
|
+
): { readonly line: number; readonly column: number } | undefined => {
|
|
1112
|
+
const map = ssrSourceMaps().get(file.replaceAll("\\", "/"))
|
|
1113
|
+
return map === undefined ? undefined : originalPosition(map, line, column)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Record the process's console and stream output into `feed`: every line as a log entry, and core's
|
|
1118
|
+
* `unhandled request error` line additionally as an `api` error with a full Diagnostic. The one sink
|
|
1119
|
+
* both dev servers and `nifra_run` use. Returns the detach function.
|
|
1120
|
+
*/
|
|
1121
|
+
export function captureInto(feed: DevFeed): () => void {
|
|
1122
|
+
return installCapture({
|
|
1123
|
+
feedId: feed.id,
|
|
1124
|
+
line: (level, message, request) =>
|
|
1125
|
+
feed.recordLog(level, message, { requestId: request?.requestId }),
|
|
1126
|
+
structured: (entry, request) => {
|
|
1127
|
+
const level = isDevLogLevel(entry.level) ? entry.level : "log"
|
|
1128
|
+
feed.recordLog(level, structuredText(entry), { requestId: request?.requestId })
|
|
1129
|
+
const error = errorFromCoreLog(entry)
|
|
1130
|
+
if (error === undefined) return
|
|
1131
|
+
const method = typeof entry.method === "string" ? entry.method : request?.method
|
|
1132
|
+
const path = typeof entry.path === "string" ? entry.path : request?.path
|
|
1133
|
+
feed.recordError(error, {
|
|
1134
|
+
category: "api",
|
|
1135
|
+
requestId: request?.requestId,
|
|
1136
|
+
request: method !== undefined && path !== undefined ? { method, url: path } : undefined,
|
|
1137
|
+
})
|
|
1138
|
+
},
|
|
1139
|
+
})
|
|
1140
|
+
}
|