@astroscope/node 2.1.0 → 3.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.
Files changed (70) hide show
  1. package/README.md +8 -5
  2. package/dist/boot.d.ts +2 -2
  3. package/dist/boot.d.ts.map +1 -1
  4. package/dist/csrf-middleware-entrypoint.d.ts +1 -2
  5. package/dist/csrf-middleware-entrypoint.d.ts.map +1 -1
  6. package/dist/csrf-middleware-entrypoint.js +1 -1
  7. package/dist/dev-middleware-entrypoint.d.ts +1 -2
  8. package/dist/dev-middleware-entrypoint.d.ts.map +1 -1
  9. package/dist/{prepare-CXZsyAVk.js → duplicate-slashes-C6hmOLl9.js} +94 -38
  10. package/dist/duplicate-slashes-C6hmOLl9.js.map +1 -0
  11. package/dist/{excludes-pE23EbmQ.js → excludes-10eDopVB.js} +18 -4
  12. package/dist/excludes-10eDopVB.js.map +1 -0
  13. package/dist/{excludes-BDiE3eyp.d.ts → excludes-rvkVGg6B.d.ts} +11 -3
  14. package/dist/excludes-rvkVGg6B.d.ts.map +1 -0
  15. package/dist/excludes.d.ts +2 -2
  16. package/dist/excludes.js +2 -2
  17. package/dist/graph-DdAxiaxa.js +35 -0
  18. package/dist/graph-DdAxiaxa.js.map +1 -0
  19. package/dist/health.d.ts +3 -4
  20. package/dist/health.d.ts.map +1 -1
  21. package/dist/image-endpoint.d.ts +1 -2
  22. package/dist/image-endpoint.d.ts.map +1 -1
  23. package/dist/index.d.ts +4 -4
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +38 -122
  26. package/dist/index.js.map +1 -1
  27. package/dist/islands-middleware-entrypoint.d.ts +1 -2
  28. package/dist/islands-middleware-entrypoint.d.ts.map +1 -1
  29. package/dist/islands-middleware-entrypoint.js +3 -1
  30. package/dist/islands-middleware-entrypoint.js.map +1 -1
  31. package/dist/islands-runtime.iife.js +1 -1
  32. package/dist/islands.d.ts +57 -2
  33. package/dist/islands.d.ts.map +1 -0
  34. package/dist/islands.js +3 -1
  35. package/dist/log/index.d.ts +5 -5
  36. package/dist/log/index.d.ts.map +1 -1
  37. package/dist/native.d.ts +5 -6
  38. package/dist/native.d.ts.map +1 -1
  39. package/dist/{prerendered-DVjzWNbW.js → prerendered-Z-Qi-FFK.js} +4 -4
  40. package/dist/prerendered-Z-Qi-FFK.js.map +1 -0
  41. package/dist/request-route-DcnZOOM4.js.map +1 -1
  42. package/dist/route-islands-B53rE5MH.js +73 -0
  43. package/dist/route-islands-B53rE5MH.js.map +1 -0
  44. package/dist/route-middleware-entrypoint.d.ts +1 -2
  45. package/dist/route-middleware-entrypoint.d.ts.map +1 -1
  46. package/dist/{route-store-CSYR4G5e.d.ts → route-store-neUA2nBb.d.ts} +10 -14
  47. package/dist/route-store-neUA2nBb.d.ts.map +1 -0
  48. package/dist/routes-D1o_WrJ5.js +39 -0
  49. package/dist/routes-D1o_WrJ5.js.map +1 -0
  50. package/dist/server.d.ts +2 -3
  51. package/dist/server.d.ts.map +1 -1
  52. package/dist/server.js +68 -45
  53. package/dist/server.js.map +1 -1
  54. package/dist/telemetry-B9aziFyQ.js +202 -0
  55. package/dist/telemetry-B9aziFyQ.js.map +1 -0
  56. package/dist/telemetry.d.ts +68 -0
  57. package/dist/telemetry.d.ts.map +1 -0
  58. package/dist/telemetry.js +2 -0
  59. package/dist/transform-Bz5z5w2Y.js +594 -0
  60. package/dist/transform-Bz5z5w2Y.js.map +1 -0
  61. package/package.json +13 -11
  62. package/dist/excludes-BDiE3eyp.d.ts.map +0 -1
  63. package/dist/excludes-pE23EbmQ.js.map +0 -1
  64. package/dist/prepare-CXZsyAVk.js.map +0 -1
  65. package/dist/prerendered-DVjzWNbW.js.map +0 -1
  66. package/dist/route-store-CSYR4G5e.d.ts.map +0 -1
  67. package/dist/strip-build-paths-C6eb1QdG.js +0 -63
  68. package/dist/strip-build-paths-C6eb1QdG.js.map +0 -1
  69. package/dist/transform-BqV8SOEm.js +0 -195
  70. package/dist/transform-BqV8SOEm.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { t as createIslandsTransformer } from "./transform-BqV8SOEm.js";
1
+ import { t as createIslandsTransformer } from "./transform-Bz5z5w2Y.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  //#region src/islands/prerendered.ts
@@ -14,13 +14,13 @@ function* walkHtmlFiles(dir) {
14
14
  * once, at build time, before the client dir is compressed. Runs without a request
15
15
  * context, so registered emitters that need one contribute nothing here.
16
16
  */
17
- async function transformPrerenderedHtml(clientDir, manifest) {
17
+ function transformPrerenderedHtml(clientDir, manifest) {
18
18
  const transformer = createIslandsTransformer(manifest);
19
19
  let transformed = 0;
20
20
  for (const file of walkHtmlFiles(clientDir)) {
21
21
  const html = fs.readFileSync(file, "utf-8");
22
22
  const rewriter = transformer.createDocumentRewriter();
23
- const result = rewriter.write(html) + await rewriter.end();
23
+ const result = rewriter.write(html) + rewriter.end();
24
24
  if (result !== html) {
25
25
  fs.writeFileSync(file, result);
26
26
  transformed++;
@@ -31,4 +31,4 @@ async function transformPrerenderedHtml(clientDir, manifest) {
31
31
  //#endregion
32
32
  export { transformPrerenderedHtml };
33
33
 
34
- //# sourceMappingURL=prerendered-DVjzWNbW.js.map
34
+ //# sourceMappingURL=prerendered-Z-Qi-FFK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prerendered-Z-Qi-FFK.js","names":[],"sources":["../src/islands/prerendered.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport { createIslandsTransformer } from './transform.js';\nimport type { IslandsManifest } from './types.js';\n\nfunction* walkHtmlFiles(dir: string): Generator<string> {\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\n const full = path.join(dir, entry.name);\n\n if (entry.isDirectory()) {\n yield* walkHtmlFiles(full);\n } else if (entry.isFile() && entry.name.endsWith('.html')) {\n yield full;\n }\n }\n}\n\n/**\n * Prerendered pages never pass through the middleware — they get the same rewrite\n * once, at build time, before the client dir is compressed. Runs without a request\n * context, so registered emitters that need one contribute nothing here.\n */\nexport function transformPrerenderedHtml(clientDir: string, manifest: IslandsManifest): number {\n const transformer = createIslandsTransformer(manifest);\n let transformed = 0;\n\n for (const file of walkHtmlFiles(clientDir)) {\n const html = fs.readFileSync(file, 'utf-8');\n const rewriter = transformer.createDocumentRewriter();\n const result = rewriter.write(html) + rewriter.end();\n\n if (result !== html) {\n fs.writeFileSync(file, result);\n transformed++;\n }\n }\n\n return transformed;\n}\n"],"mappings":";;;;AAKA,UAAU,cAAc,KAAgC;CACtD,KAAK,MAAM,SAAS,GAAG,YAAY,KAAK,EAAE,eAAe,KAAK,CAAC,GAAG;EAChE,MAAM,OAAO,KAAK,KAAK,KAAK,MAAM,IAAI;EAEtC,IAAI,MAAM,YAAY,GACpB,OAAO,cAAc,IAAI;OACpB,IAAI,MAAM,OAAO,KAAK,MAAM,KAAK,SAAS,OAAO,GACtD,MAAM;CAEV;AACF;;;;;;AAOA,SAAgB,yBAAyB,WAAmB,UAAmC;CAC7F,MAAM,cAAc,yBAAyB,QAAQ;CACrD,IAAI,cAAc;CAElB,KAAK,MAAM,QAAQ,cAAc,SAAS,GAAG;EAC3C,MAAM,OAAO,GAAG,aAAa,MAAM,OAAO;EAC1C,MAAM,WAAW,YAAY,uBAAuB;EACpD,MAAM,SAAS,SAAS,MAAM,IAAI,IAAI,SAAS,IAAI;EAEnD,IAAI,WAAW,MAAM;GACnB,GAAG,cAAc,MAAM,MAAM;GAC7B;EACF;CACF;CAEA,OAAO;AACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"request-route-DcnZOOM4.js","names":[],"sources":["../src/observability/log/store.ts","../src/observability/request-route.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport type { Logger } from 'pino';\n\n/**\n * Shared state between the public `log` proxy and the server runtime. Keyed on\n * `globalThis` because the two sides may live in different module instances\n * (bundled app vs vite module runner in dev).\n */\n\nconst STORE_KEY = Symbol.for('@astroscope/node/log');\n\nexport const EARLY_LOG_BUFFER_CAP = 100;\n\nexport interface BufferedEntry {\n level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';\n bindings: Record<string, unknown>[];\n args: unknown[];\n time: number;\n}\n\nexport interface RequestRecord {\n logger: Logger | undefined;\n url: string;\n method: string;\n route: string | undefined;\n routeOverride: boolean;\n actionName: string | undefined;\n}\n\nexport interface LogStore {\n root: Logger | undefined;\n requestStorage: AsyncLocalStorage<RequestRecord>;\n buffer: BufferedEntry[];\n dropped: number;\n}\n\nexport function getLogStore(): LogStore {\n const g = globalThis as Record<symbol, unknown>;\n let store = g[STORE_KEY] as LogStore | undefined;\n\n if (!store) {\n store = { root: undefined, requestStorage: new AsyncLocalStorage(), buffer: [], dropped: 0 };\n g[STORE_KEY] = store;\n }\n\n return store;\n}\n\nexport function getRequestRecord(): RequestRecord | undefined {\n return getLogStore().requestStorage.getStore();\n}\n","import { trace } from '@opentelemetry/api';\nimport { getRequestRecord } from './log/store.js';\n\n/**\n * Apply a route label to the request record (final log line, request duration\n * metric) and the active server span.\n *\n * Last write wins — astro's routing stamps every pass, so a rewrite lands the\n * route that rendered. An override comes from the middleware that knows what it\n * served and outranks routing, whatever the middleware order.\n */\nfunction applyRoute(route: string, method: string, override: boolean): void {\n const record = getRequestRecord();\n\n if (record) {\n if (!override && record.routeOverride) return;\n\n record.route = route;\n\n if (override) {\n record.routeOverride = true;\n }\n }\n\n const span = trace.getActiveSpan();\n\n if (span?.isRecording()) {\n span.setAttribute('http.route', route);\n\n if (!record?.actionName) {\n span.updateName(`${method} ${route}`);\n }\n }\n}\n\n/**\n * Report the route that actually served the current request, overriding the one\n * astro's routing matched.\n *\n * A middleware that rewrites (`next(url)`) or answers with its own response\n * serves a request astro has no page for, so routing matches `/404` and that is\n * what the request is logged and measured as — every such request collapsing\n * into one `/404` bucket in the request metrics. Calling this corrects the log\n * line, the metric and the server span name together.\n *\n * Pass a templated label rather than a concrete path, so metric cardinality\n * stays bounded. No-op outside instrumented requests.\n *\n * @example\n * ```ts\n * import { overrideRequestRoute } from '@astroscope/node/log';\n *\n * export const onRequest: MiddlewareHandler = (ctx, next) => {\n * const page = lookupPage(ctx.url.pathname);\n *\n * if (!page) return next();\n *\n * overrideRequestRoute('/cms/pages/[id]');\n *\n * return next(`/cms/pages/${page.id}`);\n * };\n * ```\n */\nexport function overrideRequestRoute(route: string): void {\n applyRoute(route, getRequestRecord()?.method ?? 'GET', true);\n}\n\n/**\n * Stamp the route astro's routing matched, unless it was overridden by the\n * middleware that served the request.\n * @internal\n */\nexport function setRequestRoute(route: string, method: string): void {\n applyRoute(route, method, false);\n}\n"],"mappings":";;;;;;;;AASA,MAAM,YAAY,OAAO,IAAI,sBAAsB;AA2BnD,SAAgB,cAAwB;CACtC,MAAM,IAAI;CACV,IAAI,QAAQ,EAAE;CAEd,IAAI,CAAC,OAAO;EACV,QAAQ;GAAE,MAAM,KAAA;GAAW,gBAAgB,IAAI,kBAAkB;GAAG,QAAQ,CAAC;GAAG,SAAS;EAAE;EAC3F,EAAE,aAAa;CACjB;CAEA,OAAO;AACT;AAEA,SAAgB,mBAA8C;CAC5D,OAAO,YAAY,CAAC,CAAC,eAAe,SAAS;AAC/C;;;;;;;;;;;ACvCA,SAAS,WAAW,OAAe,QAAgB,UAAyB;CAC1E,MAAM,SAAS,iBAAiB;CAEhC,IAAI,QAAQ;EACV,IAAI,CAAC,YAAY,OAAO,eAAe;EAEvC,OAAO,QAAQ;EAEf,IAAI,UACF,OAAO,gBAAgB;CAE3B;CAEA,MAAM,OAAO,MAAM,cAAc;CAEjC,IAAI,MAAM,YAAY,GAAG;EACvB,KAAK,aAAa,cAAc,KAAK;EAErC,IAAI,CAAC,QAAQ,YACX,KAAK,WAAW,GAAG,OAAO,GAAG,OAAO;CAExC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,qBAAqB,OAAqB;CACxD,WAAW,OAAO,iBAAiB,CAAC,EAAE,UAAU,OAAO,IAAI;AAC7D;;;;;;AAOA,SAAgB,gBAAgB,OAAe,QAAsB;CACnE,WAAW,OAAO,QAAQ,KAAK;AACjC"}
1
+ {"version":3,"file":"request-route-DcnZOOM4.js","names":[],"sources":["../src/observability/log/store.ts","../src/observability/request-route.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport type { Logger } from 'pino';\n\n/**\n * Shared state between the public `log` proxy and the server runtime. Keyed on\n * `globalThis` because the two sides may live in different module instances\n * (bundled app vs vite module runner in dev).\n */\n\nconst STORE_KEY = Symbol.for('@astroscope/node/log');\n\nexport const EARLY_LOG_BUFFER_CAP = 100;\n\nexport interface BufferedEntry {\n level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';\n bindings: Record<string, unknown>[];\n args: unknown[];\n time: number;\n}\n\nexport interface RequestRecord {\n logger: Logger | undefined;\n url: string;\n method: string;\n route: string | undefined;\n routeOverride: boolean;\n actionName: string | undefined;\n truncated: boolean;\n}\n\nexport interface LogStore {\n root: Logger | undefined;\n requestStorage: AsyncLocalStorage<RequestRecord>;\n buffer: BufferedEntry[];\n dropped: number;\n}\n\nexport function getLogStore(): LogStore {\n const g = globalThis as Record<symbol, unknown>;\n let store = g[STORE_KEY] as LogStore | undefined;\n\n if (!store) {\n store = { root: undefined, requestStorage: new AsyncLocalStorage(), buffer: [], dropped: 0 };\n g[STORE_KEY] = store;\n }\n\n return store;\n}\n\nexport function getRequestRecord(): RequestRecord | undefined {\n return getLogStore().requestStorage.getStore();\n}\n","import { trace } from '@opentelemetry/api';\nimport { getRequestRecord } from './log/store.js';\n\n/**\n * Apply a route label to the request record (final log line, request duration\n * metric) and the active server span.\n *\n * Last write wins — astro's routing stamps every pass, so a rewrite lands the\n * route that rendered. An override comes from the middleware that knows what it\n * served and outranks routing, whatever the middleware order.\n */\nfunction applyRoute(route: string, method: string, override: boolean): void {\n const record = getRequestRecord();\n\n if (record) {\n if (!override && record.routeOverride) return;\n\n record.route = route;\n\n if (override) {\n record.routeOverride = true;\n }\n }\n\n const span = trace.getActiveSpan();\n\n if (span?.isRecording()) {\n span.setAttribute('http.route', route);\n\n if (!record?.actionName) {\n span.updateName(`${method} ${route}`);\n }\n }\n}\n\n/**\n * Report the route that actually served the current request, overriding the one\n * astro's routing matched.\n *\n * A middleware that rewrites (`next(url)`) or answers with its own response\n * serves a request astro has no page for, so routing matches `/404` and that is\n * what the request is logged and measured as — every such request collapsing\n * into one `/404` bucket in the request metrics. Calling this corrects the log\n * line, the metric and the server span name together.\n *\n * Pass a templated label rather than a concrete path, so metric cardinality\n * stays bounded. No-op outside instrumented requests.\n *\n * @example\n * ```ts\n * import { overrideRequestRoute } from '@astroscope/node/log';\n *\n * export const onRequest: MiddlewareHandler = (ctx, next) => {\n * const page = lookupPage(ctx.url.pathname);\n *\n * if (!page) return next();\n *\n * overrideRequestRoute('/cms/pages/[id]');\n *\n * return next(`/cms/pages/${page.id}`);\n * };\n * ```\n */\nexport function overrideRequestRoute(route: string): void {\n applyRoute(route, getRequestRecord()?.method ?? 'GET', true);\n}\n\n/**\n * Stamp the route astro's routing matched, unless it was overridden by the\n * middleware that served the request.\n * @internal\n */\nexport function setRequestRoute(route: string, method: string): void {\n applyRoute(route, method, false);\n}\n"],"mappings":";;;;;;;;AASA,MAAM,YAAY,OAAO,IAAI,sBAAsB;AA4BnD,SAAgB,cAAwB;CACtC,MAAM,IAAI;CACV,IAAI,QAAQ,EAAE;CAEd,IAAI,CAAC,OAAO;EACV,QAAQ;GAAE,MAAM,KAAA;GAAW,gBAAgB,IAAI,kBAAkB;GAAG,QAAQ,CAAC;GAAG,SAAS;EAAE;EAC3F,EAAE,aAAa;CACjB;CAEA,OAAO;AACT;AAEA,SAAgB,mBAA8C;CAC5D,OAAO,YAAY,CAAC,CAAC,eAAe,SAAS;AAC/C;;;;;;;;;;;ACxCA,SAAS,WAAW,OAAe,QAAgB,UAAyB;CAC1E,MAAM,SAAS,iBAAiB;CAEhC,IAAI,QAAQ;EACV,IAAI,CAAC,YAAY,OAAO,eAAe;EAEvC,OAAO,QAAQ;EAEf,IAAI,UACF,OAAO,gBAAgB;CAE3B;CAEA,MAAM,OAAO,MAAM,cAAc;CAEjC,IAAI,MAAM,YAAY,GAAG;EACvB,KAAK,aAAa,cAAc,KAAK;EAErC,IAAI,CAAC,QAAQ,YACX,KAAK,WAAW,GAAG,OAAO,GAAG,OAAO;CAExC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,qBAAqB,OAAqB;CACxD,WAAW,OAAO,iBAAiB,CAAC,EAAE,UAAU,OAAO,IAAI;AAC7D;;;;;;AAOA,SAAgB,gBAAgB,OAAe,QAAsB;CACnE,WAAW,OAAO,QAAQ,KAAK;AACjC"}
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ //#region src/islands/route-islands.ts
3
+ const ACTIONS_ROUTE_PATTERN = "/_actions/[...path]";
4
+ const ACTIONS_ENTRYPOINT_MODULE_ID = "\0virtual:astro:actions/entrypoint";
5
+ const WALK_BOUNDARY_MODULE_IDS = /* @__PURE__ */ new Set(["\0virtual:astro:manifest"]);
6
+ /**
7
+ * Route modules (absolute ids) → the route patterns they serve, for the walk:
8
+ * pages and endpoints (endpoints have no islands, but consumers still want them
9
+ * as known routes), one component possibly backing several routes.
10
+ */
11
+ function routeEntrypoints(root, routes) {
12
+ const entrypoints = /* @__PURE__ */ new Map();
13
+ for (const route of routes) {
14
+ if (route.type !== "page" && route.type !== "endpoint") continue;
15
+ const id = route.pattern === ACTIONS_ROUTE_PATTERN ? ACTIONS_ENTRYPOINT_MODULE_ID : path.resolve(root, route.entrypoint);
16
+ entrypoints.set(id, [...entrypoints.get(id) ?? [], route.pattern]);
17
+ }
18
+ return entrypoints;
19
+ }
20
+ /** module ids can carry vite queries (`?astro&type=...`) — the file path is the identity */
21
+ function stripQuery(id) {
22
+ const at = id.indexOf("?");
23
+ return at === -1 ? id : id.slice(0, at);
24
+ }
25
+ /**
26
+ * `pages` maps a page component's absolute path to the patterns of the routes it
27
+ * serves (one component can back several routes).
28
+ */
29
+ async function collectRouteIslands(graph, pages) {
30
+ const patternsByModule = /* @__PURE__ */ new Map();
31
+ const routes = /* @__PURE__ */ new Map();
32
+ const islandsByModule = /* @__PURE__ */ new Map();
33
+ for (const id of graph.getModuleIds()) {
34
+ const meta = graph.getModuleInfo(id)?.meta?.["astro"];
35
+ if (!meta) continue;
36
+ const islands = [];
37
+ for (const component of [...meta.hydratedComponents ?? [], ...meta.clientOnlyComponents ?? []]) if (component.resolvedPath) islands.push(stripQuery(decodeURI(component.resolvedPath)));
38
+ else {
39
+ const resolved = await graph.resolve(component.specifier, id);
40
+ if (resolved) islands.push(stripQuery(resolved.id));
41
+ }
42
+ if (islands.length > 0) islandsByModule.set(id, islands);
43
+ }
44
+ for (const [page, patterns] of pages) {
45
+ if (!graph.getModuleInfo(page)) continue;
46
+ const islands = /* @__PURE__ */ new Set();
47
+ const visited = /* @__PURE__ */ new Set();
48
+ const stack = [page];
49
+ while (stack.length > 0) {
50
+ const id = stack.pop();
51
+ if (visited.has(id) || WALK_BOUNDARY_MODULE_IDS.has(id)) continue;
52
+ visited.add(id);
53
+ let owners = patternsByModule.get(id);
54
+ if (!owners) {
55
+ owners = /* @__PURE__ */ new Set();
56
+ patternsByModule.set(id, owners);
57
+ }
58
+ patterns.forEach((pattern) => owners.add(pattern));
59
+ islandsByModule.get(id)?.forEach((island) => islands.add(island));
60
+ const moduleInfo = graph.getModuleInfo(id);
61
+ if (moduleInfo) stack.push(...moduleInfo.importedIds, ...moduleInfo.dynamicallyImportedIds);
62
+ }
63
+ for (const pattern of patterns) routes.set(pattern, islands);
64
+ }
65
+ return {
66
+ routes,
67
+ patternsByModule
68
+ };
69
+ }
70
+ //#endregion
71
+ export { routeEntrypoints as n, stripQuery as r, collectRouteIslands as t };
72
+
73
+ //# sourceMappingURL=route-islands-B53rE5MH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-islands-B53rE5MH.js","names":[],"sources":["../src/islands/route-islands.ts"],"sourcesContent":["import path from 'node:path';\nimport type { IntegrationResolvedRoute } from 'astro';\nimport type { Rolldown } from 'vite';\n\n/**\n * Build-time attribution of islands to routes, from the server build's module\n * graph: every module reachable from a page component belongs to that page's\n * routes, and the hydrated / `client:only` components astro's compiler recorded\n * on such a module are the route's islands. Same walk astro does for its own\n * client entries, kept independent of its internals — page modules are matched by\n * the route entrypoints astro resolved, not by its virtual page wrappers.\n */\n\ntype AstroComponentMeta = { exportName: string; specifier: string; resolvedPath?: string | undefined };\n\ntype AstroModuleMeta = {\n hydratedComponents?: AstroComponentMeta[] | undefined;\n clientOnlyComponents?: AstroComponentMeta[] | undefined;\n};\n\nexport type ModuleGraph = {\n getModuleIds(): IterableIterator<string>;\n getModuleInfo(id: string): Rolldown.ModuleInfo | null;\n resolve(source: string, importer: string): Promise<{ id: string } | null>;\n};\n\nexport type RouteIslandsResult = {\n /** route pattern → island component ids (absolute module ids, query stripped); pages missing from the graph are absent */\n routes: Map<string, Set<string>>;\n /** module id → route patterns whose page reaches it, the whole server module graph */\n patternsByModule: Map<string, Set<string>>;\n};\n\n// astro's actions route module reaches the project's actions through the ssr\n// manifest, not through an import — the virtual entrypoint wrapping them is the\n// module the walk has to start from\nconst ACTIONS_ROUTE_PATTERN = '/_actions/[...path]';\nconst ACTIONS_ENTRYPOINT_MODULE_ID = '\\0virtual:astro:actions/entrypoint';\n\n// astro's application manifest imports every page and the middleware\nconst WALK_BOUNDARY_MODULE_IDS = new Set(['\\0virtual:astro:manifest']);\n\n/**\n * Route modules (absolute ids) → the route patterns they serve, for the walk:\n * pages and endpoints (endpoints have no islands, but consumers still want them\n * as known routes), one component possibly backing several routes.\n */\nexport function routeEntrypoints(root: string, routes: readonly IntegrationResolvedRoute[]): Map<string, string[]> {\n const entrypoints = new Map<string, string[]>();\n\n for (const route of routes) {\n if (route.type !== 'page' && route.type !== 'endpoint') continue;\n\n const id =\n route.pattern === ACTIONS_ROUTE_PATTERN ? ACTIONS_ENTRYPOINT_MODULE_ID : path.resolve(root, route.entrypoint);\n\n entrypoints.set(id, [...(entrypoints.get(id) ?? []), route.pattern]);\n }\n\n return entrypoints;\n}\n\n/** module ids can carry vite queries (`?astro&type=...`) — the file path is the identity */\nexport function stripQuery(id: string): string {\n const at = id.indexOf('?');\n\n return at === -1 ? id : id.slice(0, at);\n}\n\n/**\n * `pages` maps a page component's absolute path to the patterns of the routes it\n * serves (one component can back several routes).\n */\nexport async function collectRouteIslands(\n graph: ModuleGraph,\n pages: Map<string, string[]>,\n): Promise<RouteIslandsResult> {\n const patternsByModule = new Map<string, Set<string>>();\n const routes = new Map<string, Set<string>>();\n const islandsByModule = new Map<string, string[]>();\n\n for (const id of graph.getModuleIds()) {\n const meta = graph.getModuleInfo(id)?.meta?.['astro'] as AstroModuleMeta | undefined;\n\n if (!meta) {\n continue;\n }\n\n const islands: string[] = [];\n\n for (const component of [...(meta.hydratedComponents ?? []), ...(meta.clientOnlyComponents ?? [])]) {\n if (component.resolvedPath) {\n islands.push(stripQuery(decodeURI(component.resolvedPath)));\n } else {\n // `client:only` components are dropped from the server graph — resolve the specifier ourselves\n const resolved = await graph.resolve(component.specifier, id);\n\n if (resolved) {\n islands.push(stripQuery(resolved.id));\n }\n }\n }\n\n if (islands.length > 0) {\n islandsByModule.set(id, islands);\n }\n }\n\n for (const [page, patterns] of pages) {\n const info = graph.getModuleInfo(page);\n\n if (!info) {\n continue;\n }\n\n const islands = new Set<string>();\n const visited = new Set<string>();\n const stack = [page];\n\n while (stack.length > 0) {\n const id = stack.pop()!;\n\n if (visited.has(id) || WALK_BOUNDARY_MODULE_IDS.has(id)) {\n continue;\n }\n\n visited.add(id);\n\n let owners = patternsByModule.get(id);\n\n if (!owners) {\n owners = new Set();\n patternsByModule.set(id, owners);\n }\n\n patterns.forEach((pattern) => owners.add(pattern));\n islandsByModule.get(id)?.forEach((island) => islands.add(island));\n\n const moduleInfo = graph.getModuleInfo(id);\n\n if (moduleInfo) {\n stack.push(...moduleInfo.importedIds, ...moduleInfo.dynamicallyImportedIds);\n }\n }\n\n for (const pattern of patterns) {\n routes.set(pattern, islands);\n }\n }\n\n return { routes, patternsByModule };\n}\n"],"mappings":";;AAoCA,MAAM,wBAAwB;AAC9B,MAAM,+BAA+B;AAGrC,MAAM,2CAA2B,IAAI,IAAI,CAAC,0BAA0B,CAAC;;;;;;AAOrE,SAAgB,iBAAiB,MAAc,QAAoE;CACjH,MAAM,8BAAc,IAAI,IAAsB;CAE9C,KAAK,MAAM,SAAS,QAAQ;EAC1B,IAAI,MAAM,SAAS,UAAU,MAAM,SAAS,YAAY;EAExD,MAAM,KACJ,MAAM,YAAY,wBAAwB,+BAA+B,KAAK,QAAQ,MAAM,MAAM,UAAU;EAE9G,YAAY,IAAI,IAAI,CAAC,GAAI,YAAY,IAAI,EAAE,KAAK,CAAC,GAAI,MAAM,OAAO,CAAC;CACrE;CAEA,OAAO;AACT;;AAGA,SAAgB,WAAW,IAAoB;CAC7C,MAAM,KAAK,GAAG,QAAQ,GAAG;CAEzB,OAAO,OAAO,KAAK,KAAK,GAAG,MAAM,GAAG,EAAE;AACxC;;;;;AAMA,eAAsB,oBACpB,OACA,OAC6B;CAC7B,MAAM,mCAAmB,IAAI,IAAyB;CACtD,MAAM,yBAAS,IAAI,IAAyB;CAC5C,MAAM,kCAAkB,IAAI,IAAsB;CAElD,KAAK,MAAM,MAAM,MAAM,aAAa,GAAG;EACrC,MAAM,OAAO,MAAM,cAAc,EAAE,CAAC,EAAE,OAAO;EAE7C,IAAI,CAAC,MACH;EAGF,MAAM,UAAoB,CAAC;EAE3B,KAAK,MAAM,aAAa,CAAC,GAAI,KAAK,sBAAsB,CAAC,GAAI,GAAI,KAAK,wBAAwB,CAAC,CAAE,GAC/F,IAAI,UAAU,cACZ,QAAQ,KAAK,WAAW,UAAU,UAAU,YAAY,CAAC,CAAC;OACrD;GAEL,MAAM,WAAW,MAAM,MAAM,QAAQ,UAAU,WAAW,EAAE;GAE5D,IAAI,UACF,QAAQ,KAAK,WAAW,SAAS,EAAE,CAAC;EAExC;EAGF,IAAI,QAAQ,SAAS,GACnB,gBAAgB,IAAI,IAAI,OAAO;CAEnC;CAEA,KAAK,MAAM,CAAC,MAAM,aAAa,OAAO;EAGpC,IAAI,CAFS,MAAM,cAAc,IAEzB,GACN;EAGF,MAAM,0BAAU,IAAI,IAAY;EAChC,MAAM,0BAAU,IAAI,IAAY;EAChC,MAAM,QAAQ,CAAC,IAAI;EAEnB,OAAO,MAAM,SAAS,GAAG;GACvB,MAAM,KAAK,MAAM,IAAI;GAErB,IAAI,QAAQ,IAAI,EAAE,KAAK,yBAAyB,IAAI,EAAE,GACpD;GAGF,QAAQ,IAAI,EAAE;GAEd,IAAI,SAAS,iBAAiB,IAAI,EAAE;GAEpC,IAAI,CAAC,QAAQ;IACX,yBAAS,IAAI,IAAI;IACjB,iBAAiB,IAAI,IAAI,MAAM;GACjC;GAEA,SAAS,SAAS,YAAY,OAAO,IAAI,OAAO,CAAC;GACjD,gBAAgB,IAAI,EAAE,CAAC,EAAE,SAAS,WAAW,QAAQ,IAAI,MAAM,CAAC;GAEhE,MAAM,aAAa,MAAM,cAAc,EAAE;GAEzC,IAAI,YACF,MAAM,KAAK,GAAG,WAAW,aAAa,GAAG,WAAW,sBAAsB;EAE9E;EAEA,KAAK,MAAM,WAAW,UACpB,OAAO,IAAI,SAAS,OAAO;CAE/B;CAEA,OAAO;EAAE;EAAQ;CAAiB;AACpC"}
@@ -8,7 +8,6 @@ import { MiddlewareHandler } from "astro";
8
8
  * reports it itself via `overrideRequestRoute`, which wins over this. No-op
9
9
  * outside instrumented requests.
10
10
  */
11
- declare const onRequest: MiddlewareHandler;
11
+ export declare const onRequest: MiddlewareHandler;
12
12
  //#endregion
13
- export { onRequest };
14
13
  //# sourceMappingURL=route-middleware-entrypoint.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"route-middleware-entrypoint.d.ts","names":[],"sources":["../src/observability/route-middleware-entrypoint.ts"],"mappings":";;;;;;;;;;cAWa,WAAW"}
1
+ {"version":3,"file":"route-middleware-entrypoint.d.ts","names":[],"sources":["../src/observability/route-middleware-entrypoint.ts"],"mappings":";;;;;;;;;;qBAWa,WAAW"}
@@ -16,19 +16,15 @@ type IslandInfo = {
16
16
  fullClosure: string[];
17
17
  };
18
18
  /**
19
- * What an emitter contributes for one island. `links` are merged across emitters and
20
- * either emitted as `<link rel="modulepreload">` tags (immediate directives) or
21
- * registered on the preload global for the gate runtime (deferred directives).
22
- * `imports` are data-module urls (safe to evaluate ahead of time, e.g. translation
23
- * chunks) that the gate runtime eagerly `import()`s when a deferred island's
24
- * directive firesthey land in the module loader cache so the component's own
25
- * awaited import resolves instantly instead of fetching serially after the
26
- * component graph evaluated; urls listed in both sets are registered as imports
27
- * only. Immediate directives ignore `imports` their hydration import fires right
28
- * away and pulls the data modules itself. `html` is emitted right before the island
29
- * tag regardless of directive — an inline script there is parsed strictly before
30
- * the island connects, so it is the place for data the island's chunks read at
31
- * execution time.
19
+ * What an emitter contributes for one island. `links` are merged across emitters
20
+ * and either emitted as `<link rel="modulepreload">` tags (immediate directives)
21
+ * or registered for the gate runtime (deferred). `imports` are data-module urls
22
+ * (safe to evaluate early, e.g. translation chunks) the deferred gate eagerly
23
+ * `import()`s on directive fire, landing them in the module cache before the
24
+ * component's own awaited import urls in both sets register as imports only,
25
+ * and immediate directives ignore them. `html` is emitted right before the
26
+ * island tag regardless of directive parsed strictly before the island
27
+ * connects, the place for data its chunks read at execution time.
32
28
  */
33
29
  type IslandEmission = {
34
30
  links?: string[] | undefined;
@@ -74,4 +70,4 @@ declare function setRequestRouteData(request: Request, routeData: RouteData): vo
74
70
  declare function getRequestRouteData(request: Request): RouteData | undefined;
75
71
  //#endregion
76
72
  export { DocumentEmission as a, IslandEmitter as c, registerIslandEmitter as i, IslandInfo as l, setRequestRouteData as n, DocumentEmitter as o, registerDocumentEmitter as r, IslandEmission as s, getRequestRouteData as t };
77
- //# sourceMappingURL=route-store-CSYR4G5e.d.ts.map
73
+ //# sourceMappingURL=route-store-neUA2nBb.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-store-neUA2nBb.d.ts","names":[],"sources":["../src/islands/types.ts","../src/islands/emitters.ts","../src/server/route-store.ts"],"mappings":";;;;;;;KA4BY;EACV;EACA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;KAcU;EACV;EACA;EACA;;;;;;KAOU,iBAAiB,QAAQ,YAAY,UAAU,2BAA2B;;;;KAK1E;EACV;EACA;;;;;;;KAQU,mBAAmB,SAAS,eAAe;;;;;;;;iBC9CvC,sBAAsB,SAAS;;;;;;;iBAc/B,wBAAwB,SAAS;;;iBCpBjC,oBAAoB,SAAS,SAAS,WAAW;iBAIjD,oBAAoB,SAAS,UAAU"}
@@ -0,0 +1,39 @@
1
+ import { t as createChunkGraph } from "./graph-DdAxiaxa.js";
2
+ //#region src/islands/routes.ts
3
+ const STORE = Symbol.for("@astroscope/node.routeIslands");
4
+ function installRouteIslands(manifest) {
5
+ const routes = manifest?.routes;
6
+ if (!routes) {
7
+ globalThis[STORE] = () => null;
8
+ return;
9
+ }
10
+ let graph;
11
+ const cache = /* @__PURE__ */ new Map();
12
+ globalThis[STORE] = (pattern) => {
13
+ const cached = cache.get(pattern);
14
+ if (cached) return cached;
15
+ const fileNames = routes[pattern];
16
+ if (!fileNames) return null;
17
+ graph ??= createChunkGraph(manifest);
18
+ const islands = fileNames.map((fileName) => ({
19
+ fileName,
20
+ staticClosure: [fileName, ...graph.staticClosure(fileName)],
21
+ fullClosure: [fileName, ...graph.fullClosure(fileName)]
22
+ }));
23
+ cache.set(pattern, islands);
24
+ return islands;
25
+ };
26
+ }
27
+ /**
28
+ * The islands a route's page hydrates, from the build-time attribution — `null`
29
+ * when unknown: in dev, without a manifest, or for a route whose page the server
30
+ * build did not see (astro's own injected routes, server islands). Callers
31
+ * treat `null` as "anything", never as "nothing".
32
+ */
33
+ function getRouteIslands(pattern) {
34
+ return globalThis[STORE]?.(pattern) ?? null;
35
+ }
36
+ //#endregion
37
+ export { installRouteIslands as n, getRouteIslands as t };
38
+
39
+ //# sourceMappingURL=routes-D1o_WrJ5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes-D1o_WrJ5.js","names":[],"sources":["../src/islands/routes.ts"],"sourcesContent":["import { type ChunkGraph, createChunkGraph } from './graph.js';\nimport type { IslandsManifest } from './types.js';\n\n/**\n * Route → islands at runtime, for packages that must know before rendering what a\n * page will hydrate (per-route data loading). Installed by the islands middleware\n * entry from the manifest; keyed on `globalThis` via `Symbol.for` so the\n * vite-runner and native module instances share it. Closures are dist-relative\n * chunk file names — there is no island tag to observe a public prefix on yet.\n */\nexport type RouteIsland = {\n /** the island's entry chunk, relative to the client dist (e.g. `_astro/Cart.abc123.js`) */\n fileName: string;\n /** the entry plus its transitive static imports */\n staticClosure: string[];\n /** the entry plus its transitive static and dynamic imports */\n fullClosure: string[];\n};\n\ntype Resolver = (pattern: string) => RouteIsland[] | null;\n\nconst STORE = Symbol.for('@astroscope/node.routeIslands');\n\ntype Scope = { [STORE]?: Resolver };\n\nexport function installRouteIslands(manifest: IslandsManifest | null): void {\n const routes = manifest?.routes;\n\n if (!routes) {\n (globalThis as Scope)[STORE] = () => null;\n\n return;\n }\n\n let graph: ChunkGraph | undefined;\n const cache = new Map<string, RouteIsland[]>();\n\n (globalThis as Scope)[STORE] = (pattern) => {\n const cached = cache.get(pattern);\n\n if (cached) {\n return cached;\n }\n\n const fileNames = routes[pattern];\n\n if (!fileNames) {\n return null;\n }\n\n graph ??= createChunkGraph(manifest);\n\n const islands = fileNames.map((fileName) => ({\n fileName,\n staticClosure: [fileName, ...graph!.staticClosure(fileName)],\n fullClosure: [fileName, ...graph!.fullClosure(fileName)],\n }));\n\n cache.set(pattern, islands);\n\n return islands;\n };\n}\n\n/**\n * The islands a route's page hydrates, from the build-time attribution — `null`\n * when unknown: in dev, without a manifest, or for a route whose page the server\n * build did not see (astro's own injected routes, server islands). Callers\n * treat `null` as \"anything\", never as \"nothing\".\n */\nexport function getRouteIslands(pattern: string): RouteIsland[] | null {\n return (globalThis as Scope)[STORE]?.(pattern) ?? null;\n}\n"],"mappings":";;AAqBA,MAAM,QAAQ,OAAO,IAAI,+BAA+B;AAIxD,SAAgB,oBAAoB,UAAwC;CAC1E,MAAM,SAAS,UAAU;CAEzB,IAAI,CAAC,QAAQ;EACX,WAAsB,eAAe;EAErC;CACF;CAEA,IAAI;CACJ,MAAM,wBAAQ,IAAI,IAA2B;CAE7C,WAAsB,UAAU,YAAY;EAC1C,MAAM,SAAS,MAAM,IAAI,OAAO;EAEhC,IAAI,QACF,OAAO;EAGT,MAAM,YAAY,OAAO;EAEzB,IAAI,CAAC,WACH,OAAO;EAGT,UAAU,iBAAiB,QAAQ;EAEnC,MAAM,UAAU,UAAU,KAAK,cAAc;GAC3C;GACA,eAAe,CAAC,UAAU,GAAG,MAAO,cAAc,QAAQ,CAAC;GAC3D,aAAa,CAAC,UAAU,GAAG,MAAO,YAAY,QAAQ,CAAC;EACzD,EAAE;EAEF,MAAM,IAAI,SAAS,OAAO;EAE1B,OAAO;CACT;AACF;;;;;;;AAQA,SAAgB,gBAAgB,SAAuC;CACrE,OAAQ,WAAqB,MAAM,GAAG,OAAO,KAAK;AACpD"}
package/dist/server.d.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  //#region src/server/server.d.ts
2
- interface ServerHandle {
2
+ export interface ServerHandle {
3
3
  host: string;
4
4
  port: number;
5
5
  stop(): Promise<void>;
6
6
  closed(): Promise<void>;
7
7
  }
8
- declare function startServer(overrides?: {
8
+ export declare function startServer(overrides?: {
9
9
  host?: string | undefined;
10
10
  port?: number | undefined;
11
11
  }): Promise<ServerHandle>;
12
12
  //#endregion
13
- export { ServerHandle, startServer };
14
13
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","names":[],"sources":["../src/server/server.ts"],"mappings":";UAoFiB;EACf;EACA;EACA,QAAQ;EACR,UAAU;;iBAGU,YAAY;EAChC;EACA;IACE,QAAQ"}
1
+ {"version":3,"file":"server.d.ts","names":[],"sources":["../src/server/server.ts"],"mappings":";iBAsFiB;EACf;EACA;EACA,QAAQ;EACR,UAAU;;wBAGU,YAAY;EAChC;EACA;IACE,QAAQ"}
package/dist/server.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { n as setBootContext } from "./context-Bkg-FnnQ.js";
2
- import { a as runShutdown, i as createRequestInstrumentation, n as shutdownTelemetry, o as runStartup, r as dumpEarlyLogs, t as preparePlatform } from "./prepare-CXZsyAVk.js";
2
+ import { a as createRequestInstrumentation, i as dumpEarlyLogs, n as preparePlatform, o as runShutdown, r as shutdownTelemetry, s as runStartup, t as redirectDuplicateSlashes } from "./duplicate-slashes-C6hmOLl9.js";
3
3
  import { i as getRequestRecord } from "./request-route-DcnZOOM4.js";
4
4
  import { n as log } from "./log-B69HEBvg.js";
5
5
  import { n as dispatchNativeMount, t as clearNativeMounts } from "./native-mount-DjYEnO4X.js";
6
+ import { c as withSpan, s as startSpan } from "./telemetry-B9aziFyQ.js";
6
7
  import { n as setRequestRouteData } from "./route-store-DdxGePj2.js";
7
8
  import { n as deactivateHealthChecks, t as activateHealthChecks } from "./store-8pnTxM1x.js";
8
9
  import { n as MIME_TYPES, t as COMPRESSIBLE } from "./mime-C_GwZovh.js";
9
10
  import fs, { createReadStream } from "node:fs";
10
11
  import path from "node:path";
11
12
  import url from "node:url";
12
- import { SpanStatusCode, context, trace } from "@opentelemetry/api";
13
+ import { defined } from "@entwico/dash";
14
+ import { SpanStatusCode } from "@opentelemetry/api";
13
15
  import http from "node:http";
14
16
  import https from "node:https";
15
17
  import { checks, probes, server } from "@entwico/health-probes";
@@ -17,39 +19,8 @@ import { createApp } from "astro/app/entrypoint";
17
19
  import { setGetEnv } from "astro/env/setup";
18
20
  import { options } from "virtual:@astroscope/node/config";
19
21
  import { Readable } from "node:stream";
20
- import { createRequestFromNodeRequest, writeResponse } from "astro/app/node";
22
+ import { createRequestFromNodeRequest, getAbortControllerCleanup } from "astro/app/node";
21
23
  import send from "send";
22
- //#region src/observability/telemetry/lifecycle.ts
23
- const LIB_NAME = "@astroscope/node";
24
- /**
25
- * Lifecycle spans (`startup` / `shutdown` with phase children). No-op when no
26
- * SDK is registered — `trace.getTracer` returns the no-op tracer.
27
- */
28
- function startLifecycleSpan(name, parent) {
29
- const parentContext = parent ?? context.active();
30
- const span = trace.getTracer(LIB_NAME).startSpan(name, void 0, parentContext);
31
- return {
32
- span,
33
- context: trace.setSpan(parentContext, span)
34
- };
35
- }
36
- async function withLifecycleSpan(name, parent, fn) {
37
- const { span, context: spanContext } = startLifecycleSpan(name, parent);
38
- try {
39
- const result = await context.with(spanContext, fn);
40
- span.setStatus({ code: SpanStatusCode.OK });
41
- return result;
42
- } catch (err) {
43
- span.setStatus({
44
- code: SpanStatusCode.ERROR,
45
- message: err instanceof Error ? err.message : "unknown error"
46
- });
47
- throw err;
48
- } finally {
49
- span.end();
50
- }
51
- }
52
- //#endregion
53
24
  //#region src/server/client-dir.ts
54
25
  /**
55
26
  * Resolve the client directory at runtime relative to the built server entry.
@@ -93,6 +64,58 @@ async function readFSErrorPage(client, status) {
93
64
  }
94
65
  }
95
66
  }
67
+ function createOutgoingHttpHeaders(headers) {
68
+ const nodeHeaders = Object.fromEntries(headers.entries());
69
+ if (Object.keys(nodeHeaders).length === 0) return;
70
+ const cookies = headers.getSetCookie();
71
+ if (cookies.length > 1) nodeHeaders["set-cookie"] = cookies;
72
+ return nodeHeaders;
73
+ }
74
+ /**
75
+ * Streams the web response into the node response. A render failing after the
76
+ * first chunk cannot change the status anymore, so it is logged through the
77
+ * request logger and marked on the request record — the completion line, the
78
+ * span and `astro.render.failures` reflect it. On the wire it behaves like
79
+ * astro's own writer: an `Internal server error` marker, then the socket is
80
+ * destroyed.
81
+ */
82
+ async function writeResponse(response, res) {
83
+ res.statusMessage = response.statusText;
84
+ res.writeHead(response.status, createOutgoingHttpHeaders(response.headers));
85
+ const cleanupAbort = getAbortControllerCleanup(res.req);
86
+ if (cleanupAbort) {
87
+ const runCleanup = () => {
88
+ cleanupAbort();
89
+ res.off("finish", runCleanup);
90
+ res.off("close", runCleanup);
91
+ };
92
+ res.on("finish", runCleanup);
93
+ res.on("close", runCleanup);
94
+ }
95
+ if (!response.body) {
96
+ res.end();
97
+ return;
98
+ }
99
+ const reader = response.body.getReader();
100
+ res.on("close", () => {
101
+ reader.cancel().catch(() => void 0);
102
+ });
103
+ try {
104
+ for (let result = await reader.read(); !result.done; result = await reader.read()) res.write(result.value);
105
+ res.end();
106
+ } catch (err) {
107
+ const record = getRequestRecord();
108
+ if (record) record.truncated = true;
109
+ log.error({
110
+ ...err instanceof Error ? { err } : { reason: err },
111
+ ...record?.route && { route: record.route },
112
+ ...!record?.logger && { url: record?.url ?? res.req.url }
113
+ }, "render failed after the response started, response truncated");
114
+ res.write("Internal server error", () => {
115
+ res.destroy(err instanceof Error ? err : void 0);
116
+ });
117
+ }
118
+ }
96
119
  /**
97
120
  * Render on-demand routes: node req → web Request → `app.render()` → node res.
98
121
  * Prerendered pages never reach this handler (the static handler serves them);
@@ -138,15 +161,14 @@ function createAppHandler(app, options, client) {
138
161
  const routeData = app.match(request, true);
139
162
  const matched = routeData && !(routeData.type === "page" && routeData.prerender) ? routeData : void 0;
140
163
  if (matched) setRequestRouteData(request, matched);
141
- const response = matched ? await app.render(request, {
164
+ await writeResponse(matched ? await app.render(request, {
142
165
  addCookieHeader: true,
143
166
  routeData: matched,
144
167
  prerenderedErrorPageFetch
145
168
  }) : await app.render(request, {
146
169
  addCookieHeader: true,
147
170
  prerenderedErrorPageFetch
148
- });
149
- await writeResponse(response, res);
171
+ }), res);
150
172
  };
151
173
  }
152
174
  //#endregion
@@ -280,7 +302,7 @@ async function warmupModules() {
280
302
  app.manifest.actions,
281
303
  app.manifest.sessionDriver,
282
304
  app.manifest.serverIslandMappings
283
- ].filter((load) => load !== void 0);
305
+ ].filter(defined);
284
306
  const failures = (await Promise.allSettled(loaders.map((load) => load()))).filter((result) => result.status === "rejected");
285
307
  if (failures.length === 0) return;
286
308
  for (const failure of failures) log.error(failure.reason instanceof Error ? { err: failure.reason } : { reason: failure.reason }, "warmup import failed");
@@ -335,7 +357,7 @@ async function startServer(overrides) {
335
357
  activateHealthChecks(checks);
336
358
  log.debug("health probes listening");
337
359
  }
338
- const startup = startLifecycleSpan("startup");
360
+ const startup = startSpan("startup");
339
361
  log.info({
340
362
  host,
341
363
  port
@@ -344,7 +366,7 @@ async function startServer(overrides) {
344
366
  let bootMs = 0;
345
367
  let warmupMs = 0;
346
368
  const warmupStartedAt = performance.now();
347
- const warmupSpan = startLifecycleSpan("warmup", startup.context);
369
+ const warmupSpan = startSpan("warmup", { parent: startup.context });
348
370
  const warmup = warmupModules().then(() => {
349
371
  warmupMs = roundMs(performance.now() - warmupStartedAt);
350
372
  warmupSpan.span.end();
@@ -359,7 +381,7 @@ async function startServer(overrides) {
359
381
  });
360
382
  const shutdownLifecycle = async (shutdownContext) => {
361
383
  try {
362
- if (shutdownContext) await withLifecycleSpan("onShutdown", shutdownContext, () => runShutdown(bootModule, context));
384
+ if (shutdownContext) await withSpan("onShutdown", { parent: shutdownContext }, () => runShutdown(bootModule, context));
363
385
  else await runShutdown(bootModule, context);
364
386
  } catch (err) {
365
387
  log.error(err instanceof Error ? { err } : { reason: err }, "shutdown failed");
@@ -388,7 +410,7 @@ async function startServer(overrides) {
388
410
  try {
389
411
  const bootStartedAt = performance.now();
390
412
  bootModule = await import("virtual:@astroscope/node/boot");
391
- await withLifecycleSpan("boot", startup.context, () => runStartup(bootModule, context));
413
+ await withSpan("boot", { parent: startup.context }, () => runStartup(bootModule, context));
392
414
  bootMs = roundMs(performance.now() - bootStartedAt);
393
415
  } catch (err) {
394
416
  await failStartup(err, "startup failed");
@@ -418,13 +440,14 @@ async function startServer(overrides) {
418
440
  return;
419
441
  }
420
442
  instrument(req, res, () => {
443
+ if (redirectDuplicateSlashes(req, res)) return;
421
444
  if (dispatchNativeMount(req, res)) return;
422
445
  staticHandler(req, res, () => void appHandler(req, res));
423
446
  });
424
447
  };
425
448
  const server$1 = tls ? https.createServer(tls, listener) : http.createServer(listener);
426
449
  try {
427
- await withLifecycleSpan("listen", startup.context, () => {
450
+ await withSpan("listen", { parent: startup.context }, () => {
428
451
  return new Promise((resolve, reject) => {
429
452
  server$1.once("error", reject);
430
453
  server$1.listen(port, host, resolve);
@@ -454,8 +477,8 @@ async function startServer(overrides) {
454
477
  if (health) probes.ready.disable();
455
478
  log.info("shutdown initiated");
456
479
  const drainStartedAt = performance.now();
457
- const shutdown = startLifecycleSpan("shutdown");
458
- await withLifecycleSpan("drain", shutdown.context, async () => {
480
+ const shutdown = startSpan("shutdown");
481
+ await withSpan("drain", { parent: shutdown.context }, async () => {
459
482
  const closed = new Promise((resolve) => server$1.close(() => resolve()));
460
483
  server$1.closeIdleConnections();
461
484
  const forceTimer = setTimeout(() => server$1.closeAllConnections(), runtimeOptions.shutdownTimeout);