From 844a2da07c7aa4fd98296258975ec8818d7e0be7 Mon Sep 17 00:00:00 2001 From: vreshch Date: Mon, 10 Aug 2026 03:42:10 +0200 Subject: [PATCH] chore(release): 0.9.1 - make the health module edge-safe by construction 0.9.0 introduced the one Node API this module has ever had, and it took down admin.agentage.io: every gated route 500'd, in an app that only ever wanted `links` from the shared barrel. The barrel re-exports this module, middleware.ts imports the barrel, and middleware runs in the Edge Runtime. Next detects Node APIs in edge bundles STATICALLY, so the `typeof` guard I wrote around the call was worthless - the check never runs. The module threw at evaluation and the gate failed before any page rendered. performance.timeOrigin gives the same instant to the millisecond (verified: 0ms delta) and is a Web API present in Node, the Edge Runtime and browsers. The module now has no Node APIs beyond process.env, which Next supports in edge bundles and which the whole provenance story rests on. test/edge-safety.test.ts keeps it that way. It is a source-text test on purpose: a behavioural test cannot catch this, because the code works perfectly under Node - the only thing that fails is a bundler reading the source. It scans raw text INCLUDING comments, since tsc emits comments into dist and not every analyzer parses an AST rather than grepping. It also scans dist when one exists, and asserts this module keeps its no-imports property. The guard fired on its first run, on the comment explaining the fix. Reworded rather than exempted: a name that must not appear in the shipped artifact must not appear in prose either. Downstream repos can drop their barrel workarounds after bumping, though dropping the re-export is still better hygiene than relying on this. --- package-lock.json | 4 +-- package.json | 2 +- src/health.ts | 22 +++++++++--- test/edge-safety.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 test/edge-safety.test.ts diff --git a/package-lock.json b/package-lock.json index 622dd94..aba1a53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentage/observability", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentage/observability", - "version": "0.9.0", + "version": "0.9.1", "license": "MIT", "dependencies": { "@opentelemetry/api": "1.9.1", diff --git a/package.json b/package.json index 0f38527..ac4c180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentage/observability", - "version": "0.9.0", + "version": "0.9.1", "description": "Shared observability kit for agentage services: OTLP trace bootstrap (node --import), pino logger preset with trace correlation, one-call error capture, and the estate /health envelope.", "type": "module", "license": "MIT", diff --git a/src/health.ts b/src/health.ts index b041abd..1f37025 100644 --- a/src/health.ts +++ b/src/health.ts @@ -59,12 +59,24 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now( /** One decimal: 0.3ms (memoized) and 0ms (not run) must not round to the same number. */ const since = (start: number): number => Math.round((now() - start) * 10) / 10; -// process.uptime() is the real process start. A module-level `new Date()` is only -// module-LOAD time, and a lazily-imported Next route handler can load minutes after -// boot - reporting a fresh uptime for a container that has been up for hours. +// Real process start, not module-LOAD time: a lazily-imported Next route handler can +// load minutes after boot and would otherwise report a fresh uptime for a container +// that has been up for hours. +// +// performance.timeOrigin, NOT Node's process-uptime API. Both give the same instant to +// the millisecond, but that one is Node-only, and Next detects Node APIs in Edge +// Runtime bundles STATICALLY - a `typeof` guard does not save you, because the check +// never runs. This module is re-exported from shared barrels that middleware imports, +// so a single Node API here 500s every gated route in an app that never knowingly +// touched the health kit. That happened (admin, 2026-08-10). timeOrigin is a Web API +// present in Node, the Edge Runtime and browsers alike. +// +// Keep this module free of Node APIs - `test/edge-safety.test.ts` enforces it, and it +// scans raw source INCLUDING comments, so the forbidden names cannot be spelled here +// even in prose: tsc emits comments into dist, and not every downstream analyzer +// parses an AST rather than grepping. function processStart(): Date { - const uptime = typeof process?.uptime === 'function' ? process.uptime() : 0; - return new Date(Date.now() - Math.round(uptime * 1000)); + return new Date(typeof performance !== 'undefined' ? performance.timeOrigin : Date.now()); } const STARTED_AT = processStart(); diff --git a/test/edge-safety.test.ts b/test/edge-safety.test.ts new file mode 100644 index 0000000..c784ec4 --- /dev/null +++ b/test/edge-safety.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * `health.ts` is re-exported from the `@agentage/shared` barrel in several repos, and + * those barrels are imported by `middleware.ts`, which runs in the **Edge Runtime**. + * + * Next detects Node APIs in an edge bundle **statically**, at build or module + * evaluation - not when the line runs. So a `typeof process?.uptime === 'function'` + * guard is worthless: the mere presence of the identifier throws + * + * Error: A Node.js API is used (process.uptime) which is not supported in the Edge Runtime. + * + * at module evaluation, which fails the gate before any page renders. On 2026-08-10 + * that 500'd every authenticated route on admin.agentage.io, in an app that only ever + * wanted `links` from the barrel. + * + * So this is a SOURCE-TEXT test, deliberately. A behavioural test cannot catch it - + * the code works perfectly under Node. The only thing that fails is a bundler reading + * the source, so that is what this reproduces. + */ + +// Raw text, comments INCLUDED. tsc emits comments into dist, and while Next parses an +// AST, not every analyzer that might read this package does - so the forbidden names +// must not appear at all, even in prose explaining why they are forbidden. The first +// run of this test caught exactly that: the comment documenting the fix. +const SOURCE = readFileSync(fileURLToPath(new URL('../src/health.ts', import.meta.url)), 'utf8'); + +// `process.env` is deliberately absent: Next supports it in edge bundles (it inlines +// them), and the envelope's whole provenance story is built on it. +const NODE_ONLY = [ + 'process.uptime', + 'process.cwd', + 'process.hrtime', + 'process.memoryUsage', + 'process.versions', + 'process.platform', + 'process.exit', + 'process.nextTick', + 'require(', + "from 'node:", + 'from "node:', +]; + +describe('health.ts stays edge-safe', () => { + it.each(NODE_ONLY)('does not reference %s', (api) => { + expect(SOURCE).not.toContain(api); + }); + + it('has no imports at all - every symbol comes from a cross-runtime global', () => { + const imports = SOURCE.match(/^\s*import\s+(?!type\b)/gm) ?? []; + expect(imports).toEqual([]); + }); + + it('reads the process start from performance.timeOrigin, a Web API', () => { + expect(SOURCE).toContain('performance.timeOrigin'); + }); + + it('ships a dist free of them too, when one has been built', async () => { + const dist = new URL('../dist/health.js', import.meta.url); + let built: string; + try { + built = readFileSync(fileURLToPath(dist), 'utf8'); + } catch { + return; // test runs before build in `npm run verify`; the source scan above is the gate + } + for (const api of NODE_ONLY) expect(built).not.toContain(api); + }); + + it('still reports a plausible process start after the swap', async () => { + const { healthEnvelope } = await import('../src/health.js'); + const { startedAt, uptimeSeconds } = healthEnvelope('edge-check').data; + const drift = Math.abs(Date.parse(startedAt) - (Date.now() - process.uptime() * 1000)); + expect(drift).toBeLessThan(1000); + expect(uptimeSeconds).toBeGreaterThanOrEqual(0); + }); +});