@reliantlabs/forge-web-runtime 0.5.0 → 0.6.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/devlog.js CHANGED
@@ -58,6 +58,7 @@
58
58
  // It is also reversible: uninstallDevLogging() restores the original console
59
59
  // methods, and the scaffolded call site is an ordinary line in main.tsx /
60
60
  // providers.tsx that a project can delete outright.
61
+ import { serverTraceIdOf } from "./trace-id.js";
61
62
  /** Console methods this module mirrors. */
62
63
  const LEVELS = ["log", "info", "warn", "error", "debug"];
63
64
  export const DEV_LOG_ENDPOINT = "/__forge/log";
@@ -145,9 +146,17 @@ function capture(level, args) {
145
146
  droppedReason = "buffer full";
146
147
  return;
147
148
  }
149
+ // A failed RPC's error names the trace the server recorded it in: put it on
150
+ // the line, so one grep of the trace id finds the browser's report next to
151
+ // the server's logs in .forge/logs/<env>/.
152
+ let msg = args.map(render).join(" ");
153
+ const traceId = args.map(serverTraceIdOf).find((id) => id !== null);
154
+ if (traceId && !msg.includes(traceId)) {
155
+ msg += ` trace_id=${traceId}`;
156
+ }
148
157
  const entry = {
149
158
  level,
150
- msg: truncate(args.map(render).join(" ")),
159
+ msg: truncate(msg),
151
160
  };
152
161
  const wasEmpty = buffer.length === 0;
153
162
  buffer.push(entry);
@@ -1 +1 @@
1
- {"version":3,"file":"devlog.js","sourceRoot":"","sources":["../src/devlog.ts"],"names":[],"mappings":"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","sourcesContent":["// Dev-only browser log forwarding.\n//\n// A browser's console is a dead end: an agent debugging a scaffolded frontend\n// cannot open devtools, so every console line and — worse — every uncaught\n// error is invisible to it. The backend has no such gap (structured slog, the\n// observe ComponentChain, and forge's dev loop already tees each process's\n// stdout to .forge/logs/<env>/). This closes the frontend half by POSTing\n// browser console output to a dev-server endpoint that prints it, so it lands\n// in .forge/logs/<env>/frontend_<name>.log next to the server logs.\n//\n// ── Why this batches (protocol v2) ───────────────────────────────────────\n//\n// The first version sent one fetch POST per console call. Measured in a real\n// app that logs freely, that is ~80 requests/s at light load and 200+/s in a\n// burst, and those requests are not free bystanders: they share the browser's\n// six-connections-per-origin pool and the dev server's single event loop with\n// the app's own RPCs. In the same session, RPC latency averaged 37ms in quiet\n// seconds and 127ms in seconds carrying 200+ log lines. Worse, `keepalive`\n// requests draw on a 64 KiB in-flight browser quota shared across the page, so\n// a burst does not just slow things down — some of it gets rejected, and the\n// original `.catch(() => {})` made that indistinguishable from silence.\n//\n// So entries are buffered and flushed as a batch:\n//\n// - every FLUSH_INTERVAL_MS, timer armed when an entry lands in an empty\n// buffer (the common case: a few lines coalesce into one request),\n// - immediately once buffered output reaches MAX_BATCH_BYTES,\n// - on a microtask for anything error-level, so a crash never waits 250ms\n// while a same-tick burst still coalesces,\n// - synchronously via sendBeacon on pagehide / visibilitychange→hidden,\n// which is the only way output survives a navigation or a crash-reload.\n//\n// At most ONE request is in flight. Entries logged during it keep buffering and\n// go out when it settles, which is what makes the printed order the log order —\n// six parallel POSTs would interleave on the server.\n//\n// Nothing is ever dropped silently. A full buffer, a rejected request and an\n// over-quota request all increment a counter, and the next batch to leave\n// carries a synthetic warn line saying how many lines were lost and why. The\n// failure this replaces was a dead sink that looked exactly like working code.\n//\n// ── Legacy endpoints ─────────────────────────────────────────────────────\n//\n// The receiving endpoint is a scaffold-once file the PROJECT owns (a Vite\n// plugin / a Next.js route handler), so upgrading this package through npm will\n// routinely meet an endpoint that predates batching. Such an endpoint answers\n// 204 and prints one empty line for the whole batch — total, silent loss. A v2\n// endpoint therefore returns `X-Forge-Devlog: 2`, and a 2xx without it makes\n// this client fall back permanently to one v1 POST per line (plus one warn\n// line telling the developer to update the endpoint).\n//\n// DEV ONLY, and structurally so. The receiving endpoint is a Vite plugin\n// gated `apply: \"serve\"` / a Next.js route behind a NODE_ENV check, so it does\n// not exist in a production build; installDevLogging() additionally no-ops\n// unless it is handed dev: true. A production bundle therefore has no\n// endpoint to post to AND no code that would post.\n//\n// It is also reversible: uninstallDevLogging() restores the original console\n// methods, and the scaffolded call site is an ordinary line in main.tsx /\n// providers.tsx that a project can delete outright.\n\n/** Console methods this module mirrors. */\nconst LEVELS = [\"log\", \"info\", \"warn\", \"error\", \"debug\"] as const;\n\ntype Level = (typeof LEVELS)[number];\n\n/**\n * One log line on the wire.\n *\n * Also the whole v1 body: a legacy endpoint receives exactly this object, which\n * is why the shape is unchanged and still exported.\n */\nexport interface DevLogPayload {\n level: Level | \"error\";\n msg: string;\n}\n\n/** The v2 body: many lines, one request. */\nexport interface DevLogBatch {\n entries: DevLogPayload[];\n}\n\nexport interface DevLoggingOptions {\n /**\n * Whether to install at all. Pass `import.meta.env.DEV` (Vite) or\n * `process.env.NODE_ENV !== \"production\"` (Next.js). When false this is a\n * no-op, so the call site needs no conditional of its own.\n */\n dev: boolean;\n /** Endpoint the dev server serves. Defaults to the forge convention. */\n endpoint?: string;\n /**\n * Also keep writing to the real console. Default true — the browser devtools\n * experience should be unchanged; this feature ADDS a sink, it does not move\n * one.\n */\n mirrorToConsole?: boolean;\n}\n\nexport const DEV_LOG_ENDPOINT = \"/__forge/log\";\n\n/** Response header a batching endpoint sets. Its ABSENCE selects v1 fallback. */\nconst PROTOCOL_HEADER = \"X-Forge-Devlog\";\n\n/** Cap a single line so a runaway loop cannot fill the disk. Matches the endpoints. */\nconst MAX_LINE = 8_000;\n\n/** How long a line may wait for company before it is sent. */\nconst FLUSH_INTERVAL_MS = 250;\n\n/**\n * Per-request ceiling. Deliberately half of the browser's 64 KiB shared\n * `keepalive` in-flight quota: with one request in flight, a batch cannot be\n * the thing that pushes the page over it.\n */\nconst MAX_BATCH_BYTES = 32 * 1024;\n\n/** Buffer ceiling. Past it entries are dropped and counted, never queued forever. */\nconst MAX_BUFFER_ENTRIES = 5_000;\n\nconst original: Partial<Record<Level, (...args: unknown[]) => void>> = {};\nlet installed = false;\n\n// Guards against infinite recursion. The forwarder calls fetch(), and anything\n// that logs inside fetch (an interceptor, a polyfill, a devtools extension)\n// would otherwise re-enter the override forever. Such a line is still mirrored\n// to the real console — it is only forwarding that is suppressed.\nlet forwarding = false;\n\nlet endpointUrl = DEV_LOG_ENDPOINT;\nlet buffer: DevLogPayload[] = [];\nlet bufferedBytes = 0;\nlet flushTimer: ReturnType<typeof setTimeout> | undefined;\nlet requestInFlight = false;\nlet errorFlushQueued = false;\nlet droppedCount = 0;\nlet droppedReason = \"\";\n/** Set for good once an endpoint is known not to understand batches. */\nlet legacyEndpoint = false;\n\n/**\n * Render one console argument as a string.\n *\n * Circular structures and BigInt are the two cases a plain JSON.stringify\n * throws on, and the old fallback to String(value) turned both into\n * \"[object Object]\" — deleting exactly the shape you were trying to read. The\n * replacer keeps the object and names the cycle instead.\n */\nfunction render(value: unknown): string {\n if (typeof value === \"string\") return value;\n if (value instanceof Error) {\n return `${value.name}: ${value.message}${\n value.stack ? `\\n${value.stack}` : \"\"\n }`;\n }\n try {\n const seen = new WeakSet<object>();\n return (\n JSON.stringify(value, (_key, val: unknown) => {\n if (typeof val === \"bigint\") return `${val}n`;\n if (typeof val === \"object\" && val !== null) {\n if (seen.has(val)) return \"[Circular]\";\n seen.add(val);\n }\n return val;\n }) ?? String(value)\n );\n } catch {\n // DOM nodes, getters that throw — never let logging throw.\n return String(value);\n }\n}\n\nfunction truncate(msg: string): string {\n return msg.length > MAX_LINE ? `${msg.slice(0, MAX_LINE)}… (truncated)` : msg;\n}\n\nfunction entryBytes(entry: DevLogPayload): number {\n return JSON.stringify(entry).length;\n}\n\n/**\n * Buffer one line and decide whether it can wait.\n *\n * Arguments are rendered HERE rather than at flush time: a console argument is\n * a live object, and by the time a batch leaves 250ms later the array it logged\n * may have been mutated or emptied. The snapshot is the value at the call.\n */\nfunction capture(level: DevLogPayload[\"level\"], args: unknown[]): void {\n if (forwarding) return;\n\n if (buffer.length >= MAX_BUFFER_ENTRIES) {\n droppedCount += 1;\n droppedReason = \"buffer full\";\n return;\n }\n\n const entry: DevLogPayload = {\n level,\n msg: truncate(args.map(render).join(\" \")),\n };\n const wasEmpty = buffer.length === 0;\n buffer.push(entry);\n bufferedBytes += entryBytes(entry);\n\n if (bufferedBytes >= MAX_BATCH_BYTES) {\n flush();\n return;\n }\n\n if (level === \"error\") {\n // An error is what someone is waiting for; it must not sit in a timer. The\n // microtask (rather than a direct flush) lets a same-tick burst — a failing\n // render logging three times — still leave as one request.\n if (!errorFlushQueued) {\n errorFlushQueued = true;\n queueMicrotask(() => {\n errorFlushQueued = false;\n flush();\n });\n }\n return;\n }\n\n if (wasEmpty) armTimer();\n}\n\nfunction armTimer(): void {\n if (flushTimer !== undefined) return;\n flushTimer = setTimeout(() => {\n flushTimer = undefined;\n flush();\n }, FLUSH_INTERVAL_MS);\n}\n\nfunction clearFlushTimer(): void {\n if (flushTimer === undefined) return;\n clearTimeout(flushTimer);\n flushTimer = undefined;\n}\n\n/** Pull entries off the front of the buffer without exceeding MAX_BATCH_BYTES. */\nfunction takeBatch(limit = MAX_BATCH_BYTES): DevLogPayload[] {\n const batch: DevLogPayload[] = [];\n let size = '{\"entries\":[]}'.length;\n while (buffer.length > 0) {\n const entry = buffer[0] as DevLogPayload;\n const cost = entryBytes(entry) + (batch.length > 0 ? 1 : 0);\n // Always take at least one entry: a single line that somehow exceeds the\n // limit must still leave, or it stalls every line behind it forever.\n if (batch.length > 0 && size + cost > limit) break;\n buffer.shift();\n bufferedBytes -= entryBytes(entry);\n batch.push(entry);\n size += cost;\n }\n if (buffer.length === 0) bufferedBytes = 0;\n return batch;\n}\n\n/**\n * Prepend the \"we lost lines\" notice, if any, so it travels with the next batch\n * rather than needing a request of its own.\n */\nfunction drainDroppedNotice(): void {\n if (droppedCount === 0) return;\n const notice: DevLogPayload = {\n level: \"warn\",\n msg: `[forge-devlog] dropped ${droppedCount} lines (${droppedReason})`,\n };\n buffer.unshift(notice);\n bufferedBytes += entryBytes(notice);\n droppedCount = 0;\n droppedReason = \"\";\n}\n\nfunction post(body: string): Promise<Response> {\n // The recursion guard must wrap the CALL, not the promise: a fetch polyfill\n // that logs does so synchronously from inside fetch().\n forwarding = true;\n try {\n return fetch(endpointUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body,\n // A log emitted during unload (the interesting case for a crash on\n // navigation) is cancelled without this.\n keepalive: true,\n // Never let log forwarding trip a credentials or CORS policy.\n credentials: \"omit\",\n });\n } finally {\n forwarding = false;\n }\n}\n\n/**\n * Send one request's worth of buffered output, if nothing is in flight.\n *\n * The single-flight rule is what guarantees the dev server prints lines in the\n * order they were logged, and it doubles as backpressure: a slow endpoint makes\n * batches bigger rather than making requests pile up.\n */\nfunction flush(): void {\n if (requestInFlight) return;\n drainDroppedNotice();\n if (buffer.length === 0) return;\n clearFlushTimer();\n\n const batch = legacyEndpoint ? takeBatch(0) : takeBatch();\n if (batch.length === 0) return;\n\n const body = legacyEndpoint\n ? JSON.stringify(batch[0])\n : JSON.stringify({ entries: batch } satisfies DevLogBatch);\n\n requestInFlight = true;\n post(body).then(\n (response) => {\n requestInFlight = false;\n if (!response.ok) {\n countDropped(batch.length, `endpoint returned ${response.status}`);\n } else if (!legacyEndpoint && !response.headers.get(PROTOCOL_HEADER)) {\n adoptLegacyMode(batch);\n }\n if (buffer.length > 0 || droppedCount > 0) flush();\n },\n () => {\n requestInFlight = false;\n countDropped(batch.length, \"request failed\");\n if (buffer.length > 0) flush();\n },\n );\n}\n\nfunction countDropped(count: number, reason: string): void {\n droppedCount += count;\n droppedReason = reason;\n}\n\n/**\n * A 2xx with no protocol header came from an endpoint written before batching.\n * It parsed `{entries:[...]}` as a v1 body, found no `msg`, and printed one\n * empty line — so this batch is lost unless it is re-sent one line at a time,\n * and so is everything after it.\n */\nfunction adoptLegacyMode(lost: DevLogPayload[]): void {\n legacyEndpoint = true;\n const notice: DevLogPayload = {\n level: \"warn\",\n msg:\n \"[forge-devlog] this dev server's /__forge/log endpoint predates batched \" +\n \"posts; update it (forge skill: debug) — falling back to one request per line\",\n };\n buffer.unshift(notice, ...lost);\n bufferedBytes += [notice, ...lost].reduce((n, e) => n + entryBytes(e), 0);\n}\n\n/**\n * Last-chance flush on page teardown.\n *\n * fetch — even with keepalive — is not reliable here, and an async flush is\n * hopeless: the event loop may not run again. sendBeacon hands the bytes to the\n * browser to deliver after the document is gone, which is precisely the case\n * where the last few log lines explain what happened.\n */\nfunction flushOnUnload(): void {\n drainDroppedNotice();\n if (buffer.length === 0) return;\n\n const canBeacon =\n typeof navigator !== \"undefined\" &&\n typeof navigator.sendBeacon === \"function\";\n if (!canBeacon) {\n // No beacon: keepalive fetch is the only remaining option, and one request\n // has a better chance of leaving than a queue of them.\n flush();\n return;\n }\n\n forwarding = true;\n try {\n while (buffer.length > 0) {\n const batch = legacyEndpoint ? takeBatch(0) : takeBatch();\n const body = legacyEndpoint\n ? JSON.stringify(batch[0])\n : JSON.stringify({ entries: batch } satisfies DevLogBatch);\n // text/plain is not a decoration: sendBeacon with application/json is a\n // non-simple request and would need a CORS preflight it cannot make. The\n // endpoints parse the body regardless of Content-Type for this reason.\n const blob = new Blob([body], { type: \"text/plain;charset=UTF-8\" });\n if (!navigator.sendBeacon(endpointUrl, blob)) {\n // The beacon queue is full (64 KiB per origin). Nothing later will fit\n // either, so stop and account for what is left.\n countDropped(batch.length + buffer.length, \"beacon queue full\");\n buffer = [];\n bufferedBytes = 0;\n return;\n }\n }\n } finally {\n forwarding = false;\n }\n}\n\nfunction onWindowError(event: ErrorEvent): void {\n capture(\"error\", [\n event.error ??\n `${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,\n ]);\n}\n\nfunction onRejection(event: PromiseRejectionEvent): void {\n capture(\"error\", [\"unhandled rejection:\", event.reason]);\n}\n\nlet errorHandler: ((e: ErrorEvent) => void) | undefined;\nlet rejectionHandler: ((e: PromiseRejectionEvent) => void) | undefined;\nlet pageHideHandler: (() => void) | undefined;\nlet visibilityHandler: (() => void) | undefined;\n\n/**\n * Mirror console output and uncaught errors to the dev server, which prints\n * them into .forge/logs/<env>/frontend_<name>.log.\n *\n * The window listeners are the point of the feature as much as the console\n * override is: an uncaught TypeError or a rejected promise produces no console\n * call of its own, and those are exactly the failures worth seeing.\n *\n * Safe to call more than once; the second call is a no-op.\n */\nexport function installDevLogging(options: DevLoggingOptions): void {\n // `dev` is read off `options` rather than destructured with a default, and\n // checked FIRST. A bundler constant-folds `import.meta.env.DEV` to false at\n // the CALL SITE and can then drop the argument entirely, leaving a bare\n // `installDevLogging({})` — with a defaulted `dev` that call would install\n // the override in a production bundle. `options?.dev !== true` is therefore\n // fail-closed: absent, undefined, or anything non-true means do nothing.\n if (options?.dev !== true) return;\n if (installed) return;\n if (typeof window === \"undefined\") return; // SSR / prerender pass\n\n const { endpoint = DEV_LOG_ENDPOINT, mirrorToConsole = true } = options;\n installed = true;\n endpointUrl = endpoint;\n buffer = [];\n bufferedBytes = 0;\n droppedCount = 0;\n droppedReason = \"\";\n legacyEndpoint = false;\n requestInFlight = false;\n\n for (const level of LEVELS) {\n // Keep the ORIGINAL reference, not a bound copy: uninstall must restore\n // console.log to the identical function it replaced, so a second consumer\n // holding that reference (or a test asserting on it) sees no difference.\n const previous = console[level] as (...args: unknown[]) => void;\n original[level] = previous;\n console[level] = (...args: unknown[]) => {\n if (mirrorToConsole) previous.apply(console, args);\n capture(level, args);\n };\n }\n\n errorHandler = (e) => onWindowError(e);\n rejectionHandler = (e) => onRejection(e);\n pageHideHandler = () => flushOnUnload();\n window.addEventListener(\"error\", errorHandler);\n window.addEventListener(\"unhandledrejection\", rejectionHandler);\n window.addEventListener(\"pagehide\", pageHideHandler);\n\n if (typeof document !== \"undefined\") {\n // visibilitychange→hidden is the only teardown signal mobile Safari\n // reliably fires; pagehide alone loses the whole buffer there.\n visibilityHandler = () => {\n if (document.visibilityState === \"hidden\") flushOnUnload();\n };\n document.addEventListener(\"visibilitychange\", visibilityHandler);\n }\n}\n\n/** Restore the original console methods and remove the window listeners. */\nexport function uninstallDevLogging(): void {\n if (!installed) return;\n\n // Flush before restoring: whatever is buffered was logged while this was\n // installed, and a teardown is no reason to lose it.\n clearFlushTimer();\n flush();\n\n for (const level of LEVELS) {\n const previous = original[level];\n if (previous) console[level] = previous;\n delete original[level];\n }\n if (errorHandler) window.removeEventListener(\"error\", errorHandler);\n if (rejectionHandler)\n window.removeEventListener(\"unhandledrejection\", rejectionHandler);\n if (pageHideHandler) window.removeEventListener(\"pagehide\", pageHideHandler);\n if (visibilityHandler && typeof document !== \"undefined\")\n document.removeEventListener(\"visibilitychange\", visibilityHandler);\n errorHandler = undefined;\n rejectionHandler = undefined;\n pageHideHandler = undefined;\n visibilityHandler = undefined;\n installed = false;\n}\n\n/** Whether the console override is currently installed. Exposed for tests. */\nexport function devLoggingInstalled(): boolean {\n return installed;\n}\n"]}
1
+ {"version":3,"file":"devlog.js","sourceRoot":"","sources":["../src/devlog.ts"],"names":[],"mappings":"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","sourcesContent":["// Dev-only browser log forwarding.\n//\n// A browser's console is a dead end: an agent debugging a scaffolded frontend\n// cannot open devtools, so every console line and — worse — every uncaught\n// error is invisible to it. The backend has no such gap (structured slog, the\n// observe ComponentChain, and forge's dev loop already tees each process's\n// stdout to .forge/logs/<env>/). This closes the frontend half by POSTing\n// browser console output to a dev-server endpoint that prints it, so it lands\n// in .forge/logs/<env>/frontend_<name>.log next to the server logs.\n//\n// ── Why this batches (protocol v2) ───────────────────────────────────────\n//\n// The first version sent one fetch POST per console call. Measured in a real\n// app that logs freely, that is ~80 requests/s at light load and 200+/s in a\n// burst, and those requests are not free bystanders: they share the browser's\n// six-connections-per-origin pool and the dev server's single event loop with\n// the app's own RPCs. In the same session, RPC latency averaged 37ms in quiet\n// seconds and 127ms in seconds carrying 200+ log lines. Worse, `keepalive`\n// requests draw on a 64 KiB in-flight browser quota shared across the page, so\n// a burst does not just slow things down — some of it gets rejected, and the\n// original `.catch(() => {})` made that indistinguishable from silence.\n//\n// So entries are buffered and flushed as a batch:\n//\n// - every FLUSH_INTERVAL_MS, timer armed when an entry lands in an empty\n// buffer (the common case: a few lines coalesce into one request),\n// - immediately once buffered output reaches MAX_BATCH_BYTES,\n// - on a microtask for anything error-level, so a crash never waits 250ms\n// while a same-tick burst still coalesces,\n// - synchronously via sendBeacon on pagehide / visibilitychange→hidden,\n// which is the only way output survives a navigation or a crash-reload.\n//\n// At most ONE request is in flight. Entries logged during it keep buffering and\n// go out when it settles, which is what makes the printed order the log order —\n// six parallel POSTs would interleave on the server.\n//\n// Nothing is ever dropped silently. A full buffer, a rejected request and an\n// over-quota request all increment a counter, and the next batch to leave\n// carries a synthetic warn line saying how many lines were lost and why. The\n// failure this replaces was a dead sink that looked exactly like working code.\n//\n// ── Legacy endpoints ─────────────────────────────────────────────────────\n//\n// The receiving endpoint is a scaffold-once file the PROJECT owns (a Vite\n// plugin / a Next.js route handler), so upgrading this package through npm will\n// routinely meet an endpoint that predates batching. Such an endpoint answers\n// 204 and prints one empty line for the whole batch — total, silent loss. A v2\n// endpoint therefore returns `X-Forge-Devlog: 2`, and a 2xx without it makes\n// this client fall back permanently to one v1 POST per line (plus one warn\n// line telling the developer to update the endpoint).\n//\n// DEV ONLY, and structurally so. The receiving endpoint is a Vite plugin\n// gated `apply: \"serve\"` / a Next.js route behind a NODE_ENV check, so it does\n// not exist in a production build; installDevLogging() additionally no-ops\n// unless it is handed dev: true. A production bundle therefore has no\n// endpoint to post to AND no code that would post.\n//\n// It is also reversible: uninstallDevLogging() restores the original console\n// methods, and the scaffolded call site is an ordinary line in main.tsx /\n// providers.tsx that a project can delete outright.\n\nimport { serverTraceIdOf } from \"./trace-id.js\";\n\n/** Console methods this module mirrors. */\nconst LEVELS = [\"log\", \"info\", \"warn\", \"error\", \"debug\"] as const;\n\ntype Level = (typeof LEVELS)[number];\n\n/**\n * One log line on the wire.\n *\n * Also the whole v1 body: a legacy endpoint receives exactly this object, which\n * is why the shape is unchanged and still exported.\n */\nexport interface DevLogPayload {\n level: Level | \"error\";\n msg: string;\n}\n\n/** The v2 body: many lines, one request. */\nexport interface DevLogBatch {\n entries: DevLogPayload[];\n}\n\nexport interface DevLoggingOptions {\n /**\n * Whether to install at all. Pass `import.meta.env.DEV` (Vite) or\n * `process.env.NODE_ENV !== \"production\"` (Next.js). When false this is a\n * no-op, so the call site needs no conditional of its own.\n */\n dev: boolean;\n /** Endpoint the dev server serves. Defaults to the forge convention. */\n endpoint?: string;\n /**\n * Also keep writing to the real console. Default true — the browser devtools\n * experience should be unchanged; this feature ADDS a sink, it does not move\n * one.\n */\n mirrorToConsole?: boolean;\n}\n\nexport const DEV_LOG_ENDPOINT = \"/__forge/log\";\n\n/** Response header a batching endpoint sets. Its ABSENCE selects v1 fallback. */\nconst PROTOCOL_HEADER = \"X-Forge-Devlog\";\n\n/** Cap a single line so a runaway loop cannot fill the disk. Matches the endpoints. */\nconst MAX_LINE = 8_000;\n\n/** How long a line may wait for company before it is sent. */\nconst FLUSH_INTERVAL_MS = 250;\n\n/**\n * Per-request ceiling. Deliberately half of the browser's 64 KiB shared\n * `keepalive` in-flight quota: with one request in flight, a batch cannot be\n * the thing that pushes the page over it.\n */\nconst MAX_BATCH_BYTES = 32 * 1024;\n\n/** Buffer ceiling. Past it entries are dropped and counted, never queued forever. */\nconst MAX_BUFFER_ENTRIES = 5_000;\n\nconst original: Partial<Record<Level, (...args: unknown[]) => void>> = {};\nlet installed = false;\n\n// Guards against infinite recursion. The forwarder calls fetch(), and anything\n// that logs inside fetch (an interceptor, a polyfill, a devtools extension)\n// would otherwise re-enter the override forever. Such a line is still mirrored\n// to the real console — it is only forwarding that is suppressed.\nlet forwarding = false;\n\nlet endpointUrl = DEV_LOG_ENDPOINT;\nlet buffer: DevLogPayload[] = [];\nlet bufferedBytes = 0;\nlet flushTimer: ReturnType<typeof setTimeout> | undefined;\nlet requestInFlight = false;\nlet errorFlushQueued = false;\nlet droppedCount = 0;\nlet droppedReason = \"\";\n/** Set for good once an endpoint is known not to understand batches. */\nlet legacyEndpoint = false;\n\n/**\n * Render one console argument as a string.\n *\n * Circular structures and BigInt are the two cases a plain JSON.stringify\n * throws on, and the old fallback to String(value) turned both into\n * \"[object Object]\" — deleting exactly the shape you were trying to read. The\n * replacer keeps the object and names the cycle instead.\n */\nfunction render(value: unknown): string {\n if (typeof value === \"string\") return value;\n if (value instanceof Error) {\n return `${value.name}: ${value.message}${\n value.stack ? `\\n${value.stack}` : \"\"\n }`;\n }\n try {\n const seen = new WeakSet<object>();\n return (\n JSON.stringify(value, (_key, val: unknown) => {\n if (typeof val === \"bigint\") return `${val}n`;\n if (typeof val === \"object\" && val !== null) {\n if (seen.has(val)) return \"[Circular]\";\n seen.add(val);\n }\n return val;\n }) ?? String(value)\n );\n } catch {\n // DOM nodes, getters that throw — never let logging throw.\n return String(value);\n }\n}\n\nfunction truncate(msg: string): string {\n return msg.length > MAX_LINE ? `${msg.slice(0, MAX_LINE)}… (truncated)` : msg;\n}\n\nfunction entryBytes(entry: DevLogPayload): number {\n return JSON.stringify(entry).length;\n}\n\n/**\n * Buffer one line and decide whether it can wait.\n *\n * Arguments are rendered HERE rather than at flush time: a console argument is\n * a live object, and by the time a batch leaves 250ms later the array it logged\n * may have been mutated or emptied. The snapshot is the value at the call.\n */\nfunction capture(level: DevLogPayload[\"level\"], args: unknown[]): void {\n if (forwarding) return;\n\n if (buffer.length >= MAX_BUFFER_ENTRIES) {\n droppedCount += 1;\n droppedReason = \"buffer full\";\n return;\n }\n\n // A failed RPC's error names the trace the server recorded it in: put it on\n // the line, so one grep of the trace id finds the browser's report next to\n // the server's logs in .forge/logs/<env>/.\n let msg = args.map(render).join(\" \");\n const traceId = args.map(serverTraceIdOf).find((id) => id !== null);\n if (traceId && !msg.includes(traceId)) {\n msg += ` trace_id=${traceId}`;\n }\n const entry: DevLogPayload = {\n level,\n msg: truncate(msg),\n };\n const wasEmpty = buffer.length === 0;\n buffer.push(entry);\n bufferedBytes += entryBytes(entry);\n\n if (bufferedBytes >= MAX_BATCH_BYTES) {\n flush();\n return;\n }\n\n if (level === \"error\") {\n // An error is what someone is waiting for; it must not sit in a timer. The\n // microtask (rather than a direct flush) lets a same-tick burst — a failing\n // render logging three times — still leave as one request.\n if (!errorFlushQueued) {\n errorFlushQueued = true;\n queueMicrotask(() => {\n errorFlushQueued = false;\n flush();\n });\n }\n return;\n }\n\n if (wasEmpty) armTimer();\n}\n\nfunction armTimer(): void {\n if (flushTimer !== undefined) return;\n flushTimer = setTimeout(() => {\n flushTimer = undefined;\n flush();\n }, FLUSH_INTERVAL_MS);\n}\n\nfunction clearFlushTimer(): void {\n if (flushTimer === undefined) return;\n clearTimeout(flushTimer);\n flushTimer = undefined;\n}\n\n/** Pull entries off the front of the buffer without exceeding MAX_BATCH_BYTES. */\nfunction takeBatch(limit = MAX_BATCH_BYTES): DevLogPayload[] {\n const batch: DevLogPayload[] = [];\n let size = '{\"entries\":[]}'.length;\n while (buffer.length > 0) {\n const entry = buffer[0] as DevLogPayload;\n const cost = entryBytes(entry) + (batch.length > 0 ? 1 : 0);\n // Always take at least one entry: a single line that somehow exceeds the\n // limit must still leave, or it stalls every line behind it forever.\n if (batch.length > 0 && size + cost > limit) break;\n buffer.shift();\n bufferedBytes -= entryBytes(entry);\n batch.push(entry);\n size += cost;\n }\n if (buffer.length === 0) bufferedBytes = 0;\n return batch;\n}\n\n/**\n * Prepend the \"we lost lines\" notice, if any, so it travels with the next batch\n * rather than needing a request of its own.\n */\nfunction drainDroppedNotice(): void {\n if (droppedCount === 0) return;\n const notice: DevLogPayload = {\n level: \"warn\",\n msg: `[forge-devlog] dropped ${droppedCount} lines (${droppedReason})`,\n };\n buffer.unshift(notice);\n bufferedBytes += entryBytes(notice);\n droppedCount = 0;\n droppedReason = \"\";\n}\n\nfunction post(body: string): Promise<Response> {\n // The recursion guard must wrap the CALL, not the promise: a fetch polyfill\n // that logs does so synchronously from inside fetch().\n forwarding = true;\n try {\n return fetch(endpointUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body,\n // A log emitted during unload (the interesting case for a crash on\n // navigation) is cancelled without this.\n keepalive: true,\n // Never let log forwarding trip a credentials or CORS policy.\n credentials: \"omit\",\n });\n } finally {\n forwarding = false;\n }\n}\n\n/**\n * Send one request's worth of buffered output, if nothing is in flight.\n *\n * The single-flight rule is what guarantees the dev server prints lines in the\n * order they were logged, and it doubles as backpressure: a slow endpoint makes\n * batches bigger rather than making requests pile up.\n */\nfunction flush(): void {\n if (requestInFlight) return;\n drainDroppedNotice();\n if (buffer.length === 0) return;\n clearFlushTimer();\n\n const batch = legacyEndpoint ? takeBatch(0) : takeBatch();\n if (batch.length === 0) return;\n\n const body = legacyEndpoint\n ? JSON.stringify(batch[0])\n : JSON.stringify({ entries: batch } satisfies DevLogBatch);\n\n requestInFlight = true;\n post(body).then(\n (response) => {\n requestInFlight = false;\n if (!response.ok) {\n countDropped(batch.length, `endpoint returned ${response.status}`);\n } else if (!legacyEndpoint && !response.headers.get(PROTOCOL_HEADER)) {\n adoptLegacyMode(batch);\n }\n if (buffer.length > 0 || droppedCount > 0) flush();\n },\n () => {\n requestInFlight = false;\n countDropped(batch.length, \"request failed\");\n if (buffer.length > 0) flush();\n },\n );\n}\n\nfunction countDropped(count: number, reason: string): void {\n droppedCount += count;\n droppedReason = reason;\n}\n\n/**\n * A 2xx with no protocol header came from an endpoint written before batching.\n * It parsed `{entries:[...]}` as a v1 body, found no `msg`, and printed one\n * empty line — so this batch is lost unless it is re-sent one line at a time,\n * and so is everything after it.\n */\nfunction adoptLegacyMode(lost: DevLogPayload[]): void {\n legacyEndpoint = true;\n const notice: DevLogPayload = {\n level: \"warn\",\n msg:\n \"[forge-devlog] this dev server's /__forge/log endpoint predates batched \" +\n \"posts; update it (forge skill: debug) — falling back to one request per line\",\n };\n buffer.unshift(notice, ...lost);\n bufferedBytes += [notice, ...lost].reduce((n, e) => n + entryBytes(e), 0);\n}\n\n/**\n * Last-chance flush on page teardown.\n *\n * fetch — even with keepalive — is not reliable here, and an async flush is\n * hopeless: the event loop may not run again. sendBeacon hands the bytes to the\n * browser to deliver after the document is gone, which is precisely the case\n * where the last few log lines explain what happened.\n */\nfunction flushOnUnload(): void {\n drainDroppedNotice();\n if (buffer.length === 0) return;\n\n const canBeacon =\n typeof navigator !== \"undefined\" &&\n typeof navigator.sendBeacon === \"function\";\n if (!canBeacon) {\n // No beacon: keepalive fetch is the only remaining option, and one request\n // has a better chance of leaving than a queue of them.\n flush();\n return;\n }\n\n forwarding = true;\n try {\n while (buffer.length > 0) {\n const batch = legacyEndpoint ? takeBatch(0) : takeBatch();\n const body = legacyEndpoint\n ? JSON.stringify(batch[0])\n : JSON.stringify({ entries: batch } satisfies DevLogBatch);\n // text/plain is not a decoration: sendBeacon with application/json is a\n // non-simple request and would need a CORS preflight it cannot make. The\n // endpoints parse the body regardless of Content-Type for this reason.\n const blob = new Blob([body], { type: \"text/plain;charset=UTF-8\" });\n if (!navigator.sendBeacon(endpointUrl, blob)) {\n // The beacon queue is full (64 KiB per origin). Nothing later will fit\n // either, so stop and account for what is left.\n countDropped(batch.length + buffer.length, \"beacon queue full\");\n buffer = [];\n bufferedBytes = 0;\n return;\n }\n }\n } finally {\n forwarding = false;\n }\n}\n\nfunction onWindowError(event: ErrorEvent): void {\n capture(\"error\", [\n event.error ??\n `${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,\n ]);\n}\n\nfunction onRejection(event: PromiseRejectionEvent): void {\n capture(\"error\", [\"unhandled rejection:\", event.reason]);\n}\n\nlet errorHandler: ((e: ErrorEvent) => void) | undefined;\nlet rejectionHandler: ((e: PromiseRejectionEvent) => void) | undefined;\nlet pageHideHandler: (() => void) | undefined;\nlet visibilityHandler: (() => void) | undefined;\n\n/**\n * Mirror console output and uncaught errors to the dev server, which prints\n * them into .forge/logs/<env>/frontend_<name>.log.\n *\n * The window listeners are the point of the feature as much as the console\n * override is: an uncaught TypeError or a rejected promise produces no console\n * call of its own, and those are exactly the failures worth seeing.\n *\n * Safe to call more than once; the second call is a no-op.\n */\nexport function installDevLogging(options: DevLoggingOptions): void {\n // `dev` is read off `options` rather than destructured with a default, and\n // checked FIRST. A bundler constant-folds `import.meta.env.DEV` to false at\n // the CALL SITE and can then drop the argument entirely, leaving a bare\n // `installDevLogging({})` — with a defaulted `dev` that call would install\n // the override in a production bundle. `options?.dev !== true` is therefore\n // fail-closed: absent, undefined, or anything non-true means do nothing.\n if (options?.dev !== true) return;\n if (installed) return;\n if (typeof window === \"undefined\") return; // SSR / prerender pass\n\n const { endpoint = DEV_LOG_ENDPOINT, mirrorToConsole = true } = options;\n installed = true;\n endpointUrl = endpoint;\n buffer = [];\n bufferedBytes = 0;\n droppedCount = 0;\n droppedReason = \"\";\n legacyEndpoint = false;\n requestInFlight = false;\n\n for (const level of LEVELS) {\n // Keep the ORIGINAL reference, not a bound copy: uninstall must restore\n // console.log to the identical function it replaced, so a second consumer\n // holding that reference (or a test asserting on it) sees no difference.\n const previous = console[level] as (...args: unknown[]) => void;\n original[level] = previous;\n console[level] = (...args: unknown[]) => {\n if (mirrorToConsole) previous.apply(console, args);\n capture(level, args);\n };\n }\n\n errorHandler = (e) => onWindowError(e);\n rejectionHandler = (e) => onRejection(e);\n pageHideHandler = () => flushOnUnload();\n window.addEventListener(\"error\", errorHandler);\n window.addEventListener(\"unhandledrejection\", rejectionHandler);\n window.addEventListener(\"pagehide\", pageHideHandler);\n\n if (typeof document !== \"undefined\") {\n // visibilitychange→hidden is the only teardown signal mobile Safari\n // reliably fires; pagehide alone loses the whole buffer there.\n visibilityHandler = () => {\n if (document.visibilityState === \"hidden\") flushOnUnload();\n };\n document.addEventListener(\"visibilitychange\", visibilityHandler);\n }\n}\n\n/** Restore the original console methods and remove the window listeners. */\nexport function uninstallDevLogging(): void {\n if (!installed) return;\n\n // Flush before restoring: whatever is buffered was logged while this was\n // installed, and a teardown is no reason to lose it.\n clearFlushTimer();\n flush();\n\n for (const level of LEVELS) {\n const previous = original[level];\n if (previous) console[level] = previous;\n delete original[level];\n }\n if (errorHandler) window.removeEventListener(\"error\", errorHandler);\n if (rejectionHandler)\n window.removeEventListener(\"unhandledrejection\", rejectionHandler);\n if (pageHideHandler) window.removeEventListener(\"pagehide\", pageHideHandler);\n if (visibilityHandler && typeof document !== \"undefined\")\n document.removeEventListener(\"visibilitychange\", visibilityHandler);\n errorHandler = undefined;\n rejectionHandler = undefined;\n pageHideHandler = undefined;\n visibilityHandler = undefined;\n installed = false;\n}\n\n/** Whether the console override is currently installed. Exposed for tests. */\nexport function devLoggingInstalled(): boolean {\n return installed;\n}\n"]}
@@ -22,6 +22,11 @@ export declare function setExceptionReporter(next: ExceptionReporter | null): vo
22
22
  * boundary-caught error to console.error itself, so the recorder installed
23
23
  * by `/telemetry` skips an error the SDK has already seen: one failure is
24
24
  * one record, never two.
25
+ *
26
+ * A failed RPC's error (ConnectClientError) carries the trace the server
27
+ * recorded it in; it is added as the `server.trace_id` attribute, so the
28
+ * browser record links to the server's spans and logs. An attribute of that
29
+ * name passed by the caller wins.
25
30
  */
26
31
  export declare function reportException(error: unknown, attributes?: ExceptionAttributes): boolean;
27
32
  export {};
@@ -10,6 +10,7 @@
10
10
  // SDK has initialised. Until then — SSR, telemetry off, SDK still loading —
11
11
  // reportException is a no-op, which is what makes it safe to call from any
12
12
  // catch block unconditionally.
13
+ import { SERVER_TRACE_ID_ATTRIBUTE, serverTraceIdOf } from "./trace-id.js";
13
14
  /**
14
15
  * Reports which recorder is installed, if any. reportException's return
15
16
  * value is "the telemetry SDK has this error" — true both when it was just
@@ -39,13 +40,21 @@ export function setExceptionReporter(next) {
39
40
  * boundary-caught error to console.error itself, so the recorder installed
40
41
  * by `/telemetry` skips an error the SDK has already seen: one failure is
41
42
  * one record, never two.
43
+ *
44
+ * A failed RPC's error (ConnectClientError) carries the trace the server
45
+ * recorded it in; it is added as the `server.trace_id` attribute, so the
46
+ * browser record links to the server's spans and logs. An attribute of that
47
+ * name passed by the caller wins.
42
48
  */
43
49
  export function reportException(error, attributes) {
44
50
  if (!reporter) {
45
51
  return false;
46
52
  }
47
53
  try {
48
- reporter(error, attributes);
54
+ const traceId = serverTraceIdOf(error);
55
+ reporter(error, traceId
56
+ ? { [SERVER_TRACE_ID_ATTRIBUTE]: traceId, ...attributes }
57
+ : attributes);
49
58
  }
50
59
  catch {
51
60
  // Telemetry must never break the app.
@@ -1 +1 @@
1
- {"version":3,"file":"error-reporter.js","sourceRoot":"","sources":["../src/error-reporter.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,iEAAiE;AACjE,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,8EAA8E;AAC9E,yEAAyE;AACzE,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,+BAA+B;AAU/B;;;;GAIG;AAEH,IAAI,QAAQ,GAA6B,IAAI,CAAC;AAE9C;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAA8B;IACjE,QAAQ,GAAG,IAAI,CAAC;AAClB,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,eAAe,CAC7B,KAAc,EACd,UAAgC;IAEhC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC;QACH,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,sCAAsC;IACxC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The seam between \"something threw\" and \"somebody recorded it\".\n//\n// The error boundary and the Next.js error files live in the barrel, which\n// every frontend imports — including the Vite SPA and React Native ones that\n// never install the HyperDX browser SDK. So the barrel cannot import the SDK.\n// It imports this dependency-free registry instead, and the `/telemetry`\n// subpath (which does import the SDK) installs the real recorder here once the\n// SDK has initialised. Until then — SSR, telemetry off, SDK still loading —\n// reportException is a no-op, which is what makes it safe to call from any\n// catch block unconditionally.\n\n/** Span/log attribute values the recorder accepts. */\nexport type ExceptionAttributes = Record<string, string | number | boolean>;\n\ntype ExceptionReporter = (\n error: unknown,\n attributes?: ExceptionAttributes,\n) => void;\n\n/**\n * Reports which recorder is installed, if any. reportException's return\n * value is \"the telemetry SDK has this error\" — true both when it was just\n * recorded and when it had already been recorded by another route.\n */\n\nlet reporter: ExceptionReporter | null = null;\n\n/**\n * Install (or clear, with null) the recorder reportException forwards to.\n * Called by initBrowserTelemetry; an app has no reason to call it directly.\n */\nexport function setExceptionReporter(next: ExceptionReporter | null): void {\n reporter = next;\n}\n\n/**\n * reportException hands a caught error to the browser telemetry SDK, if one\n * is running. Never throws: reporting an error must not become a second\n * error.\n *\n * Returns true when the SDK has the error and false when no SDK is running —\n * so a caller can fall back to console.error and an error never vanishes:\n *\n * if (!reportException(error)) console.error(error);\n *\n * Use it where React or Next catch an error for you (error boundaries,\n * `error.tsx`). The SDK already records uncaught exceptions, unhandled\n * rejections and anything passed to console.error, and React logs a\n * boundary-caught error to console.error itself, so the recorder installed\n * by `/telemetry` skips an error the SDK has already seen: one failure is\n * one record, never two.\n */\nexport function reportException(\n error: unknown,\n attributes?: ExceptionAttributes,\n): boolean {\n if (!reporter) {\n return false;\n }\n try {\n reporter(error, attributes);\n } catch {\n // Telemetry must never break the app.\n }\n return true;\n}\n"]}
1
+ {"version":3,"file":"error-reporter.js","sourceRoot":"","sources":["../src/error-reporter.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,iEAAiE;AACjE,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,8EAA8E;AAC9E,yEAAyE;AACzE,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,+BAA+B;AAE/B,OAAO,EAAE,yBAAyB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAU3E;;;;GAIG;AAEH,IAAI,QAAQ,GAA6B,IAAI,CAAC;AAE9C;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAA8B;IACjE,QAAQ,GAAG,IAAI,CAAC;AAClB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,eAAe,CAC7B,KAAc,EACd,UAAgC;IAEhC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,eAAe,CAAC,KAAK,CAAC,CAAC;QACvC,QAAQ,CACN,KAAK,EACL,OAAO;YACL,CAAC,CAAC,EAAE,CAAC,yBAAyB,CAAC,EAAE,OAAO,EAAE,GAAG,UAAU,EAAE;YACzD,CAAC,CAAC,UAAU,CACf,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,sCAAsC;IACxC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The seam between \"something threw\" and \"somebody recorded it\".\n//\n// The error boundary and the Next.js error files live in the barrel, which\n// every frontend imports — including the Vite SPA and React Native ones that\n// never install the HyperDX browser SDK. So the barrel cannot import the SDK.\n// It imports this dependency-free registry instead, and the `/telemetry`\n// subpath (which does import the SDK) installs the real recorder here once the\n// SDK has initialised. Until then — SSR, telemetry off, SDK still loading —\n// reportException is a no-op, which is what makes it safe to call from any\n// catch block unconditionally.\n\nimport { SERVER_TRACE_ID_ATTRIBUTE, serverTraceIdOf } from \"./trace-id.js\";\n\n/** Span/log attribute values the recorder accepts. */\nexport type ExceptionAttributes = Record<string, string | number | boolean>;\n\ntype ExceptionReporter = (\n error: unknown,\n attributes?: ExceptionAttributes,\n) => void;\n\n/**\n * Reports which recorder is installed, if any. reportException's return\n * value is \"the telemetry SDK has this error\" — true both when it was just\n * recorded and when it had already been recorded by another route.\n */\n\nlet reporter: ExceptionReporter | null = null;\n\n/**\n * Install (or clear, with null) the recorder reportException forwards to.\n * Called by initBrowserTelemetry; an app has no reason to call it directly.\n */\nexport function setExceptionReporter(next: ExceptionReporter | null): void {\n reporter = next;\n}\n\n/**\n * reportException hands a caught error to the browser telemetry SDK, if one\n * is running. Never throws: reporting an error must not become a second\n * error.\n *\n * Returns true when the SDK has the error and false when no SDK is running —\n * so a caller can fall back to console.error and an error never vanishes:\n *\n * if (!reportException(error)) console.error(error);\n *\n * Use it where React or Next catch an error for you (error boundaries,\n * `error.tsx`). The SDK already records uncaught exceptions, unhandled\n * rejections and anything passed to console.error, and React logs a\n * boundary-caught error to console.error itself, so the recorder installed\n * by `/telemetry` skips an error the SDK has already seen: one failure is\n * one record, never two.\n *\n * A failed RPC's error (ConnectClientError) carries the trace the server\n * recorded it in; it is added as the `server.trace_id` attribute, so the\n * browser record links to the server's spans and logs. An attribute of that\n * name passed by the caller wins.\n */\nexport function reportException(\n error: unknown,\n attributes?: ExceptionAttributes,\n): boolean {\n if (!reporter) {\n return false;\n }\n try {\n const traceId = serverTraceIdOf(error);\n reporter(\n error,\n traceId\n ? { [SERVER_TRACE_ID_ATTRIBUTE]: traceId, ...attributes }\n : attributes,\n );\n } catch {\n // Telemetry must never break the app.\n }\n return true;\n}\n"]}
package/dist/errors.d.ts CHANGED
@@ -52,6 +52,14 @@ export declare class ConnectClientError extends Error {
52
52
  * never off `message` — message text is display-only.
53
53
  */
54
54
  readonly reason: string | null;
55
+ /**
56
+ * The trace the failed request was recorded in: the server's
57
+ * `traceresponse` when it sent one, else the trace id of the traceparent
58
+ * this runtime sent, else null (the request never left the browser). Put it
59
+ * in a bug report, a toast's "copy details", or a support ticket — it is the
60
+ * key to every server span and log line of that request.
61
+ */
62
+ readonly traceId: string | null;
55
63
  constructor(args: {
56
64
  message: string;
57
65
  code: string;
@@ -59,6 +67,7 @@ export declare class ConnectClientError extends Error {
59
67
  status: number;
60
68
  retryable: boolean;
61
69
  reason?: string | null;
70
+ traceId?: string | null;
62
71
  cause?: unknown;
63
72
  });
64
73
  /**
@@ -80,11 +89,12 @@ export declare function isRetryableCode(code: Code): boolean;
80
89
  /**
81
90
  * normalizeError coerces any thrown value into a ConnectClientError. Already-
82
91
  * normalized errors pass through unchanged so the interceptor chain never
83
- * double-wraps.
92
+ * double-wraps. `requestTraceId` is the trace id of the traceparent the
93
+ * request carried, used when the server sent no `traceresponse`.
84
94
  *
85
95
  * Both `instanceof` checks below match on SHAPE rather than class identity —
86
96
  * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one
87
97
  * @connectrpc/connect ships — so neither branch is missed when a second copy
88
98
  * of either package lands in the bundle.
89
99
  */
90
- export declare function normalizeError(err: unknown): ConnectClientError;
100
+ export declare function normalizeError(err: unknown, requestTraceId?: string | null): ConnectClientError;
package/dist/errors.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // code name, an HTTP-ish status, a retryability verdict, and a
8
8
  // human-presentable message.
9
9
  import { Code, ConnectError } from "@connectrpc/connect";
10
+ import { TRACERESPONSE_HEADER, traceIdFrom } from "./trace.js";
10
11
  /**
11
12
  * FORGE_ERROR_REASON_HEADER is the Connect trailer/metadata key carrying a
12
13
  * stable, machine-readable reason code (snake_case) the backend sets for
@@ -115,6 +116,14 @@ export class ConnectClientError extends Error {
115
116
  * never off `message` — message text is display-only.
116
117
  */
117
118
  reason;
119
+ /**
120
+ * The trace the failed request was recorded in: the server's
121
+ * `traceresponse` when it sent one, else the trace id of the traceparent
122
+ * this runtime sent, else null (the request never left the browser). Put it
123
+ * in a bug report, a toast's "copy details", or a support ticket — it is the
124
+ * key to every server span and log line of that request.
125
+ */
126
+ traceId;
118
127
  constructor(args) {
119
128
  super(args.message, { cause: args.cause });
120
129
  this.name = "ConnectClientError";
@@ -123,6 +132,7 @@ export class ConnectClientError extends Error {
123
132
  this.status = args.status;
124
133
  this.retryable = args.retryable;
125
134
  this.reason = args.reason ?? null;
135
+ this.traceId = args.traceId ?? null;
126
136
  }
127
137
  /**
128
138
  * `instanceof` matches on SHAPE, not on class identity — so every check
@@ -147,14 +157,15 @@ export function isRetryableCode(code) {
147
157
  /**
148
158
  * normalizeError coerces any thrown value into a ConnectClientError. Already-
149
159
  * normalized errors pass through unchanged so the interceptor chain never
150
- * double-wraps.
160
+ * double-wraps. `requestTraceId` is the trace id of the traceparent the
161
+ * request carried, used when the server sent no `traceresponse`.
151
162
  *
152
163
  * Both `instanceof` checks below match on SHAPE rather than class identity —
153
164
  * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one
154
165
  * @connectrpc/connect ships — so neither branch is missed when a second copy
155
166
  * of either package lands in the bundle.
156
167
  */
157
- export function normalizeError(err) {
168
+ export function normalizeError(err, requestTraceId = null) {
158
169
  if (err instanceof ConnectClientError) {
159
170
  return err;
160
171
  }
@@ -169,6 +180,9 @@ export function normalizeError(err) {
169
180
  retryable: isRetryableCode(err.code),
170
181
  // Stable machine reason from backend metadata (backend owns the set).
171
182
  reason: err.metadata.get(FORGE_ERROR_REASON_HEADER) || null,
183
+ // The server's own span when it answered; the trace this runtime asked
184
+ // for when it did not (a proxy error, an older server).
185
+ traceId: traceIdFrom(err.metadata.get(TRACERESPONSE_HEADER)) ?? requestTraceId,
172
186
  cause: err,
173
187
  });
174
188
  }
@@ -184,6 +198,7 @@ export function normalizeError(err) {
184
198
  status: networkFault ? 503 : 500,
185
199
  retryable: networkFault,
186
200
  reason: null,
201
+ traceId: requestTraceId,
187
202
  cause: err,
188
203
  });
189
204
  }
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,wEAAwE;AACxE,wEAAwE;AACxE,4EAA4E;AAC5E,yEAAyE;AACzE,+DAA+D;AAC/D,6BAA6B;AAC7B,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,sBAAsB,CAAC;AAEhE;;;;;;;;;;GAUG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAe;IAChD,OAAO,OAAO,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;AACxD,CAAC;AAED,8EAA8E;AAC9E,MAAM,oBAAoB,GAAG,yCAAyC,CAAC;AAEvE;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,WAAW,CACzB,GAAY,EACZ,WAAmB,oBAAoB;IAEvC,IAAI,GAAG,YAAY,YAAY,EAAE,CAAC;QAChC,OAAO,kBAAkB,CAAC,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC,OAAO,CAAC,IAAI,QAAQ,CAAC;IACvE,CAAC;IACD,IAAI,GAAG,YAAY,KAAK,EAAE,CAAC;QACzB,OAAO,kBAAkB,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,QAAQ,CAAC;IACrD,CAAC;IACD,OAAO,GAAG,IAAI,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,kBAAkB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,QAAQ,CAAC;AAC9E,CAAC;AAED,+DAA+D;AAC/D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAO;IAC9B,IAAI,CAAC,WAAW;IAChB,IAAI,CAAC,gBAAgB;IACrB,IAAI,CAAC,OAAO;IACZ,IAAI,CAAC,iBAAiB;CACvB,CAAC,CAAC;AAEH,2EAA2E;AAC3E,MAAM,WAAW,GAAkC;IACjD,CAAC,IAAI,CAAC,eAAe,CAAC,EAAE,GAAG;IAC3B,CAAC,IAAI,CAAC,eAAe,CAAC,EAAE,GAAG;IAC3B,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,GAAG;IAC5B,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,GAAG;IACpB,CAAC,IAAI,CAAC,aAAa,CAAC,EAAE,GAAG;IACzB,CAAC,IAAI,CAAC,iBAAiB,CAAC,EAAE,GAAG;IAC7B,CAAC,IAAI,CAAC,aAAa,CAAC,EAAE,GAAG;IACzB,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,GAAG;IACvB,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,GAAG;IAC5B,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,GAAG;CACrB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,SAAS,yBAAyB,CAAC,CAAU;IAC3C,IAAI,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACxC,IAAI,MAAM,CAAC,cAAc,CAAC,CAAC,CAAC,KAAK,kBAAkB,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IAC3E,MAAM,CAAC,GAAG,CAAgC,CAAC;IAC3C,OAAO,CACL,CAAC,CAAC,IAAI,KAAK,oBAAoB;QAC/B,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ;QAC1B,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ;QAC5B,OAAO,CAAC,CAAC,SAAS,KAAK,SAAS;QAChC,CAAC,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI,CAAC,CACpD,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC3C,oEAAoE;IAC3D,IAAI,CAAS;IACtB,6EAA6E;IACpE,WAAW,CAAQ;IAC5B,6DAA6D;IACpD,MAAM,CAAS;IACxB,wEAAwE;IAC/D,SAAS,CAAU;IAC5B;;;;OAIG;IACM,MAAM,CAAgB;IAE/B,YAAY,IAQX;QACC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACtB,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC;QACpC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QAC1B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAChC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC;IACpC,CAAC;IAED;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAU;QACpC,OAAO,yBAAyB,CAAC,CAAC,CAAC,CAAC;IACtC,CAAC;CACF;AAED,2EAA2E;AAC3E,MAAM,UAAU,eAAe,CAAC,IAAU;IACxC,OAAO,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAC7B,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,GAAY;IACzC,IAAI,GAAG,YAAY,kBAAkB,EAAE,CAAC;QACtC,OAAO,GAAG,CAAC;IACb,CAAC;IAED,IAAI,GAAG,YAAY,YAAY,EAAE,CAAC;QAChC,OAAO,IAAI,kBAAkB,CAAC;YAC5B,mEAAmE;YACnE,eAAe;YACf,OAAO,EAAE,kBAAkB,CAAC,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC,OAAO,CAAC;YAC1D,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,IAAI,SAAS;YAChD,WAAW,EAAE,GAAG,CAAC,IAAI;YACrB,MAAM,EAAE,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,GAAG;YACpC,SAAS,EAAE,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC;YACpC,sEAAsE;YACtE,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,yBAAyB,CAAC,IAAI,IAAI;YAC3D,KAAK,EAAE,GAAG;SACX,CAAC,CAAC;IACL,CAAC;IAED,IAAI,GAAG,YAAY,KAAK,EAAE,CAAC;QACzB,sEAAsE;QACtE,uEAAuE;QACvE,wEAAwE;QACxE,mEAAmE;QACnE,MAAM,YAAY,GAAG,GAAG,YAAY,SAAS,CAAC;QAC9C,OAAO,IAAI,kBAAkB,CAAC;YAC5B,OAAO,EAAE,kBAAkB,CAAC,GAAG,CAAC,OAAO,CAAC;YACxC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS;YAC9C,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG;YAChC,SAAS,EAAE,YAAY;YACvB,MAAM,EAAE,IAAI;YACZ,KAAK,EAAE,GAAG;SACX,CAAC,CAAC;IACL,CAAC;IAED,OAAO,IAAI,kBAAkB,CAAC;QAC5B,OAAO,EACL,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,eAAe;QACrE,IAAI,EAAE,SAAS;QACf,MAAM,EAAE,GAAG;QACX,SAAS,EAAE,KAAK;QAChB,MAAM,EAAE,IAAI;QACZ,KAAK,EAAE,GAAG;KACX,CAAC,CAAC;AACL,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Typed client errors. Connect surfaces failures as ConnectError with a\n// numeric Code; the rest of the app should reason about a stable, typed\n// shape instead of sniffing status codes at every call site. normalizeError\n// maps any thrown value into a ConnectClientError carrying the canonical\n// code name, an HTTP-ish status, a retryability verdict, and a\n// human-presentable message.\nimport { Code, ConnectError } from \"@connectrpc/connect\";\n\n/**\n * FORGE_ERROR_REASON_HEADER is the Connect trailer/metadata key carrying a\n * stable, machine-readable reason code (snake_case) the backend sets for\n * every error. UI/routing keys off `error.reason` instead of matching\n * human-readable message strings — the message text is display-only and\n * may change. The backend owns the setter; the frontend only reads it.\n */\nexport const FORGE_ERROR_REASON_HEADER = \"x-forge-error-reason\";\n\n/**\n * stripServerFraming removes the transport framing that must never surface\n * to a user: the leading \"[code] \" gRPC-code prefix connect-es puts on\n * `ConnectError.message`. What survives is the message the server wrote,\n * which is exactly what a user should read.\n *\n * This is the SINGLE implementation — a project never wants a different\n * answer here, and a copy frozen into a scaffold could never be corrected\n * once shipped. Idempotent: framing that has already been stripped strips\n * to itself.\n */\nexport function stripServerFraming(message: string): string {\n return message.replace(/^\\[[a-z_]+\\]\\s*/i, \"\").trim();\n}\n\n/** The copy shown when an error carries no presentable message of its own. */\nconst DEFAULT_USER_MESSAGE = \"Something went wrong. Please try again.\";\n\n/**\n * userMessage — turn any thrown value into copy fit for end users.\n *\n * `ConnectError.message` prefixes the gRPC code (\"[not_found] no such task\");\n * `rawMessage` is the server's human-readable text without that framing, and\n * stripServerFraming drops the prefix from whichever of the two is available.\n * Use this everywhere an error is SHOWN (banners, toasts) instead of\n * `err.message`.\n *\n * Pass `fallback` to override the default copy for a surface that wants its\n * own wording.\n */\nexport function userMessage(\n err: unknown,\n fallback: string = DEFAULT_USER_MESSAGE,\n): string {\n if (err instanceof ConnectError) {\n return stripServerFraming(err.rawMessage || err.message) || fallback;\n }\n if (err instanceof Error) {\n return stripServerFraming(err.message) || fallback;\n }\n return err == null ? fallback : stripServerFraming(String(err)) || fallback;\n}\n\n/** Connect codes worth a transparent transport-level retry. */\nconst RETRYABLE = new Set<Code>([\n Code.Unavailable,\n Code.DeadlineExceeded,\n Code.Aborted,\n Code.ResourceExhausted,\n]);\n\n/** Rough Connect-code → HTTP-status mapping for display/telemetry only. */\nconst HTTP_STATUS: Partial<Record<Code, number>> = {\n [Code.InvalidArgument]: 400,\n [Code.Unauthenticated]: 401,\n [Code.PermissionDenied]: 403,\n [Code.NotFound]: 404,\n [Code.AlreadyExists]: 409,\n [Code.ResourceExhausted]: 429,\n [Code.Unimplemented]: 501,\n [Code.Unavailable]: 503,\n [Code.DeadlineExceeded]: 504,\n [Code.Internal]: 500,\n};\n\n/**\n * isConnectClientErrorShape — the STRUCTURAL test behind\n * {@link ConnectClientError}'s `instanceof`.\n *\n * A class object is per module instance, not per package. npm nests a\n * transitive dependency on any version conflict, and a bundler can pull one\n * package in as both ESM and CJS, so a tree holding two copies of this\n * package holds two distinct ConnectClientError classes. Matching on shape\n * instead of identity is what makes the two agree: the shape is the\n * contract, the constructor is not.\n */\nfunction isConnectClientErrorShape(v: unknown): v is ConnectClientError {\n if (!(v instanceof Error)) return false;\n if (Object.getPrototypeOf(v) === ConnectClientError.prototype) return true;\n const e = v as Partial<ConnectClientError>;\n return (\n v.name === \"ConnectClientError\" &&\n typeof e.code === \"string\" &&\n typeof e.status === \"number\" &&\n typeof e.retryable === \"boolean\" &&\n (typeof e.reason === \"string\" || e.reason === null)\n );\n}\n\n/**\n * ConnectClientError is the single typed error shape the app catches. It is\n * a plain Error subclass so it works with React Query, error boundaries, and\n * `instanceof` alike.\n */\nexport class ConnectClientError extends Error {\n /** Canonical Connect code name, e.g. \"unavailable\", \"not_found\". */\n readonly code: string;\n /** Numeric Connect code (Code enum), or undefined for non-Connect errors. */\n readonly connectCode?: Code;\n /** Approximate HTTP status, for telemetry and generic UI. */\n readonly status: number;\n /** True when a transparent retry is reasonable (transient failures). */\n readonly retryable: boolean;\n /**\n * Stable machine-readable reason code from the backend\n * (x-forge-error-reason metadata), or null. Key UI/routing off THIS,\n * never off `message` — message text is display-only.\n */\n readonly reason: string | null;\n\n constructor(args: {\n message: string;\n code: string;\n connectCode?: Code;\n status: number;\n retryable: boolean;\n reason?: string | null;\n cause?: unknown;\n }) {\n super(args.message, { cause: args.cause });\n this.name = \"ConnectClientError\";\n this.code = args.code;\n this.connectCode = args.connectCode;\n this.status = args.status;\n this.retryable = args.retryable;\n this.reason = args.reason ?? null;\n }\n\n /**\n * `instanceof` matches on SHAPE, not on class identity — so every check\n * spelled `err instanceof ConnectClientError`, here and in app code, keeps\n * working when two copies of this package end up in one bundle (see\n * {@link isConnectClientErrorShape}). Without it, normalizeError's\n * pass-through guard misses an already-normalized error from the other\n * copy; that error's `name` is \"ConnectClientError\" rather than\n * \"ConnectError\", so it misses the ConnectError branch too and is re-wrapped\n * through the generic Error branch — silently downgrading every error in the\n * app to code \"unknown\", status 500, no connectCode and no reason.\n * @connectrpc/connect does the same on ConnectError, for the same reason.\n */\n static [Symbol.hasInstance](v: unknown): boolean {\n return isConnectClientErrorShape(v);\n }\n}\n\n/** isRetryable reports whether a Connect code merits a transport retry. */\nexport function isRetryableCode(code: Code): boolean {\n return RETRYABLE.has(code);\n}\n\n/**\n * normalizeError coerces any thrown value into a ConnectClientError. Already-\n * normalized errors pass through unchanged so the interceptor chain never\n * double-wraps.\n *\n * Both `instanceof` checks below match on SHAPE rather than class identity —\n * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one\n * @connectrpc/connect ships — so neither branch is missed when a second copy\n * of either package lands in the bundle.\n */\nexport function normalizeError(err: unknown): ConnectClientError {\n if (err instanceof ConnectClientError) {\n return err;\n }\n\n if (err instanceof ConnectError) {\n return new ConnectClientError({\n // Strip the \"[code] \" prefix so the message is user-presentable at\n // every layer.\n message: stripServerFraming(err.rawMessage || err.message),\n code: Code[err.code]?.toLowerCase() ?? \"unknown\",\n connectCode: err.code,\n status: HTTP_STATUS[err.code] ?? 500,\n retryable: isRetryableCode(err.code),\n // Stable machine reason from backend metadata (backend owns the set).\n reason: err.metadata.get(FORGE_ERROR_REASON_HEADER) || null,\n cause: err,\n });\n }\n\n if (err instanceof Error) {\n // A network fault (fetch rejected: offline, DNS, CORS preflight, TLS)\n // surfaces as a TypeError — that, and only that, is a safe transparent\n // retry. A generic Error is NOT blanket-retried (blanket-retrying every\n // bare Error widened the blast radius to non-idempotent failures).\n const networkFault = err instanceof TypeError;\n return new ConnectClientError({\n message: stripServerFraming(err.message),\n code: networkFault ? \"unavailable\" : \"unknown\",\n status: networkFault ? 503 : 500,\n retryable: networkFault,\n reason: null,\n cause: err,\n });\n }\n\n return new ConnectClientError({\n message:\n typeof err === \"string\" ? stripServerFraming(err) : \"Unknown error\",\n code: \"unknown\",\n status: 500,\n retryable: false,\n reason: null,\n cause: err,\n });\n}\n"]}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Typed client errors. Connect surfaces failures as ConnectError with a\n// numeric Code; the rest of the app should reason about a stable, typed\n// shape instead of sniffing status codes at every call site. normalizeError\n// maps any thrown value into a ConnectClientError carrying the canonical\n// code name, an HTTP-ish status, a retryability verdict, and a\n// human-presentable message.\nimport { Code, ConnectError } from \"@connectrpc/connect\";\n\nimport { TRACERESPONSE_HEADER, traceIdFrom } from \"./trace.js\";\n\n/**\n * FORGE_ERROR_REASON_HEADER is the Connect trailer/metadata key carrying a\n * stable, machine-readable reason code (snake_case) the backend sets for\n * every error. UI/routing keys off `error.reason` instead of matching\n * human-readable message strings — the message text is display-only and\n * may change. The backend owns the setter; the frontend only reads it.\n */\nexport const FORGE_ERROR_REASON_HEADER = \"x-forge-error-reason\";\n\n/**\n * stripServerFraming removes the transport framing that must never surface\n * to a user: the leading \"[code] \" gRPC-code prefix connect-es puts on\n * `ConnectError.message`. What survives is the message the server wrote,\n * which is exactly what a user should read.\n *\n * This is the SINGLE implementation — a project never wants a different\n * answer here, and a copy frozen into a scaffold could never be corrected\n * once shipped. Idempotent: framing that has already been stripped strips\n * to itself.\n */\nexport function stripServerFraming(message: string): string {\n return message.replace(/^\\[[a-z_]+\\]\\s*/i, \"\").trim();\n}\n\n/** The copy shown when an error carries no presentable message of its own. */\nconst DEFAULT_USER_MESSAGE = \"Something went wrong. Please try again.\";\n\n/**\n * userMessage — turn any thrown value into copy fit for end users.\n *\n * `ConnectError.message` prefixes the gRPC code (\"[not_found] no such task\");\n * `rawMessage` is the server's human-readable text without that framing, and\n * stripServerFraming drops the prefix from whichever of the two is available.\n * Use this everywhere an error is SHOWN (banners, toasts) instead of\n * `err.message`.\n *\n * Pass `fallback` to override the default copy for a surface that wants its\n * own wording.\n */\nexport function userMessage(\n err: unknown,\n fallback: string = DEFAULT_USER_MESSAGE,\n): string {\n if (err instanceof ConnectError) {\n return stripServerFraming(err.rawMessage || err.message) || fallback;\n }\n if (err instanceof Error) {\n return stripServerFraming(err.message) || fallback;\n }\n return err == null ? fallback : stripServerFraming(String(err)) || fallback;\n}\n\n/** Connect codes worth a transparent transport-level retry. */\nconst RETRYABLE = new Set<Code>([\n Code.Unavailable,\n Code.DeadlineExceeded,\n Code.Aborted,\n Code.ResourceExhausted,\n]);\n\n/** Rough Connect-code → HTTP-status mapping for display/telemetry only. */\nconst HTTP_STATUS: Partial<Record<Code, number>> = {\n [Code.InvalidArgument]: 400,\n [Code.Unauthenticated]: 401,\n [Code.PermissionDenied]: 403,\n [Code.NotFound]: 404,\n [Code.AlreadyExists]: 409,\n [Code.ResourceExhausted]: 429,\n [Code.Unimplemented]: 501,\n [Code.Unavailable]: 503,\n [Code.DeadlineExceeded]: 504,\n [Code.Internal]: 500,\n};\n\n/**\n * isConnectClientErrorShape — the STRUCTURAL test behind\n * {@link ConnectClientError}'s `instanceof`.\n *\n * A class object is per module instance, not per package. npm nests a\n * transitive dependency on any version conflict, and a bundler can pull one\n * package in as both ESM and CJS, so a tree holding two copies of this\n * package holds two distinct ConnectClientError classes. Matching on shape\n * instead of identity is what makes the two agree: the shape is the\n * contract, the constructor is not.\n */\nfunction isConnectClientErrorShape(v: unknown): v is ConnectClientError {\n if (!(v instanceof Error)) return false;\n if (Object.getPrototypeOf(v) === ConnectClientError.prototype) return true;\n const e = v as Partial<ConnectClientError>;\n return (\n v.name === \"ConnectClientError\" &&\n typeof e.code === \"string\" &&\n typeof e.status === \"number\" &&\n typeof e.retryable === \"boolean\" &&\n (typeof e.reason === \"string\" || e.reason === null)\n );\n}\n\n/**\n * ConnectClientError is the single typed error shape the app catches. It is\n * a plain Error subclass so it works with React Query, error boundaries, and\n * `instanceof` alike.\n */\nexport class ConnectClientError extends Error {\n /** Canonical Connect code name, e.g. \"unavailable\", \"not_found\". */\n readonly code: string;\n /** Numeric Connect code (Code enum), or undefined for non-Connect errors. */\n readonly connectCode?: Code;\n /** Approximate HTTP status, for telemetry and generic UI. */\n readonly status: number;\n /** True when a transparent retry is reasonable (transient failures). */\n readonly retryable: boolean;\n /**\n * Stable machine-readable reason code from the backend\n * (x-forge-error-reason metadata), or null. Key UI/routing off THIS,\n * never off `message` — message text is display-only.\n */\n readonly reason: string | null;\n /**\n * The trace the failed request was recorded in: the server's\n * `traceresponse` when it sent one, else the trace id of the traceparent\n * this runtime sent, else null (the request never left the browser). Put it\n * in a bug report, a toast's \"copy details\", or a support ticket — it is the\n * key to every server span and log line of that request.\n */\n readonly traceId: string | null;\n\n constructor(args: {\n message: string;\n code: string;\n connectCode?: Code;\n status: number;\n retryable: boolean;\n reason?: string | null;\n traceId?: string | null;\n cause?: unknown;\n }) {\n super(args.message, { cause: args.cause });\n this.name = \"ConnectClientError\";\n this.code = args.code;\n this.connectCode = args.connectCode;\n this.status = args.status;\n this.retryable = args.retryable;\n this.reason = args.reason ?? null;\n this.traceId = args.traceId ?? null;\n }\n\n /**\n * `instanceof` matches on SHAPE, not on class identity — so every check\n * spelled `err instanceof ConnectClientError`, here and in app code, keeps\n * working when two copies of this package end up in one bundle (see\n * {@link isConnectClientErrorShape}). Without it, normalizeError's\n * pass-through guard misses an already-normalized error from the other\n * copy; that error's `name` is \"ConnectClientError\" rather than\n * \"ConnectError\", so it misses the ConnectError branch too and is re-wrapped\n * through the generic Error branch — silently downgrading every error in the\n * app to code \"unknown\", status 500, no connectCode and no reason.\n * @connectrpc/connect does the same on ConnectError, for the same reason.\n */\n static [Symbol.hasInstance](v: unknown): boolean {\n return isConnectClientErrorShape(v);\n }\n}\n\n/** isRetryable reports whether a Connect code merits a transport retry. */\nexport function isRetryableCode(code: Code): boolean {\n return RETRYABLE.has(code);\n}\n\n/**\n * normalizeError coerces any thrown value into a ConnectClientError. Already-\n * normalized errors pass through unchanged so the interceptor chain never\n * double-wraps. `requestTraceId` is the trace id of the traceparent the\n * request carried, used when the server sent no `traceresponse`.\n *\n * Both `instanceof` checks below match on SHAPE rather than class identity —\n * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one\n * @connectrpc/connect ships — so neither branch is missed when a second copy\n * of either package lands in the bundle.\n */\nexport function normalizeError(\n err: unknown,\n requestTraceId: string | null = null,\n): ConnectClientError {\n if (err instanceof ConnectClientError) {\n return err;\n }\n\n if (err instanceof ConnectError) {\n return new ConnectClientError({\n // Strip the \"[code] \" prefix so the message is user-presentable at\n // every layer.\n message: stripServerFraming(err.rawMessage || err.message),\n code: Code[err.code]?.toLowerCase() ?? \"unknown\",\n connectCode: err.code,\n status: HTTP_STATUS[err.code] ?? 500,\n retryable: isRetryableCode(err.code),\n // Stable machine reason from backend metadata (backend owns the set).\n reason: err.metadata.get(FORGE_ERROR_REASON_HEADER) || null,\n // The server's own span when it answered; the trace this runtime asked\n // for when it did not (a proxy error, an older server).\n traceId:\n traceIdFrom(err.metadata.get(TRACERESPONSE_HEADER)) ?? requestTraceId,\n cause: err,\n });\n }\n\n if (err instanceof Error) {\n // A network fault (fetch rejected: offline, DNS, CORS preflight, TLS)\n // surfaces as a TypeError — that, and only that, is a safe transparent\n // retry. A generic Error is NOT blanket-retried (blanket-retrying every\n // bare Error widened the blast radius to non-idempotent failures).\n const networkFault = err instanceof TypeError;\n return new ConnectClientError({\n message: stripServerFraming(err.message),\n code: networkFault ? \"unavailable\" : \"unknown\",\n status: networkFault ? 503 : 500,\n retryable: networkFault,\n reason: null,\n traceId: requestTraceId,\n cause: err,\n });\n }\n\n return new ConnectClientError({\n message:\n typeof err === \"string\" ? stripServerFraming(err) : \"Unknown error\",\n code: \"unknown\",\n status: 500,\n retryable: false,\n reason: null,\n cause: err,\n });\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export { createBasePath, joinBasePath, normalizeBasePath, type BasePathHelpers,
23
23
  */
24
24
  export { basisPointsToInput, currencyMinorDigits, formatBasisPoints, formatMinorUnits, minorUnitsToInput, parseBasisPoints, parseMinorUnits, type BasisPointsOptions, type MoneyOptions, type ParseBasisPointsOptions, type ParseMoneyOptions, } from "./money.js";
25
25
  /** W3C trace-context propagation. */
26
- export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
26
+ export { freshTraceparent, injectTraceContext, setTraceSampled, traceIdFrom, TRACEPARENT_RE, TRACERESPONSE_HEADER, } from "./trace.js";
27
27
  /** Session: the signed-in user, derived from the app's auth state and JWT claims. */
28
28
  export { decodeJwt, SessionProvider, useSession, type JwtClaims, type Session, type SessionAuth, } from "./session.js";
29
29
  /** The app-shell provider set: session + error boundary + toast host in one. */
@@ -42,6 +42,8 @@ export { Resource, type ResourceColumn, type ResourceProps, type ResourceStatus,
42
42
  * through the "/telemetry" subpath, never the barrel.
43
43
  */
44
44
  export { reportException, setExceptionReporter, type ExceptionAttributes, } from "./error-reporter.js";
45
+ /** The server trace id a failed RPC's error carries (ConnectClientError.traceId). */
46
+ export { SERVER_TRACE_ID_ATTRIBUTE, serverTraceIdOf } from "./trace-id.js";
45
47
  /**
46
48
  * Product events, the signed-in user and the consent kill-switch for browser
47
49
  * telemetry. Like reportException these are safe to call anywhere — no-ops on
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ export { createBasePath, joinBasePath, normalizeBasePath, } from "./basepath.js"
41
41
  */
42
42
  export { basisPointsToInput, currencyMinorDigits, formatBasisPoints, formatMinorUnits, minorUnitsToInput, parseBasisPoints, parseMinorUnits, } from "./money.js";
43
43
  /** W3C trace-context propagation. */
44
- export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
44
+ export { freshTraceparent, injectTraceContext, setTraceSampled, traceIdFrom, TRACEPARENT_RE, TRACERESPONSE_HEADER, } from "./trace.js";
45
45
  /** Session: the signed-in user, derived from the app's auth state and JWT claims. */
46
46
  export { decodeJwt, SessionProvider, useSession, } from "./session.js";
47
47
  /** The app-shell provider set: session + error boundary + toast host in one. */
@@ -60,6 +60,8 @@ export { Resource, } from "./resource.js";
60
60
  * through the "/telemetry" subpath, never the barrel.
61
61
  */
62
62
  export { reportException, setExceptionReporter, } from "./error-reporter.js";
63
+ /** The server trace id a failed RPC's error carries (ConnectClientError.traceId). */
64
+ export { SERVER_TRACE_ID_ATTRIBUTE, serverTraceIdOf } from "./trace-id.js";
63
65
  /**
64
66
  * Product events, the signed-in user and the consent kill-switch for browser
65
67
  * telemetry. Like reportException these are safe to call anywhere — no-ops on
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAChE,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,4CAA4C;AAE5C,yEAAyE;AACzE,6EAA6E;AAC7E,4EAA4E;AAC5E,4EAA4E;AAC5E,sEAAsE;AAEtE,mCAAmC;AACnC,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,GAEjB,MAAM,mBAAmB,CAAC;AAE3B;;;GAGG;AACH,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,iBAAiB,GAElB,MAAM,eAAe,CAAC;AAEvB;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,GAKhB,MAAM,YAAY,CAAC;AAEpB,qCAAqC;AACrC,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB,qFAAqF;AACrF,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,GAIX,MAAM,cAAc,CAAC;AAEtB,gFAAgF;AAChF,OAAO,EAAE,YAAY,EAA0B,MAAM,gBAAgB,CAAC;AAEtE,+EAA+E;AAC/E,OAAO,EACL,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAE7B,yEAAyE;AACzE,OAAO,EACL,gBAAgB,GAMjB,MAAM,iBAAiB,CAAC;AAEzB,gEAAgE;AAChE,OAAO,EAAE,UAAU,EAAwB,MAAM,kBAAkB,CAAC;AAEpE,uFAAuF;AACvF,OAAO,EACL,QAAQ,GAIT,MAAM,eAAe,CAAC;AAEvB;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,GAErB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,GAGN,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,GAGpB,MAAM,aAAa,CAAC","sourcesContent":["// @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Public barrel. Import batteries from \"@reliantlabs/forge-web-runtime\".\n//\n// Everything here is MECHANISM: the transport interceptor stack, the session,\n// the error boundary, the toast queue, the <Resource> tristate ladder, W3C\n// trace propagation, client RUM. Nothing here reaches into an application —\n// app-shaped values (auth state, event subscriptions, toast markup) arrive as\n// props, so an app can restyle or replace its own files freely.\n//\n// Presentation that a project is meant to own — the component library under\n// src/components/ui, globals.css, nav, layout — deliberately stays in the\n// scaffold and is NOT part of this package.\n\n// The per-group docs below are JSDoc, not `//`, on purpose: TypeScript's\n// declaration emit preserves JSDoc and DROPS line comments, and dist/ is now\n// the whole of what this package publishes. `forge project libraries` reads\n// this inventory out of the published barrel, and a consumer's editor shows\n// it on hover — both would get symbols with no explanation otherwise.\n\n/** Transport interceptor stack. */\nexport {\n authInterceptor,\n buildRuntimeInterceptors,\n errorNormalizeInterceptor,\n headerInterceptor,\n retryInterceptor,\n traceInterceptor,\n type RuntimeInterceptorConfig,\n} from \"./interceptors.js\";\n\n/**\n * Typed client errors, and the backend-framing strippers every surface that\n * SHOWS an error goes through.\n */\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n stripServerFraming,\n userMessage,\n} from \"./errors.js\";\n\n/**\n * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute\n * URLs handed to external systems that round-trip back into the app.\n *\n * Barrel-exported rather than behind a subpath: three string functions with\n * no imports, so there is no dependency footprint for a subpath to fence off.\n */\nexport {\n createBasePath,\n joinBasePath,\n normalizeBasePath,\n type BasePathHelpers,\n} from \"./basepath.js\";\n\n/**\n * Money and basis points as integers: format minor units through Intl, and\n * parse form input back to minor units without rounding.\n *\n * The web counterpart of forge/pkg/money — an int64 count of the currency's\n * minor unit plus an ISO 4217 code, exact past 2^53. Barrel-exported for the\n * same reason as the base-path helpers: it imports nothing.\n */\nexport {\n basisPointsToInput,\n currencyMinorDigits,\n formatBasisPoints,\n formatMinorUnits,\n minorUnitsToInput,\n parseBasisPoints,\n parseMinorUnits,\n type BasisPointsOptions,\n type MoneyOptions,\n type ParseBasisPointsOptions,\n type ParseMoneyOptions,\n} from \"./money.js\";\n\n/** W3C trace-context propagation. */\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n TRACEPARENT_RE,\n} from \"./trace.js\";\n\n/** Session: the signed-in user, derived from the app's auth state and JWT claims. */\nexport {\n decodeJwt,\n SessionProvider,\n useSession,\n type JwtClaims,\n type Session,\n type SessionAuth,\n} from \"./session.js\";\n\n/** The app-shell provider set: session + error boundary + toast host in one. */\nexport { RuntimeShell, type RuntimeShellProps } from \"./providers.js\";\n\n/** Error boundary with a designed crash fallback, for a route or a subtree. */\nexport {\n DefaultErrorFallback,\n RuntimeErrorBoundary,\n} from \"./error-boundary.js\";\n\n/** The toast queue. The app supplies the event source and the markup. */\nexport {\n RuntimeToastHost,\n type RuntimeToast,\n type RuntimeToastHostProps,\n type RuntimeToastPayload,\n type RuntimeToastSink,\n type RuntimeToastVariant,\n} from \"./toast-host.js\";\n\n/** Route guarding: render a page only to a signed-in caller. */\nexport { RouteGuard, type RouteGuardProps } from \"./route-guard.js\";\n\n/** Generic data-table container: the tristate ladder, filter and cursor pagination. */\nexport {\n Resource,\n type ResourceColumn,\n type ResourceProps,\n type ResourceStatus,\n} from \"./resource.js\";\n\n/**\n * Record a caught error with the browser telemetry SDK, when one is running.\n * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives\n * through the \"/telemetry\" subpath, never the barrel.\n */\nexport {\n reportException,\n setExceptionReporter,\n type ExceptionAttributes,\n} from \"./error-reporter.js\";\n\n/**\n * Product events, the signed-in user and the consent kill-switch for browser\n * telemetry. Like reportException these are safe to call anywhere — no-ops on\n * the server and when telemetry is off — and calls made while the SDK is still\n * loading are held and replayed. The SDK itself arrives through the\n * \"/telemetry\" subpath, never the barrel.\n */\nexport {\n disableTelemetry,\n setTelemetryUser,\n track,\n type TrackProps,\n type TrackValue,\n} from \"./track.js\";\n\n/**\n * Dev-only browser log forwarding: mirrors console output and uncaught errors\n * to the dev server, which prints them into\n * `.forge/logs/<env>/frontend_<name>.log`. No-ops unless `dev: true`, and the\n * receiving endpoint exists only in a dev server — so a production bundle\n * neither posts nor has anywhere to post to.\n */\nexport {\n DEV_LOG_ENDPOINT,\n devLoggingInstalled,\n installDevLogging,\n uninstallDevLogging,\n type DevLoggingOptions,\n type DevLogPayload,\n} from \"./devlog.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAChE,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,4CAA4C;AAE5C,yEAAyE;AACzE,6EAA6E;AAC7E,4EAA4E;AAC5E,4EAA4E;AAC5E,sEAAsE;AAEtE,mCAAmC;AACnC,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,GAEjB,MAAM,mBAAmB,CAAC;AAE3B;;;GAGG;AACH,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,iBAAiB,GAElB,MAAM,eAAe,CAAC;AAEvB;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,GAKhB,MAAM,YAAY,CAAC;AAEpB,qCAAqC;AACrC,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,WAAW,EACX,cAAc,EACd,oBAAoB,GACrB,MAAM,YAAY,CAAC;AAEpB,qFAAqF;AACrF,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,GAIX,MAAM,cAAc,CAAC;AAEtB,gFAAgF;AAChF,OAAO,EAAE,YAAY,EAA0B,MAAM,gBAAgB,CAAC;AAEtE,+EAA+E;AAC/E,OAAO,EACL,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAE7B,yEAAyE;AACzE,OAAO,EACL,gBAAgB,GAMjB,MAAM,iBAAiB,CAAC;AAEzB,gEAAgE;AAChE,OAAO,EAAE,UAAU,EAAwB,MAAM,kBAAkB,CAAC;AAEpE,uFAAuF;AACvF,OAAO,EACL,QAAQ,GAIT,MAAM,eAAe,CAAC;AAEvB;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,GAErB,MAAM,qBAAqB,CAAC;AAE7B,qFAAqF;AACrF,OAAO,EAAE,yBAAyB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAE3E;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,GAGN,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,GAGpB,MAAM,aAAa,CAAC","sourcesContent":["// @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Public barrel. Import batteries from \"@reliantlabs/forge-web-runtime\".\n//\n// Everything here is MECHANISM: the transport interceptor stack, the session,\n// the error boundary, the toast queue, the <Resource> tristate ladder, W3C\n// trace propagation, client RUM. Nothing here reaches into an application —\n// app-shaped values (auth state, event subscriptions, toast markup) arrive as\n// props, so an app can restyle or replace its own files freely.\n//\n// Presentation that a project is meant to own — the component library under\n// src/components/ui, globals.css, nav, layout — deliberately stays in the\n// scaffold and is NOT part of this package.\n\n// The per-group docs below are JSDoc, not `//`, on purpose: TypeScript's\n// declaration emit preserves JSDoc and DROPS line comments, and dist/ is now\n// the whole of what this package publishes. `forge project libraries` reads\n// this inventory out of the published barrel, and a consumer's editor shows\n// it on hover — both would get symbols with no explanation otherwise.\n\n/** Transport interceptor stack. */\nexport {\n authInterceptor,\n buildRuntimeInterceptors,\n errorNormalizeInterceptor,\n headerInterceptor,\n retryInterceptor,\n traceInterceptor,\n type RuntimeInterceptorConfig,\n} from \"./interceptors.js\";\n\n/**\n * Typed client errors, and the backend-framing strippers every surface that\n * SHOWS an error goes through.\n */\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n stripServerFraming,\n userMessage,\n} from \"./errors.js\";\n\n/**\n * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute\n * URLs handed to external systems that round-trip back into the app.\n *\n * Barrel-exported rather than behind a subpath: three string functions with\n * no imports, so there is no dependency footprint for a subpath to fence off.\n */\nexport {\n createBasePath,\n joinBasePath,\n normalizeBasePath,\n type BasePathHelpers,\n} from \"./basepath.js\";\n\n/**\n * Money and basis points as integers: format minor units through Intl, and\n * parse form input back to minor units without rounding.\n *\n * The web counterpart of forge/pkg/money — an int64 count of the currency's\n * minor unit plus an ISO 4217 code, exact past 2^53. Barrel-exported for the\n * same reason as the base-path helpers: it imports nothing.\n */\nexport {\n basisPointsToInput,\n currencyMinorDigits,\n formatBasisPoints,\n formatMinorUnits,\n minorUnitsToInput,\n parseBasisPoints,\n parseMinorUnits,\n type BasisPointsOptions,\n type MoneyOptions,\n type ParseBasisPointsOptions,\n type ParseMoneyOptions,\n} from \"./money.js\";\n\n/** W3C trace-context propagation. */\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n traceIdFrom,\n TRACEPARENT_RE,\n TRACERESPONSE_HEADER,\n} from \"./trace.js\";\n\n/** Session: the signed-in user, derived from the app's auth state and JWT claims. */\nexport {\n decodeJwt,\n SessionProvider,\n useSession,\n type JwtClaims,\n type Session,\n type SessionAuth,\n} from \"./session.js\";\n\n/** The app-shell provider set: session + error boundary + toast host in one. */\nexport { RuntimeShell, type RuntimeShellProps } from \"./providers.js\";\n\n/** Error boundary with a designed crash fallback, for a route or a subtree. */\nexport {\n DefaultErrorFallback,\n RuntimeErrorBoundary,\n} from \"./error-boundary.js\";\n\n/** The toast queue. The app supplies the event source and the markup. */\nexport {\n RuntimeToastHost,\n type RuntimeToast,\n type RuntimeToastHostProps,\n type RuntimeToastPayload,\n type RuntimeToastSink,\n type RuntimeToastVariant,\n} from \"./toast-host.js\";\n\n/** Route guarding: render a page only to a signed-in caller. */\nexport { RouteGuard, type RouteGuardProps } from \"./route-guard.js\";\n\n/** Generic data-table container: the tristate ladder, filter and cursor pagination. */\nexport {\n Resource,\n type ResourceColumn,\n type ResourceProps,\n type ResourceStatus,\n} from \"./resource.js\";\n\n/**\n * Record a caught error with the browser telemetry SDK, when one is running.\n * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives\n * through the \"/telemetry\" subpath, never the barrel.\n */\nexport {\n reportException,\n setExceptionReporter,\n type ExceptionAttributes,\n} from \"./error-reporter.js\";\n\n/** The server trace id a failed RPC's error carries (ConnectClientError.traceId). */\nexport { SERVER_TRACE_ID_ATTRIBUTE, serverTraceIdOf } from \"./trace-id.js\";\n\n/**\n * Product events, the signed-in user and the consent kill-switch for browser\n * telemetry. Like reportException these are safe to call anywhere — no-ops on\n * the server and when telemetry is off — and calls made while the SDK is still\n * loading are held and replayed. The SDK itself arrives through the\n * \"/telemetry\" subpath, never the barrel.\n */\nexport {\n disableTelemetry,\n setTelemetryUser,\n track,\n type TrackProps,\n type TrackValue,\n} from \"./track.js\";\n\n/**\n * Dev-only browser log forwarding: mirrors console output and uncaught errors\n * to the dev server, which prints them into\n * `.forge/logs/<env>/frontend_<name>.log`. No-ops unless `dev: true`, and the\n * receiving endpoint exists only in a dev server — so a production bundle\n * neither posts nor has anywhere to post to.\n */\nexport {\n DEV_LOG_ENDPOINT,\n devLoggingInstalled,\n installDevLogging,\n uninstallDevLogging,\n type DevLoggingOptions,\n type DevLogPayload,\n} from \"./devlog.js\";\n"]}
@@ -50,4 +50,4 @@ export declare function retryInterceptor(cfg: RuntimeInterceptorConfig): Interce
50
50
  */
51
51
  export declare function buildRuntimeInterceptors(cfg?: RuntimeInterceptorConfig): Interceptor[];
52
52
  export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.js";
53
- export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
53
+ export { freshTraceparent, injectTraceContext, setTraceSampled, traceIdFrom, TRACEPARENT_RE, TRACERESPONSE_HEADER, } from "./trace.js";
@@ -35,7 +35,7 @@
35
35
  import { MethodOptions_IdempotencyLevel } from "@bufbuild/protobuf/wkt";
36
36
  import { ConnectClientError, normalizeError } from "./errors.js";
37
37
  import { telemetryReady } from "./telemetry-gate.js";
38
- import { injectTraceContext } from "./trace.js";
38
+ import { injectTraceContext, traceIdFrom } from "./trace.js";
39
39
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
40
  /** Attaches the bearer token from the app's auth provider. */
41
41
  export function authInterceptor(cfg) {
@@ -79,7 +79,10 @@ export function errorNormalizeInterceptor(cfg) {
79
79
  return await next(req);
80
80
  }
81
81
  catch (err) {
82
- const normalized = normalizeError(err);
82
+ // traceInterceptor (innermost) has written the traceparent onto this
83
+ // same request by now: the fallback when the server sent no
84
+ // traceresponse.
85
+ const normalized = normalizeError(err, traceIdFrom(req.header.get("traceparent")));
83
86
  cfg.onError?.(normalized);
84
87
  throw normalized;
85
88
  }
@@ -156,5 +159,5 @@ export function buildRuntimeInterceptors(cfg = {}) {
156
159
  // only knob. A consumer of "@reliantlabs/forge-web-runtime/interceptors" must
157
160
  // never have to fall back to the React-typed barrel to complete the job.
158
161
  export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.js";
159
- export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
162
+ export { freshTraceparent, injectTraceContext, setTraceSampled, traceIdFrom, TRACEPARENT_RE, TRACERESPONSE_HEADER, } from "./trace.js";
160
163
  //# sourceMappingURL=interceptors.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"interceptors.js","sourceRoot":"","sources":["../src/interceptors.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// This module is also the package's SECOND ENTRY POINT, published as\n// \"@reliantlabs/forge-web-runtime/interceptors\". It is the DOM-free, React-free\n// half of the runtime: the transport layer and nothing else. Importing it\n// pulls in exactly ./errors and ./trace — no React, no @types/react, no\n// window/document. That matters for React Native, where Expo pins React 18\n// while the package's components are typed against React 19: a mobile app\n// importing the full barrel would typecheck the components it never renders\n// and fail. Everything re-exported at the bottom of this file is part of\n// this entry point's surface; keep it React-free (guarded by\n// interceptors-entry.test.ts).\n//\n// The Connect transport interceptor stack. This is the one place every\n// frontend→backend call flows through, composed once and handed to\n// createConnectTransport by the app's own connect module. Order (outermost\n// first):\n//\n// error-normalize → auth → brand headers → traceparent\n//\n// error-normalize is outermost so every caller sees a typed\n// ConnectClientError instead of a raw ConnectError.\n//\n// RETRY OWNERSHIP — read before adding a retry layer.\n// The single retry owner is the app's React Query client: it\n// structurally knows reads from writes, so it retries transient READ\n// failures and NEVER retries a mutation — no duplicate writes. Each React\n// Query retry re-runs the queryFn, which re-runs this whole interceptor\n// chain (fresh token + traceparent), so nothing is lost by not retrying\n// here. `retryInterceptor` below is therefore NOT in the default chain; it\n// stays exported (idempotency-gated) only for callers who use the raw\n// transport OUTSIDE React Query. Putting it back in the default chain would\n// stack two retry layers AND retry non-idempotent mutations — the bug this\n// split fixed.\nimport { MethodOptions_IdempotencyLevel } from \"@bufbuild/protobuf/wkt\";\n\nimport { ConnectClientError, normalizeError } from \"./errors.js\";\nimport { telemetryReady } from \"./telemetry-gate.js\";\nimport { injectTraceContext } from \"./trace.js\";\n\nimport type {\n Interceptor,\n UnaryRequest,\n StreamRequest,\n} from \"@connectrpc/connect\";\n\n/**\n * RuntimeInterceptorConfig is the seam the app wires its runtime dependencies\n * through. Everything is optional: an unconfigured chain still propagates\n * traceparent and normalizes errors.\n */\nexport interface RuntimeInterceptorConfig {\n /** Returns the current bearer token, or null when unauthenticated. */\n getToken?: () => Promise<string | null> | string | null;\n /** Returns the active brand, attached as the X-Brand header. */\n getBrand?: () => string | null | undefined;\n /** Invoked with the typed error on every failed RPC (telemetry sink). */\n onError?: (err: ConnectClientError) => void;\n /** Max transparent retries for retryable unary failures. Default 2. */\n maxRetries?: number;\n /** Base backoff in ms; grows exponentially with jitter. Default 200. */\n retryBaseMs?: number;\n}\n\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\n\n/** Attaches the bearer token from the app's auth provider. */\nexport function authInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const token = cfg.getToken ? await cfg.getToken() : null;\n if (token) {\n req.header.set(\"Authorization\", `Bearer ${token}`);\n }\n return next(req);\n };\n}\n\n/** Attaches brand context headers when the app supplies them. */\nexport function headerInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const brand = cfg.getBrand?.();\n if (brand) {\n req.header.set(\"X-Brand\", brand);\n }\n return next(req);\n };\n}\n\n/**\n * Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins\n * the same distributed trace. Always emits a valid header — see trace.ts.\n *\n * While browser telemetry is still starting it waits (bounded; see\n * telemetry-gate.ts) so the call is made through the SDK's patched fetch and\n * its traceparent names a span the browser really exports.\n */\nexport function traceInterceptor(): Interceptor {\n return (next) => async (req) => {\n await telemetryReady();\n injectTraceContext(req.header);\n return next(req);\n };\n}\n\n/** Converts any thrown failure into a typed ConnectClientError. */\nexport function errorNormalizeInterceptor(\n cfg: RuntimeInterceptorConfig,\n): Interceptor {\n return (next) => async (req) => {\n try {\n return await next(req);\n } catch (err) {\n const normalized = normalizeError(err);\n cfg.onError?.(normalized);\n throw normalized;\n }\n };\n}\n\n/**\n * retryEligible reports whether a request is SAFE to retry transparently:\n * a no-side-effects / idempotent method (declared via proto\n * `idempotency_level`), or one carrying an explicit Idempotency-Key header.\n * A plain mutation (the default IDEMPOTENCY_UNKNOWN) is never retried, so a\n * transparent retry can't produce a duplicate write.\n */\nfunction retryEligible(req: UnaryRequest | StreamRequest): boolean {\n const level = req.method.idempotency;\n if (\n level === MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS ||\n level === MethodOptions_IdempotencyLevel.IDEMPOTENT\n ) {\n return true;\n }\n return req.header.has(\"Idempotency-Key\");\n}\n\n/**\n * Transparently retries retryable failures with exponential backoff +\n * jitter — for IDEMPOTENT requests ONLY (see retryEligible). NOT part of the\n * default chain: React Query owns retries (see the retry-ownership note at\n * the top of this file). Exported for callers using the raw transport\n * outside React Query. Streaming RPCs are never retried (a partially-\n * consumed stream can't be safely replayed).\n */\nexport function retryInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n const max = cfg.maxRetries ?? 2;\n const base = cfg.retryBaseMs ?? 200;\n return (next) => async (req) => {\n if (req.stream || !retryEligible(req)) {\n return next(req);\n }\n let attempt = 0;\n for (;;) {\n try {\n return await next(req);\n } catch (err) {\n const normalized =\n err instanceof ConnectClientError ? err : normalizeError(err);\n if (attempt >= max || !normalized.retryable) {\n throw normalized;\n }\n const backoff = base * 2 ** attempt + Math.random() * base;\n await sleep(backoff);\n attempt++;\n }\n }\n };\n}\n\n/**\n * buildRuntimeInterceptors composes the full stack in the correct order.\n * Pass the result to createConnectTransport's `interceptors` option.\n *\n * retryInterceptor is deliberately NOT included — React Query is the single\n * retry owner (see the retry-ownership note at the top of this file).\n */\nexport function buildRuntimeInterceptors(\n cfg: RuntimeInterceptorConfig = {},\n): Interceptor[] {\n return [\n errorNormalizeInterceptor(cfg),\n authInterceptor(cfg),\n headerInterceptor(cfg),\n traceInterceptor(),\n ];\n}\n\n// ── The rest of the transport-layer surface ──────────────────────────────\n//\n// These belong to this entry point because the interceptor stack's contract\n// is expressed in them: errorNormalizeInterceptor THROWS ConnectClientError\n// and RuntimeInterceptorConfig.onError RECEIVES one, so a consumer cannot\n// write a handler without the type; setTraceSampled is traceInterceptor's\n// only knob. A consumer of \"@reliantlabs/forge-web-runtime/interceptors\" must\n// never have to fall back to the React-typed barrel to complete the job.\n\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n} from \"./errors.js\";\n\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n TRACEPARENT_RE,\n} from \"./trace.js\";\n"]}
1
+ {"version":3,"file":"interceptors.js","sourceRoot":"","sources":["../src/interceptors.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// This module is also the package's SECOND ENTRY POINT, published as\n// \"@reliantlabs/forge-web-runtime/interceptors\". It is the DOM-free, React-free\n// half of the runtime: the transport layer and nothing else. Importing it\n// pulls in exactly ./errors and ./trace — no React, no @types/react, no\n// window/document. That matters for React Native, where Expo pins React 18\n// while the package's components are typed against React 19: a mobile app\n// importing the full barrel would typecheck the components it never renders\n// and fail. Everything re-exported at the bottom of this file is part of\n// this entry point's surface; keep it React-free (guarded by\n// interceptors-entry.test.ts).\n//\n// The Connect transport interceptor stack. This is the one place every\n// frontend→backend call flows through, composed once and handed to\n// createConnectTransport by the app's own connect module. Order (outermost\n// first):\n//\n// error-normalize → auth → brand headers → traceparent\n//\n// error-normalize is outermost so every caller sees a typed\n// ConnectClientError instead of a raw ConnectError.\n//\n// RETRY OWNERSHIP — read before adding a retry layer.\n// The single retry owner is the app's React Query client: it\n// structurally knows reads from writes, so it retries transient READ\n// failures and NEVER retries a mutation — no duplicate writes. Each React\n// Query retry re-runs the queryFn, which re-runs this whole interceptor\n// chain (fresh token + traceparent), so nothing is lost by not retrying\n// here. `retryInterceptor` below is therefore NOT in the default chain; it\n// stays exported (idempotency-gated) only for callers who use the raw\n// transport OUTSIDE React Query. Putting it back in the default chain would\n// stack two retry layers AND retry non-idempotent mutations — the bug this\n// split fixed.\nimport { MethodOptions_IdempotencyLevel } from \"@bufbuild/protobuf/wkt\";\n\nimport { ConnectClientError, normalizeError } from \"./errors.js\";\nimport { telemetryReady } from \"./telemetry-gate.js\";\nimport { injectTraceContext, traceIdFrom } from \"./trace.js\";\n\nimport type {\n Interceptor,\n UnaryRequest,\n StreamRequest,\n} from \"@connectrpc/connect\";\n\n/**\n * RuntimeInterceptorConfig is the seam the app wires its runtime dependencies\n * through. Everything is optional: an unconfigured chain still propagates\n * traceparent and normalizes errors.\n */\nexport interface RuntimeInterceptorConfig {\n /** Returns the current bearer token, or null when unauthenticated. */\n getToken?: () => Promise<string | null> | string | null;\n /** Returns the active brand, attached as the X-Brand header. */\n getBrand?: () => string | null | undefined;\n /** Invoked with the typed error on every failed RPC (telemetry sink). */\n onError?: (err: ConnectClientError) => void;\n /** Max transparent retries for retryable unary failures. Default 2. */\n maxRetries?: number;\n /** Base backoff in ms; grows exponentially with jitter. Default 200. */\n retryBaseMs?: number;\n}\n\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\n\n/** Attaches the bearer token from the app's auth provider. */\nexport function authInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const token = cfg.getToken ? await cfg.getToken() : null;\n if (token) {\n req.header.set(\"Authorization\", `Bearer ${token}`);\n }\n return next(req);\n };\n}\n\n/** Attaches brand context headers when the app supplies them. */\nexport function headerInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const brand = cfg.getBrand?.();\n if (brand) {\n req.header.set(\"X-Brand\", brand);\n }\n return next(req);\n };\n}\n\n/**\n * Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins\n * the same distributed trace. Always emits a valid header — see trace.ts.\n *\n * While browser telemetry is still starting it waits (bounded; see\n * telemetry-gate.ts) so the call is made through the SDK's patched fetch and\n * its traceparent names a span the browser really exports.\n */\nexport function traceInterceptor(): Interceptor {\n return (next) => async (req) => {\n await telemetryReady();\n injectTraceContext(req.header);\n return next(req);\n };\n}\n\n/** Converts any thrown failure into a typed ConnectClientError. */\nexport function errorNormalizeInterceptor(\n cfg: RuntimeInterceptorConfig,\n): Interceptor {\n return (next) => async (req) => {\n try {\n return await next(req);\n } catch (err) {\n // traceInterceptor (innermost) has written the traceparent onto this\n // same request by now: the fallback when the server sent no\n // traceresponse.\n const normalized = normalizeError(\n err,\n traceIdFrom(req.header.get(\"traceparent\")),\n );\n cfg.onError?.(normalized);\n throw normalized;\n }\n };\n}\n\n/**\n * retryEligible reports whether a request is SAFE to retry transparently:\n * a no-side-effects / idempotent method (declared via proto\n * `idempotency_level`), or one carrying an explicit Idempotency-Key header.\n * A plain mutation (the default IDEMPOTENCY_UNKNOWN) is never retried, so a\n * transparent retry can't produce a duplicate write.\n */\nfunction retryEligible(req: UnaryRequest | StreamRequest): boolean {\n const level = req.method.idempotency;\n if (\n level === MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS ||\n level === MethodOptions_IdempotencyLevel.IDEMPOTENT\n ) {\n return true;\n }\n return req.header.has(\"Idempotency-Key\");\n}\n\n/**\n * Transparently retries retryable failures with exponential backoff +\n * jitter — for IDEMPOTENT requests ONLY (see retryEligible). NOT part of the\n * default chain: React Query owns retries (see the retry-ownership note at\n * the top of this file). Exported for callers using the raw transport\n * outside React Query. Streaming RPCs are never retried (a partially-\n * consumed stream can't be safely replayed).\n */\nexport function retryInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n const max = cfg.maxRetries ?? 2;\n const base = cfg.retryBaseMs ?? 200;\n return (next) => async (req) => {\n if (req.stream || !retryEligible(req)) {\n return next(req);\n }\n let attempt = 0;\n for (;;) {\n try {\n return await next(req);\n } catch (err) {\n const normalized =\n err instanceof ConnectClientError ? err : normalizeError(err);\n if (attempt >= max || !normalized.retryable) {\n throw normalized;\n }\n const backoff = base * 2 ** attempt + Math.random() * base;\n await sleep(backoff);\n attempt++;\n }\n }\n };\n}\n\n/**\n * buildRuntimeInterceptors composes the full stack in the correct order.\n * Pass the result to createConnectTransport's `interceptors` option.\n *\n * retryInterceptor is deliberately NOT included — React Query is the single\n * retry owner (see the retry-ownership note at the top of this file).\n */\nexport function buildRuntimeInterceptors(\n cfg: RuntimeInterceptorConfig = {},\n): Interceptor[] {\n return [\n errorNormalizeInterceptor(cfg),\n authInterceptor(cfg),\n headerInterceptor(cfg),\n traceInterceptor(),\n ];\n}\n\n// ── The rest of the transport-layer surface ──────────────────────────────\n//\n// These belong to this entry point because the interceptor stack's contract\n// is expressed in them: errorNormalizeInterceptor THROWS ConnectClientError\n// and RuntimeInterceptorConfig.onError RECEIVES one, so a consumer cannot\n// write a handler without the type; setTraceSampled is traceInterceptor's\n// only knob. A consumer of \"@reliantlabs/forge-web-runtime/interceptors\" must\n// never have to fall back to the React-typed barrel to complete the job.\n\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n} from \"./errors.js\";\n\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n traceIdFrom,\n TRACEPARENT_RE,\n TRACERESPONSE_HEADER,\n} from \"./trace.js\";\n"]}
@@ -0,0 +1,8 @@
1
+ /** Attribute key reportException stamps the server's trace id under. */
2
+ export declare const SERVER_TRACE_ID_ATTRIBUTE = "server.trace_id";
3
+ /**
4
+ * serverTraceIdOf returns the trace id a failed request was recorded in — the
5
+ * `traceId` a ConnectClientError carries (errors.ts) — for `value` or anything
6
+ * on its `cause` chain, or null.
7
+ */
8
+ export declare function serverTraceIdOf(value: unknown): string | null;
@@ -0,0 +1,30 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // Reading the server trace id off a thrown value, with NO dependencies.
4
+ //
5
+ // devlog.ts and error-reporter.ts are imported by every frontend, including the
6
+ // ones that never install OpenTelemetry, so they cannot import trace.ts (which
7
+ // imports @opentelemetry/api) or errors.ts (which imports @connectrpc/connect).
8
+ // They need one fact from a ConnectClientError — its traceId — and read it
9
+ // structurally here: the same shape-over-identity rule errors.ts applies to
10
+ // `instanceof`.
11
+ /** Attribute key reportException stamps the server's trace id under. */
12
+ export const SERVER_TRACE_ID_ATTRIBUTE = "server.trace_id";
13
+ const TRACE_ID_RE = /^[0-9a-f]{32}$/;
14
+ /**
15
+ * serverTraceIdOf returns the trace id a failed request was recorded in — the
16
+ * `traceId` a ConnectClientError carries (errors.ts) — for `value` or anything
17
+ * on its `cause` chain, or null.
18
+ */
19
+ export function serverTraceIdOf(value) {
20
+ let v = value;
21
+ for (let depth = 0; depth < 5 && v !== null && typeof v === "object"; depth++) {
22
+ const id = v.traceId;
23
+ if (typeof id === "string" && TRACE_ID_RE.test(id)) {
24
+ return id;
25
+ }
26
+ v = v.cause;
27
+ }
28
+ return null;
29
+ }
30
+ //# sourceMappingURL=trace-id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-id.js","sourceRoot":"","sources":["../src/trace-id.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,wEAAwE;AACxE,EAAE;AACF,gFAAgF;AAChF,+EAA+E;AAC/E,gFAAgF;AAChF,2EAA2E;AAC3E,4EAA4E;AAC5E,gBAAgB;AAEhB,wEAAwE;AACxE,MAAM,CAAC,MAAM,yBAAyB,GAAG,iBAAiB,CAAC;AAE3D,MAAM,WAAW,GAAG,gBAAgB,CAAC;AAErC;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,IAAI,CAAC,GAAY,KAAK,CAAC;IACvB,KACE,IAAI,KAAK,GAAG,CAAC,EACb,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ,EAChD,KAAK,EAAE,EACP,CAAC;QACD,MAAM,EAAE,GAAI,CAA2B,CAAC,OAAO,CAAC;QAChD,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;YACnD,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,CAAC,GAAI,CAAyB,CAAC,KAAK,CAAC;IACvC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Reading the server trace id off a thrown value, with NO dependencies.\n//\n// devlog.ts and error-reporter.ts are imported by every frontend, including the\n// ones that never install OpenTelemetry, so they cannot import trace.ts (which\n// imports @opentelemetry/api) or errors.ts (which imports @connectrpc/connect).\n// They need one fact from a ConnectClientError — its traceId — and read it\n// structurally here: the same shape-over-identity rule errors.ts applies to\n// `instanceof`.\n\n/** Attribute key reportException stamps the server's trace id under. */\nexport const SERVER_TRACE_ID_ATTRIBUTE = \"server.trace_id\";\n\nconst TRACE_ID_RE = /^[0-9a-f]{32}$/;\n\n/**\n * serverTraceIdOf returns the trace id a failed request was recorded in — the\n * `traceId` a ConnectClientError carries (errors.ts) — for `value` or anything\n * on its `cause` chain, or null.\n */\nexport function serverTraceIdOf(value: unknown): string | null {\n let v: unknown = value;\n for (\n let depth = 0;\n depth < 5 && v !== null && typeof v === \"object\";\n depth++\n ) {\n const id = (v as { traceId?: unknown }).traceId;\n if (typeof id === \"string\" && TRACE_ID_RE.test(id)) {\n return id;\n }\n v = (v as { cause?: unknown }).cause;\n }\n return null;\n}\n"]}
package/dist/trace.d.ts CHANGED
@@ -9,6 +9,21 @@ export declare function setTraceSampled(sampled: boolean): void;
9
9
  * all-zero draw (the spec forbids all-zero ids).
10
10
  */
11
11
  export declare function freshTraceparent(): string;
12
+ /**
13
+ * TRACERESPONSE_HEADER is the W3C Trace Context Level 2 response header a
14
+ * forge server sets on every Connect response, failed ones included: the
15
+ * context of ITS server span (forge/pkg/observe TraceResponseInterceptor).
16
+ * It is the authoritative answer to "which trace did this request land in" —
17
+ * the traceparent the browser sent is only a request, which a server that does
18
+ * not trust remote parents, or a proxy that restarts the trace, overrides.
19
+ */
20
+ export declare const TRACERESPONSE_HEADER = "traceresponse";
21
+ /**
22
+ * traceIdFrom extracts the trace id from a traceparent or traceresponse value
23
+ * (`00-<trace-id>-<span-id>-<flags>`), or returns null for anything malformed
24
+ * or an all-zero id.
25
+ */
26
+ export declare function traceIdFrom(value: string | null | undefined): string | null;
12
27
  /**
13
28
  * injectTraceContext writes a `traceparent` (and any accompanying W3C
14
29
  * context headers, e.g. `tracestate`) onto the outgoing request headers.
package/dist/trace.js CHANGED
@@ -79,6 +79,27 @@ export function freshTraceparent() {
79
79
  const flags = traceSampled ? "01" : "00";
80
80
  return `00-${traceId}-${spanId}-${flags}`;
81
81
  }
82
+ /**
83
+ * TRACERESPONSE_HEADER is the W3C Trace Context Level 2 response header a
84
+ * forge server sets on every Connect response, failed ones included: the
85
+ * context of ITS server span (forge/pkg/observe TraceResponseInterceptor).
86
+ * It is the authoritative answer to "which trace did this request land in" —
87
+ * the traceparent the browser sent is only a request, which a server that does
88
+ * not trust remote parents, or a proxy that restarts the trace, overrides.
89
+ */
90
+ export const TRACERESPONSE_HEADER = "traceresponse";
91
+ /**
92
+ * traceIdFrom extracts the trace id from a traceparent or traceresponse value
93
+ * (`00-<trace-id>-<span-id>-<flags>`), or returns null for anything malformed
94
+ * or an all-zero id.
95
+ */
96
+ export function traceIdFrom(value) {
97
+ if (!value || !TRACEPARENT_RE.test(value.trim())) {
98
+ return null;
99
+ }
100
+ const traceId = value.trim().split("-")[1] ?? null;
101
+ return traceId === ZERO_TRACE_ID ? null : traceId;
102
+ }
82
103
  /**
83
104
  * injectTraceContext writes a `traceparent` (and any accompanying W3C
84
105
  * context headers, e.g. `tracestate`) onto the outgoing request headers.
package/dist/trace.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"trace.js","sourceRoot":"","sources":["../src/trace.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// W3C trace-context propagation for the browser.\n//\n// The headline capability: EVERY Connect call carries a valid `traceparent`\n// header, so the backend (which runs otelconnect + otelhttp with a\n// TraceContext propagator) joins the SAME distributed trace. This works\n// WITHOUT any OTLP collector configured — traceparent generation is\n// decoupled from span export.\n//\n// - If the app opted into full OpenTelemetry tracing (src/lib/otel.ts\n// ran initTelemetry with an endpoint, registering a WebTracerProvider),\n// there is an active recording span; we inject ITS context so the\n// browser span and the server span share one trace id.\n// - Otherwise we synthesize a fresh, valid W3C traceparent from a CSPRNG.\n// The backend still roots a trace we can correlate this request to, and\n// no browser-side collector is required.\nimport { context, propagation } from \"@opentelemetry/api\";\n\n/** Matches a well-formed W3C traceparent: version-traceid-spanid-flags. */\nexport const TRACEPARENT_RE = /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/;\n\nconst ZERO_TRACE_ID = \"0\".repeat(32);\nconst ZERO_SPAN_ID = \"0\".repeat(16);\n\n/**\n * Whether a SYNTHESIZED traceparent (the no-active-span fallback below)\n * marks the trace as sampled/recorded.\n *\n * Default true — \"correlation-safe\": a browser-initiated request is always\n * recorded, so it can be joined to its server span even when the backend\n * uses head-based sampling (this module's whole promise is \"every call is\n * correlatable\"). Flip it to false via setTraceSampled() to instead DEFER to\n * the server's sampler — cheaper at high volume, but a synthesized trace the\n * server samples out leaves that browser request uncorrelated. Only affects\n * the synthesized path; when a real OpenTelemetry span is active, ITS\n * sampling flag is propagated verbatim (see injectTraceContext).\n */\nlet traceSampled = true;\n\n/** Override the sampled flag on synthesized traceparents. Default true. */\nexport function setTraceSampled(sampled: boolean): void {\n traceSampled = sampled;\n}\n\n/**\n * randomHex returns `bytes` random bytes as a lowercase hex string using a\n * CSPRNG. Falls back to Math.random only if Web Crypto is unavailable (old\n * embedded webviews); the fallback is fine for a correlation id that carries\n * no security weight.\n */\nfunction randomHex(bytes: number): string {\n const buf = new Uint8Array(bytes);\n const c = globalThis.crypto;\n if (c && typeof c.getRandomValues === \"function\") {\n c.getRandomValues(buf);\n } else {\n for (let i = 0; i < bytes; i++) {\n buf[i] = Math.floor(Math.random() * 256);\n }\n }\n let out = \"\";\n for (const b of buf) {\n out += b.toString(16).padStart(2, \"0\");\n }\n return out;\n}\n\n/**\n * freshTraceparent synthesizes a valid W3C traceparent. The trace-flags byte\n * reflects the configurable sampled flag (setTraceSampled; default 01 =\n * sampled). Trace/span ids are re-rolled on the astronomically-unlikely\n * all-zero draw (the spec forbids all-zero ids).\n */\nexport function freshTraceparent(): string {\n let traceId = randomHex(16);\n while (traceId === ZERO_TRACE_ID) {\n traceId = randomHex(16);\n }\n let spanId = randomHex(8);\n while (spanId === ZERO_SPAN_ID) {\n spanId = randomHex(8);\n }\n const flags = traceSampled ? \"01\" : \"00\";\n return `00-${traceId}-${spanId}-${flags}`;\n}\n\n/**\n * injectTraceContext writes a `traceparent` (and any accompanying W3C\n * context headers, e.g. `tracestate`) onto the outgoing request headers.\n *\n * Prefers an active OpenTelemetry span so a fully-instrumented app stitches\n * the browser and backend spans into one trace; otherwise falls back to a\n * synthesized traceparent so propagation ALWAYS happens. Idempotent per\n * request — safe to call once per attempt.\n */\nexport function injectTraceContext(header: Headers): void {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier, {\n set(c, k, v) {\n c[k] = String(v);\n },\n });\n\n if (carrier.traceparent && TRACEPARENT_RE.test(carrier.traceparent)) {\n for (const [k, v] of Object.entries(carrier)) {\n header.set(k, v);\n }\n return;\n }\n\n header.set(\"traceparent\", freshTraceparent());\n}\n"]}
1
+ {"version":3,"file":"trace.js","sourceRoot":"","sources":["../src/trace.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// W3C trace-context propagation for the browser.\n//\n// The headline capability: EVERY Connect call carries a valid `traceparent`\n// header, so the backend (which runs otelconnect + otelhttp with a\n// TraceContext propagator) joins the SAME distributed trace. This works\n// WITHOUT any OTLP collector configured — traceparent generation is\n// decoupled from span export.\n//\n// - If the app opted into full OpenTelemetry tracing (src/lib/otel.ts\n// ran initTelemetry with an endpoint, registering a WebTracerProvider),\n// there is an active recording span; we inject ITS context so the\n// browser span and the server span share one trace id.\n// - Otherwise we synthesize a fresh, valid W3C traceparent from a CSPRNG.\n// The backend still roots a trace we can correlate this request to, and\n// no browser-side collector is required.\nimport { context, propagation } from \"@opentelemetry/api\";\n\n/** Matches a well-formed W3C traceparent: version-traceid-spanid-flags. */\nexport const TRACEPARENT_RE = /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/;\n\nconst ZERO_TRACE_ID = \"0\".repeat(32);\nconst ZERO_SPAN_ID = \"0\".repeat(16);\n\n/**\n * Whether a SYNTHESIZED traceparent (the no-active-span fallback below)\n * marks the trace as sampled/recorded.\n *\n * Default true — \"correlation-safe\": a browser-initiated request is always\n * recorded, so it can be joined to its server span even when the backend\n * uses head-based sampling (this module's whole promise is \"every call is\n * correlatable\"). Flip it to false via setTraceSampled() to instead DEFER to\n * the server's sampler — cheaper at high volume, but a synthesized trace the\n * server samples out leaves that browser request uncorrelated. Only affects\n * the synthesized path; when a real OpenTelemetry span is active, ITS\n * sampling flag is propagated verbatim (see injectTraceContext).\n */\nlet traceSampled = true;\n\n/** Override the sampled flag on synthesized traceparents. Default true. */\nexport function setTraceSampled(sampled: boolean): void {\n traceSampled = sampled;\n}\n\n/**\n * randomHex returns `bytes` random bytes as a lowercase hex string using a\n * CSPRNG. Falls back to Math.random only if Web Crypto is unavailable (old\n * embedded webviews); the fallback is fine for a correlation id that carries\n * no security weight.\n */\nfunction randomHex(bytes: number): string {\n const buf = new Uint8Array(bytes);\n const c = globalThis.crypto;\n if (c && typeof c.getRandomValues === \"function\") {\n c.getRandomValues(buf);\n } else {\n for (let i = 0; i < bytes; i++) {\n buf[i] = Math.floor(Math.random() * 256);\n }\n }\n let out = \"\";\n for (const b of buf) {\n out += b.toString(16).padStart(2, \"0\");\n }\n return out;\n}\n\n/**\n * freshTraceparent synthesizes a valid W3C traceparent. The trace-flags byte\n * reflects the configurable sampled flag (setTraceSampled; default 01 =\n * sampled). Trace/span ids are re-rolled on the astronomically-unlikely\n * all-zero draw (the spec forbids all-zero ids).\n */\nexport function freshTraceparent(): string {\n let traceId = randomHex(16);\n while (traceId === ZERO_TRACE_ID) {\n traceId = randomHex(16);\n }\n let spanId = randomHex(8);\n while (spanId === ZERO_SPAN_ID) {\n spanId = randomHex(8);\n }\n const flags = traceSampled ? \"01\" : \"00\";\n return `00-${traceId}-${spanId}-${flags}`;\n}\n\n/**\n * TRACERESPONSE_HEADER is the W3C Trace Context Level 2 response header a\n * forge server sets on every Connect response, failed ones included: the\n * context of ITS server span (forge/pkg/observe TraceResponseInterceptor).\n * It is the authoritative answer to \"which trace did this request land in\" —\n * the traceparent the browser sent is only a request, which a server that does\n * not trust remote parents, or a proxy that restarts the trace, overrides.\n */\nexport const TRACERESPONSE_HEADER = \"traceresponse\";\n\n/**\n * traceIdFrom extracts the trace id from a traceparent or traceresponse value\n * (`00-<trace-id>-<span-id>-<flags>`), or returns null for anything malformed\n * or an all-zero id.\n */\nexport function traceIdFrom(value: string | null | undefined): string | null {\n if (!value || !TRACEPARENT_RE.test(value.trim())) {\n return null;\n }\n const traceId = value.trim().split(\"-\")[1] ?? null;\n return traceId === ZERO_TRACE_ID ? null : traceId;\n}\n\n/**\n * injectTraceContext writes a `traceparent` (and any accompanying W3C\n * context headers, e.g. `tracestate`) onto the outgoing request headers.\n *\n * Prefers an active OpenTelemetry span so a fully-instrumented app stitches\n * the browser and backend spans into one trace; otherwise falls back to a\n * synthesized traceparent so propagation ALWAYS happens. Idempotent per\n * request — safe to call once per attempt.\n */\nexport function injectTraceContext(header: Headers): void {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier, {\n set(c, k, v) {\n c[k] = String(v);\n },\n });\n\n if (carrier.traceparent && TRACEPARENT_RE.test(carrier.traceparent)) {\n for (const [k, v] of Object.entries(carrier)) {\n header.set(k, v);\n }\n return;\n }\n\n header.set(\"traceparent\", freshTraceparent());\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reliantlabs/forge-web-runtime",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The forge frontend runtime: Connect interceptor stack, session, error boundary, toast queue, <Resource> container, W3C trace propagation and the HyperDX browser SDK wiring (errors, console, network, traces; replay off by default). The web twin of forge/pkg.",
5
5
  "license": "Apache-2.0",
6
6
  "//repository note": "REQUIRED for `npm publish --provenance`, not decoration. npm cross-checks this against the repository in the signed provenance statement and rejects the publish with a 422 if they disagree — an absent field reads as \"\" and fails that comparison. `directory` names the subdirectory this package lives in, so the provenance points at the real source. Omitting this is what made the first 0.3.1 publish fail AFTER the tarball had been signed and written to the sigstore transparency log.",