@reliantlabs/forge-web-runtime 0.5.0 → 0.7.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
@@ -1,4 +1,4 @@
1
- import { Code } from "@connectrpc/connect";
1
+ import { Code, ConnectError } from "@connectrpc/connect";
2
2
  /**
3
3
  * FORGE_ERROR_REASON_HEADER is the Connect trailer/metadata key carrying a
4
4
  * stable, machine-readable reason code (snake_case) the backend sets for
@@ -33,15 +33,42 @@ export declare function stripServerFraming(message: string): string;
33
33
  */
34
34
  export declare function userMessage(err: unknown, fallback?: string): string;
35
35
  /**
36
- * ConnectClientError is the single typed error shape the app catches. It is
37
- * a plain Error subclass so it works with React Query, error boundaries, and
38
- * `instanceof` alike.
36
+ * codeName — the canonical wire name of a Connect code: `Code.NotFound` →
37
+ * "not_found", `Code.DeadlineExceeded` → "deadline_exceeded". The spelling
38
+ * the Connect protocol puts on the wire and in the "[code] " message prefix.
39
+ *
40
+ * (An earlier version lower-cased the enum key, which produced "notfound" —
41
+ * a name that matches nothing the server or the docs spell.)
42
+ */
43
+ export declare function codeName(code: Code): string;
44
+ /** Detail types a ConnectError carries; spelled off the base class's field. */
45
+ type ConnectErrorDetails = ConnectError["details"];
46
+ /**
47
+ * ConnectClientError is the single typed error shape the app catches.
48
+ *
49
+ * It IS a ConnectError — a subclass — so the error the runtime's
50
+ * errorNormalizeInterceptor throws still satisfies every consumer written
51
+ * against connect-es: `err instanceof ConnectError` holds, `err.code` is the
52
+ * numeric `Code`, and `metadata`, `details`, `findDetails()` and `rawMessage`
53
+ * are the original error's. What it adds is the normalized view: `codeName`,
54
+ * `status`, `retryable`, `reason` and `traceId`. An app that already branches
55
+ * on ConnectError can therefore adopt the runtime chain one call site at a
56
+ * time; nothing downgrades to `Code.Unknown` because a catch block met a type
57
+ * it did not expect.
58
+ *
59
+ * `name` stays "ConnectError". That is load-bearing: connect-es's
60
+ * `ConnectError[Symbol.hasInstance]` accepts an object whose prototype is
61
+ * exactly `ConnectError.prototype`, or one NAMED "ConnectError" with the
62
+ * right fields — a subclass instance is the second case, so any other name
63
+ * fails `instanceof ConnectError`.
64
+ *
65
+ * `message` is the server's text WITHOUT the "[code] " framing (the same as
66
+ * `rawMessage`), so it is presentable at every layer; connect-es's own
67
+ * `message` carries the prefix. Render with {@link userMessage} regardless.
39
68
  */
40
- export declare class ConnectClientError extends Error {
69
+ export declare class ConnectClientError extends ConnectError {
41
70
  /** Canonical Connect code name, e.g. "unavailable", "not_found". */
42
- readonly code: string;
43
- /** Numeric Connect code (Code enum), or undefined for non-Connect errors. */
44
- readonly connectCode?: Code;
71
+ readonly codeName: string;
45
72
  /** Approximate HTTP status, for telemetry and generic UI. */
46
73
  readonly status: number;
47
74
  /** True when a transparent retry is reasonable (transient failures). */
@@ -52,13 +79,28 @@ export declare class ConnectClientError extends Error {
52
79
  * never off `message` — message text is display-only.
53
80
  */
54
81
  readonly reason: string | null;
82
+ /**
83
+ * The trace the failed request was recorded in: the server's
84
+ * `traceresponse` when it sent one, else the trace id of the traceparent
85
+ * this runtime sent, else null (the request never left the browser). Put it
86
+ * in a bug report, a toast's "copy details", or a support ticket — it is the
87
+ * key to every server span and log line of that request.
88
+ */
89
+ readonly traceId: string | null;
90
+ /**
91
+ * `status` and `retryable` default from `code`; `reason` defaults to the
92
+ * x-forge-error-reason entry in `metadata`.
93
+ */
55
94
  constructor(args: {
95
+ /** The server's message, without "[code] " framing. */
56
96
  message: string;
57
- code: string;
58
- connectCode?: Code;
59
- status: number;
60
- retryable: boolean;
97
+ code: Code;
98
+ metadata?: HeadersInit;
99
+ details?: ConnectErrorDetails;
100
+ status?: number;
101
+ retryable?: boolean;
61
102
  reason?: string | null;
103
+ traceId?: string | null;
62
104
  cause?: unknown;
63
105
  });
64
106
  /**
@@ -67,11 +109,8 @@ export declare class ConnectClientError extends Error {
67
109
  * working when two copies of this package end up in one bundle (see
68
110
  * {@link isConnectClientErrorShape}). Without it, normalizeError's
69
111
  * pass-through guard misses an already-normalized error from the other
70
- * copy; that error's `name` is "ConnectClientError" rather than
71
- * "ConnectError", so it misses the ConnectError branch too and is re-wrapped
72
- * through the generic Error branch — silently downgrading every error in the
73
- * app to code "unknown", status 500, no connectCode and no reason.
74
- * @connectrpc/connect does the same on ConnectError, for the same reason.
112
+ * copy and re-wraps it as a fresh error. @connectrpc/connect does the same
113
+ * on ConnectError, for the same reason.
75
114
  */
76
115
  static [Symbol.hasInstance](v: unknown): boolean;
77
116
  }
@@ -80,11 +119,14 @@ export declare function isRetryableCode(code: Code): boolean;
80
119
  /**
81
120
  * normalizeError coerces any thrown value into a ConnectClientError. Already-
82
121
  * normalized errors pass through unchanged so the interceptor chain never
83
- * double-wraps.
122
+ * double-wraps. `requestTraceId` is the trace id of the traceparent the
123
+ * request carried, used when the server sent no `traceresponse`.
84
124
  *
85
125
  * Both `instanceof` checks below match on SHAPE rather than class identity —
86
126
  * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one
87
127
  * @connectrpc/connect ships — so neither branch is missed when a second copy
88
- * of either package lands in the bundle.
128
+ * of either package lands in the bundle. The ConnectClientError check comes
129
+ * first because every ConnectClientError is also a ConnectError.
89
130
  */
90
- export declare function normalizeError(err: unknown): ConnectClientError;
131
+ export declare function normalizeError(err: unknown, requestTraceId?: string | null): ConnectClientError;
132
+ export {};
package/dist/errors.js CHANGED
@@ -3,10 +3,12 @@
3
3
  // Typed client errors. Connect surfaces failures as ConnectError with a
4
4
  // numeric Code; the rest of the app should reason about a stable, typed
5
5
  // shape instead of sniffing status codes at every call site. normalizeError
6
- // maps any thrown value into a ConnectClientError carrying the canonical
7
- // code name, an HTTP-ish status, a retryability verdict, and a
6
+ // maps any thrown value into a ConnectClientError — a ConnectError subclass,
7
+ // so connect-es consumers keep working — carrying the canonical code name,
8
+ // an HTTP-ish status, a retryability verdict, the backend's reason, and a
8
9
  // human-presentable message.
9
10
  import { Code, ConnectError } from "@connectrpc/connect";
11
+ import { TRACERESPONSE_HEADER, traceIdFrom } from "./trace.js";
10
12
  /**
11
13
  * FORGE_ERROR_REASON_HEADER is the Connect trailer/metadata key carrying a
12
14
  * stable, machine-readable reason code (snake_case) the backend sets for
@@ -72,6 +74,20 @@ const HTTP_STATUS = {
72
74
  [Code.DeadlineExceeded]: 504,
73
75
  [Code.Internal]: 500,
74
76
  };
77
+ /**
78
+ * codeName — the canonical wire name of a Connect code: `Code.NotFound` →
79
+ * "not_found", `Code.DeadlineExceeded` → "deadline_exceeded". The spelling
80
+ * the Connect protocol puts on the wire and in the "[code] " message prefix.
81
+ *
82
+ * (An earlier version lower-cased the enum key, which produced "notfound" —
83
+ * a name that matches nothing the server or the docs spell.)
84
+ */
85
+ export function codeName(code) {
86
+ const key = Code[code];
87
+ if (typeof key !== "string")
88
+ return "unknown";
89
+ return key.replace(/[A-Z]/g, (c, i) => (i === 0 ? "" : "_") + c.toLowerCase());
90
+ }
75
91
  /**
76
92
  * isConnectClientErrorShape — the STRUCTURAL test behind
77
93
  * {@link ConnectClientError}'s `instanceof`.
@@ -82,6 +98,10 @@ const HTTP_STATUS = {
82
98
  * package holds two distinct ConnectClientError classes. Matching on shape
83
99
  * instead of identity is what makes the two agree: the shape is the
84
100
  * contract, the constructor is not.
101
+ *
102
+ * The shape is a ConnectError's (numeric `code`, `metadata`, `rawMessage`)
103
+ * plus the fields this class adds; `name` is "ConnectError" for the reason
104
+ * given on the class.
85
105
  */
86
106
  function isConnectClientErrorShape(v) {
87
107
  if (!(v instanceof Error))
@@ -89,22 +109,40 @@ function isConnectClientErrorShape(v) {
89
109
  if (Object.getPrototypeOf(v) === ConnectClientError.prototype)
90
110
  return true;
91
111
  const e = v;
92
- return (v.name === "ConnectClientError" &&
93
- typeof e.code === "string" &&
112
+ return (v.name === "ConnectError" &&
113
+ typeof e.code === "number" &&
114
+ typeof e.codeName === "string" &&
115
+ typeof e.rawMessage === "string" &&
94
116
  typeof e.status === "number" &&
95
117
  typeof e.retryable === "boolean" &&
96
118
  (typeof e.reason === "string" || e.reason === null));
97
119
  }
98
120
  /**
99
- * ConnectClientError is the single typed error shape the app catches. It is
100
- * a plain Error subclass so it works with React Query, error boundaries, and
101
- * `instanceof` alike.
121
+ * ConnectClientError is the single typed error shape the app catches.
122
+ *
123
+ * It IS a ConnectError — a subclass — so the error the runtime's
124
+ * errorNormalizeInterceptor throws still satisfies every consumer written
125
+ * against connect-es: `err instanceof ConnectError` holds, `err.code` is the
126
+ * numeric `Code`, and `metadata`, `details`, `findDetails()` and `rawMessage`
127
+ * are the original error's. What it adds is the normalized view: `codeName`,
128
+ * `status`, `retryable`, `reason` and `traceId`. An app that already branches
129
+ * on ConnectError can therefore adopt the runtime chain one call site at a
130
+ * time; nothing downgrades to `Code.Unknown` because a catch block met a type
131
+ * it did not expect.
132
+ *
133
+ * `name` stays "ConnectError". That is load-bearing: connect-es's
134
+ * `ConnectError[Symbol.hasInstance]` accepts an object whose prototype is
135
+ * exactly `ConnectError.prototype`, or one NAMED "ConnectError" with the
136
+ * right fields — a subclass instance is the second case, so any other name
137
+ * fails `instanceof ConnectError`.
138
+ *
139
+ * `message` is the server's text WITHOUT the "[code] " framing (the same as
140
+ * `rawMessage`), so it is presentable at every layer; connect-es's own
141
+ * `message` carries the prefix. Render with {@link userMessage} regardless.
102
142
  */
103
- export class ConnectClientError extends Error {
143
+ export class ConnectClientError extends ConnectError {
104
144
  /** Canonical Connect code name, e.g. "unavailable", "not_found". */
105
- code;
106
- /** Numeric Connect code (Code enum), or undefined for non-Connect errors. */
107
- connectCode;
145
+ codeName;
108
146
  /** Approximate HTTP status, for telemetry and generic UI. */
109
147
  status;
110
148
  /** True when a transparent retry is reasonable (transient failures). */
@@ -115,14 +153,34 @@ export class ConnectClientError extends Error {
115
153
  * never off `message` — message text is display-only.
116
154
  */
117
155
  reason;
156
+ /**
157
+ * The trace the failed request was recorded in: the server's
158
+ * `traceresponse` when it sent one, else the trace id of the traceparent
159
+ * this runtime sent, else null (the request never left the browser). Put it
160
+ * in a bug report, a toast's "copy details", or a support ticket — it is the
161
+ * key to every server span and log line of that request.
162
+ */
163
+ traceId;
164
+ /**
165
+ * `status` and `retryable` default from `code`; `reason` defaults to the
166
+ * x-forge-error-reason entry in `metadata`.
167
+ */
118
168
  constructor(args) {
119
- super(args.message, { cause: args.cause });
120
- this.name = "ConnectClientError";
121
- this.code = args.code;
122
- this.connectCode = args.connectCode;
123
- this.status = args.status;
124
- this.retryable = args.retryable;
125
- this.reason = args.reason ?? null;
169
+ super(args.message, args.code, args.metadata, undefined, args.cause);
170
+ // ConnectError's constructor takes only OUTGOING details (a server's);
171
+ // a client error carries the INCOMING ones it received, so they are
172
+ // assigned rather than passed. Its name, "ConnectError", is kept (above).
173
+ if (args.details)
174
+ this.details = args.details;
175
+ this.message = args.message;
176
+ this.codeName = codeName(args.code);
177
+ this.status = args.status ?? HTTP_STATUS[args.code] ?? 500;
178
+ this.retryable = args.retryable ?? isRetryableCode(args.code);
179
+ this.reason =
180
+ args.reason !== undefined
181
+ ? args.reason
182
+ : this.metadata.get(FORGE_ERROR_REASON_HEADER) || null;
183
+ this.traceId = args.traceId ?? null;
126
184
  }
127
185
  /**
128
186
  * `instanceof` matches on SHAPE, not on class identity — so every check
@@ -130,11 +188,8 @@ export class ConnectClientError extends Error {
130
188
  * working when two copies of this package end up in one bundle (see
131
189
  * {@link isConnectClientErrorShape}). Without it, normalizeError's
132
190
  * pass-through guard misses an already-normalized error from the other
133
- * copy; that error's `name` is "ConnectClientError" rather than
134
- * "ConnectError", so it misses the ConnectError branch too and is re-wrapped
135
- * through the generic Error branch — silently downgrading every error in the
136
- * app to code "unknown", status 500, no connectCode and no reason.
137
- * @connectrpc/connect does the same on ConnectError, for the same reason.
191
+ * copy and re-wraps it as a fresh error. @connectrpc/connect does the same
192
+ * on ConnectError, for the same reason.
138
193
  */
139
194
  static [Symbol.hasInstance](v) {
140
195
  return isConnectClientErrorShape(v);
@@ -147,14 +202,16 @@ export function isRetryableCode(code) {
147
202
  /**
148
203
  * normalizeError coerces any thrown value into a ConnectClientError. Already-
149
204
  * normalized errors pass through unchanged so the interceptor chain never
150
- * double-wraps.
205
+ * double-wraps. `requestTraceId` is the trace id of the traceparent the
206
+ * request carried, used when the server sent no `traceresponse`.
151
207
  *
152
208
  * Both `instanceof` checks below match on SHAPE rather than class identity —
153
209
  * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one
154
210
  * @connectrpc/connect ships — so neither branch is missed when a second copy
155
- * of either package lands in the bundle.
211
+ * of either package lands in the bundle. The ConnectClientError check comes
212
+ * first because every ConnectClientError is also a ConnectError.
156
213
  */
157
- export function normalizeError(err) {
214
+ export function normalizeError(err, requestTraceId = null) {
158
215
  if (err instanceof ConnectClientError) {
159
216
  return err;
160
217
  }
@@ -163,16 +220,29 @@ export function normalizeError(err) {
163
220
  // Strip the "[code] " prefix so the message is user-presentable at
164
221
  // every layer.
165
222
  message: stripServerFraming(err.rawMessage || err.message),
166
- code: Code[err.code]?.toLowerCase() ?? "unknown",
167
- connectCode: err.code,
168
- status: HTTP_STATUS[err.code] ?? 500,
169
- retryable: isRetryableCode(err.code),
170
- // Stable machine reason from backend metadata (backend owns the set).
171
- reason: err.metadata.get(FORGE_ERROR_REASON_HEADER) || null,
223
+ code: err.code,
224
+ // The original metadata and details ride along, so `findDetails()` and
225
+ // `metadata.get(...)` answer exactly as they did on the raw error. The
226
+ // reason (backend-owned) is read from that metadata by the constructor.
227
+ metadata: err.metadata,
228
+ details: err.details,
229
+ // The server's own span when it answered; the trace this runtime asked
230
+ // for when it did not (a proxy error, an older server).
231
+ traceId: traceIdFrom(err.metadata.get(TRACERESPONSE_HEADER)) ?? requestTraceId,
172
232
  cause: err,
173
233
  });
174
234
  }
175
235
  if (err instanceof Error) {
236
+ // An aborted or timed-out fetch is a cancellation, as ConnectError.from
237
+ // reads it.
238
+ if (err.name === "AbortError" || err.name === "TimeoutError") {
239
+ return new ConnectClientError({
240
+ message: stripServerFraming(err.message),
241
+ code: Code.Canceled,
242
+ traceId: requestTraceId,
243
+ cause: err,
244
+ });
245
+ }
176
246
  // A network fault (fetch rejected: offline, DNS, CORS preflight, TLS)
177
247
  // surfaces as a TypeError — that, and only that, is a safe transparent
178
248
  // retry. A generic Error is NOT blanket-retried (blanket-retrying every
@@ -180,16 +250,17 @@ export function normalizeError(err) {
180
250
  const networkFault = err instanceof TypeError;
181
251
  return new ConnectClientError({
182
252
  message: stripServerFraming(err.message),
183
- code: networkFault ? "unavailable" : "unknown",
253
+ code: networkFault ? Code.Unavailable : Code.Unknown,
184
254
  status: networkFault ? 503 : 500,
185
255
  retryable: networkFault,
186
256
  reason: null,
257
+ traceId: requestTraceId,
187
258
  cause: err,
188
259
  });
189
260
  }
190
261
  return new ConnectClientError({
191
262
  message: typeof err === "string" ? stripServerFraming(err) : "Unknown error",
192
- code: "unknown",
263
+ code: Code.Unknown,
193
264
  status: 500,
194
265
  retryable: false,
195
266
  reason: null,
@@ -1 +1 @@
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\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 — a ConnectError subclass,\n// so connect-es consumers keep working — carrying the canonical code name,\n// an HTTP-ish status, a retryability verdict, the backend's reason, 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 * codeName — the canonical wire name of a Connect code: `Code.NotFound` →\n * \"not_found\", `Code.DeadlineExceeded` → \"deadline_exceeded\". The spelling\n * the Connect protocol puts on the wire and in the \"[code] \" message prefix.\n *\n * (An earlier version lower-cased the enum key, which produced \"notfound\" —\n * a name that matches nothing the server or the docs spell.)\n */\nexport function codeName(code: Code): string {\n const key = Code[code];\n if (typeof key !== \"string\") return \"unknown\";\n return key.replace(\n /[A-Z]/g,\n (c, i: number) => (i === 0 ? \"\" : \"_\") + c.toLowerCase(),\n );\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 *\n * The shape is a ConnectError's (numeric `code`, `metadata`, `rawMessage`)\n * plus the fields this class adds; `name` is \"ConnectError\" for the reason\n * given on the class.\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 === \"ConnectError\" &&\n typeof e.code === \"number\" &&\n typeof e.codeName === \"string\" &&\n typeof e.rawMessage === \"string\" &&\n typeof e.status === \"number\" &&\n typeof e.retryable === \"boolean\" &&\n (typeof e.reason === \"string\" || e.reason === null)\n );\n}\n\n/** Detail types a ConnectError carries; spelled off the base class's field. */\ntype ConnectErrorDetails = ConnectError[\"details\"];\n\n/**\n * ConnectClientError is the single typed error shape the app catches.\n *\n * It IS a ConnectError — a subclass — so the error the runtime's\n * errorNormalizeInterceptor throws still satisfies every consumer written\n * against connect-es: `err instanceof ConnectError` holds, `err.code` is the\n * numeric `Code`, and `metadata`, `details`, `findDetails()` and `rawMessage`\n * are the original error's. What it adds is the normalized view: `codeName`,\n * `status`, `retryable`, `reason` and `traceId`. An app that already branches\n * on ConnectError can therefore adopt the runtime chain one call site at a\n * time; nothing downgrades to `Code.Unknown` because a catch block met a type\n * it did not expect.\n *\n * `name` stays \"ConnectError\". That is load-bearing: connect-es's\n * `ConnectError[Symbol.hasInstance]` accepts an object whose prototype is\n * exactly `ConnectError.prototype`, or one NAMED \"ConnectError\" with the\n * right fields — a subclass instance is the second case, so any other name\n * fails `instanceof ConnectError`.\n *\n * `message` is the server's text WITHOUT the \"[code] \" framing (the same as\n * `rawMessage`), so it is presentable at every layer; connect-es's own\n * `message` carries the prefix. Render with {@link userMessage} regardless.\n */\nexport class ConnectClientError extends ConnectError {\n /** Canonical Connect code name, e.g. \"unavailable\", \"not_found\". */\n readonly codeName: string;\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 /**\n * `status` and `retryable` default from `code`; `reason` defaults to the\n * x-forge-error-reason entry in `metadata`.\n */\n constructor(args: {\n /** The server's message, without \"[code] \" framing. */\n message: string;\n code: Code;\n metadata?: HeadersInit;\n details?: ConnectErrorDetails;\n status?: number;\n retryable?: boolean;\n reason?: string | null;\n traceId?: string | null;\n cause?: unknown;\n }) {\n super(args.message, args.code, args.metadata, undefined, args.cause);\n // ConnectError's constructor takes only OUTGOING details (a server's);\n // a client error carries the INCOMING ones it received, so they are\n // assigned rather than passed. Its name, \"ConnectError\", is kept (above).\n if (args.details) this.details = args.details;\n this.message = args.message;\n this.codeName = codeName(args.code);\n this.status = args.status ?? HTTP_STATUS[args.code] ?? 500;\n this.retryable = args.retryable ?? isRetryableCode(args.code);\n this.reason =\n args.reason !== undefined\n ? args.reason\n : this.metadata.get(FORGE_ERROR_REASON_HEADER) || 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 and re-wraps it as a fresh error. @connectrpc/connect does the same\n * 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. The ConnectClientError check comes\n * first because every ConnectClientError is also a ConnectError.\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: err.code,\n // The original metadata and details ride along, so `findDetails()` and\n // `metadata.get(...)` answer exactly as they did on the raw error. The\n // reason (backend-owned) is read from that metadata by the constructor.\n metadata: err.metadata,\n details: err.details,\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 // An aborted or timed-out fetch is a cancellation, as ConnectError.from\n // reads it.\n if (err.name === \"AbortError\" || err.name === \"TimeoutError\") {\n return new ConnectClientError({\n message: stripServerFraming(err.message),\n code: Code.Canceled,\n traceId: requestTraceId,\n cause: err,\n });\n }\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 ? Code.Unavailable : Code.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: Code.Unknown,\n status: 500,\n retryable: false,\n reason: null,\n cause: err,\n });\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { authInterceptor, buildRuntimeInterceptors, errorNormalizeInterceptor, h
4
4
  * Typed client errors, and the backend-framing strippers every surface that
5
5
  * SHOWS an error goes through.
6
6
  */
7
- export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, stripServerFraming, userMessage, } from "./errors.js";
7
+ export { codeName, ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, stripServerFraming, userMessage, } from "./errors.js";
8
8
  /**
9
9
  * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute
10
10
  * URLs handed to external systems that round-trip back into the app.
@@ -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
@@ -22,7 +22,7 @@ export { authInterceptor, buildRuntimeInterceptors, errorNormalizeInterceptor, h
22
22
  * Typed client errors, and the backend-framing strippers every surface that
23
23
  * SHOWS an error goes through.
24
24
  */
25
- export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, stripServerFraming, userMessage, } from "./errors.js";
25
+ export { codeName, ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, stripServerFraming, userMessage, } from "./errors.js";
26
26
  /**
27
27
  * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute
28
28
  * URLs handed to external systems that round-trip back into the app.
@@ -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,QAAQ,EACR,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 codeName,\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"]}
@@ -49,5 +49,5 @@ export declare function retryInterceptor(cfg: RuntimeInterceptorConfig): Interce
49
49
  * retry owner (see the retry-ownership note at the top of this file).
50
50
  */
51
51
  export declare function buildRuntimeInterceptors(cfg?: RuntimeInterceptorConfig): Interceptor[];
52
- export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.js";
53
- export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
52
+ export { codeName, ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.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
  }
@@ -155,6 +158,6 @@ export function buildRuntimeInterceptors(cfg = {}) {
155
158
  // write a handler without the type; setTraceSampled is traceInterceptor's
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
- export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.js";
159
- export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
161
+ export { codeName, ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.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 codeName,\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"]}
@@ -56,7 +56,7 @@ export type ServiceMutationHook<Schema extends DescMessage, Response> = (options
56
56
  * Query's default `Error`. That type is declared on the OPTIONS parameter
57
57
  * because React Query takes the result's error type from the same TError it
58
58
  * reads there, so `query.error` is a ConnectClientError at every call site
59
- * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`
59
+ * with nothing to pass. Typing it as `Error` hid `.reason`, `.codeName`, `.status`
60
60
  * and `.retryable`, and left `err.message.includes(...)` as the only
61
61
  * expression that compiled — the one thing the error contract forbids.
62
62
  */
@@ -79,7 +79,7 @@ export function serviceKeys(service) {
79
79
  * Query's default `Error`. That type is declared on the OPTIONS parameter
80
80
  * because React Query takes the result's error type from the same TError it
81
81
  * reads there, so `query.error` is a ConnectClientError at every call site
82
- * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`
82
+ * with nothing to pass. Typing it as `Error` hid `.reason`, `.codeName`, `.status`
83
83
  * and `.retryable`, and left `err.message.includes(...)` as the only
84
84
  * expression that compiled — the one thing the error contract forbids.
85
85
  */
@@ -1 +1 @@
1
- {"version":3,"file":"service-hooks.js","sourceRoot":"","sources":["../src/service-hooks.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,sEAAsE;AACtE,EAAE;AACF,8EAA8E;AAC9E,6EAA6E;AAC7E,uEAAuE;AACvE,6EAA6E;AAC7E,+EAA+E;AAC/E,yEAAyE;AACzE,gEAAgE;AAChE,EAAE;AACF,8EAA8E;AAC9E,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,qEAAqE;AACrE,qEAAqE;AACrE,+EAA+E;AAC/E,4EAA4E;AAC5E,8EAA8E;AAC9E,sEAAsE;AACtE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,wEAAwE;AACxE,6EAA6E;AAC7E,4EAA4E;AAC5E,mCAAmC;AACnC,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,uDAAuD;AACvD,yEAAyE;AACzE,8EAA8E;AAC9E,8EAA8E;AAC9E,OAAO,EACL,MAAM,EACN,MAAM,GAGP,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,WAAW,EACX,QAAQ,EACR,cAAc,GAKf,MAAM,uBAAuB,CAAC;AAW/B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,UAAU,CACxB,MAAc,EACd,GAA6B;IAE7B,OAAO,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAoBD;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO;QACL,GAAG,EAAE,CAAC,OAAO,CAAU;QACvB,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,CAAU;QAClD,KAAK,EACH,CACE,MAAc,EACd,MAAc,EACd,WAAoB,EACpB,EAAE,CACJ,CAAC,GAA6B,EAAmB,EAAE,CACjD,WAAW,KAAK,SAAS;YACvB,CAAC,CAAE,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;YACvD,CAAC,CAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;KAC3E,CAAC;AACJ,CAAC;AAeD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAC7B,MAA0D,EAC1D,IAA0D;IAE1D,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,EAAE,CACtB,QAAQ,CAAC;QACP,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC;QACrB,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;QACxB,GAAG,OAAO;KACX,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,kBAAkB,CAChC,aAA8B,EAC9B,IAA0D;IAE1D,OAAO,CAAC,OAAO,EAAE,EAAE;QACjB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;QACrC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,IAAI,EAAE,CAAC;QAC7C,OAAO,WAAW,CAAC;YACjB,UAAU,EAAE,CAAC,GAA6B,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;YACxD,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE;gBACrB,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,aAAa,EAAE,CAAC,CAAC;gBAC3D,SAAS,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;YACvB,CAAC;YACD,GAAG,IAAI;SACR,CAAC,CAAC;IACL,CAAC,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Every export below is a React hook or builds one, so this module belongs in\n// the CLIENT graph. Without the directive the Next.js App Router compiles it\n// into the SERVER graph, where it binds a SECOND @tanstack/react-query\n// instance and every generated hook throws \"No QueryClient set\" at runtime —\n// invisible to tsc, eslint AND `next build`. published-surface.test.ts asserts\n// the directive survives into dist/. It is NOT applied package-wide: the\n// ./interceptors subpath exists to be importable without React.\n//\n// The React Query wrapper machinery behind forge's generated per-service hook\n// files (`src/hooks/<svc>-service-hooks_gen.ts`).\n//\n// WHAT WAS HERE BEFORE. Each generated file re-emitted the same three things\n// per RPC: a query-key entry, a useQuery or useMutation wrapper, and — for\n// mutations — the entity-scoped invalidateQueries plus the onSuccess\n// composition, with the reasoning for each re-inlined as comments. A\n// five-service project rendered five copies of machinery that differed only in\n// RPC names and message types. This module IS that machinery, written once.\n// The generated file keeps exactly what varies per RPC: the name, the request\n// schema, the response type, query-vs-mutation, and the entity scope.\n//\n// WHY A SUBPATH AND NOT THE BARREL. This module imports @tanstack/react-query.\n// Every browser frontend forge scaffolds depends on it — but the barrel is\n// also what a consumer imports for interceptors and errors alone, and\n// anchoring React Query there would make a non-React consumer resolve a\n// package it never installed. Same reasoning as ./mock-transport and ./otel:\n// an engine with its own dependency footprint gets its own entry point, and\n// React Query is an OPTIONAL peer.\n//\n// WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts — the\n// app-wide MutationCache in src/lib/query-client.ts is the single error-toast\n// chokepoint, and pages that render errors inline pass\n// `meta: { silenceErrorToast: true }`. It never defaults success-side UX\n// either: \"CreateOrder succeeded\" leaks RPC names into the UI, so the toast /\n// redirect / inline confirmation stays the call site's via options.onSuccess.\nimport {\n create,\n toJson,\n type DescMessage,\n type MessageInitShape,\n} from \"@bufbuild/protobuf\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationOptions,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\n\nimport type { ConnectClientError } from \"./errors.js\";\n\n/**\n * A generated query key. The tuple is [service, entity?, method,\n * protojson(req)] for a method key and [service] / [service, entity] for the\n * broader scopes.\n */\nexport type ServiceQueryKey = readonly unknown[];\n\n/**\n * requestKey projects a request init into protojson for use inside a query\n * key. Two reasons this is not the raw request object:\n *\n * - React Query's default hasher is JSON.stringify, which THROWS on bigint\n * fields (any proto int64) — the raw message crashes the cache.\n * - protojson normalizes representations, so { id: 1 } and { id: \"1\" } land\n * in ONE cache entry instead of silently splitting the cache.\n *\n * Exported because a hand-written hook that wants to share a generated hook's\n * cache entry has to hash its request the identical way.\n */\nexport function requestKey<Schema extends DescMessage>(\n schema: Schema,\n req: MessageInitShape<Schema>,\n): unknown {\n return toJson(schema, create(schema, req));\n}\n\n/** The three key-scope builders a generated key factory is assembled from. */\nexport interface ServiceKeyBuilders {\n /** Every query on this service: [service]. */\n readonly all: readonly [string];\n /** Every query for one CRUD entity: [service, entity]. Mutations invalidate this. */\n entity(name: string): readonly [string, string];\n /**\n * One query: [service, entity?, method, protojson(req)]. Returns the\n * per-request key FUNCTION, which is what both the query hook and any\n * call-site prefetch/setQueryData use.\n */\n query<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): (req: MessageInitShape<Schema>) => ServiceQueryKey;\n}\n\n/**\n * serviceKeys returns the builders for one service's query-key factory.\n *\n * Cache-key semantics are a compatibility contract, not an implementation\n * detail: an existing app's\n * `invalidateQueries({ queryKey: orderServiceKeys.order })` has to keep\n * matching the keys its queries were registered under across a version bump.\n * So the tuple order and contents produced here are fixed, and identical to\n * what forge previously inlined into every generated hook file.\n */\nexport function serviceKeys(service: string): ServiceKeyBuilders {\n return {\n all: [service] as const,\n entity: (name: string) => [service, name] as const,\n query:\n <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ) =>\n (req: MessageInitShape<Schema>): ServiceQueryKey =>\n entityScope === undefined\n ? ([service, method, requestKey(schema, req)] as const)\n : ([service, entityScope, method, requestKey(schema, req)] as const),\n };\n}\n\n/** A generated query hook: `useGetOrder(req, options?)`. */\nexport type ServiceQueryHook<Schema extends DescMessage, Response> = (\n req: MessageInitShape<Schema>,\n options?: Partial<UseQueryOptions<Response, ConnectClientError>>,\n) => UseQueryResult<Response, ConnectClientError>;\n\n/** A generated mutation hook: `useCreateOrder(options?)`. */\nexport type ServiceMutationHook<Schema extends DescMessage, Response> = (\n options?: Partial<\n UseMutationOptions<Response, ConnectClientError, MessageInitShape<Schema>>\n >,\n) => UseMutationResult<Response, ConnectClientError, MessageInitShape<Schema>>;\n\n/**\n * createQueryHook builds one useQuery wrapper.\n *\n * The signature it returns is the one call sites already use — `(req,\n * options?)`, with the error typed as ConnectClientError rather than React\n * Query's default `Error`. That type is declared on the OPTIONS parameter\n * because React Query takes the result's error type from the same TError it\n * reads there, so `query.error` is a ConnectClientError at every call site\n * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`\n * and `.retryable`, and left `err.message.includes(...)` as the only\n * expression that compiled — the one thing the error contract forbids.\n */\nexport function createQueryHook<Schema extends DescMessage, Response>(\n keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceQueryHook<Schema, Response> {\n return (req, options) =>\n useQuery({\n queryKey: keyFor(req),\n queryFn: () => call(req),\n ...options,\n });\n}\n\n/**\n * createMutationHook builds one useMutation wrapper with scoped invalidation.\n *\n * invalidateKey is the entity scope when the RPC maps to a CRUD entity, and\n * the whole-service scope otherwise — a bespoke mutation may touch anything,\n * so over-invalidating is the safe default there.\n *\n * The invalidation is composed BEFORE the caller's onSuccess and is never\n * replaced by it: onSuccess is destructured out and re-invoked after the\n * invalidate, while the rest of the options object is spread separately. A\n * caller passing onSuccess therefore ADDS behaviour, rather than silently\n * disabling the cache refresh — which is exactly what a plain `...options`\n * spread over a default onSuccess would do.\n */\nexport function createMutationHook<Schema extends DescMessage, Response>(\n invalidateKey: ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceMutationHook<Schema, Response> {\n return (options) => {\n const queryClient = useQueryClient();\n const { onSuccess, ...rest } = options ?? {};\n return useMutation({\n mutationFn: (req: MessageInitShape<Schema>) => call(req),\n onSuccess: (...args) => {\n queryClient.invalidateQueries({ queryKey: invalidateKey });\n onSuccess?.(...args);\n },\n ...rest,\n });\n };\n}\n"]}
1
+ {"version":3,"file":"service-hooks.js","sourceRoot":"","sources":["../src/service-hooks.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,sEAAsE;AACtE,EAAE;AACF,8EAA8E;AAC9E,6EAA6E;AAC7E,uEAAuE;AACvE,6EAA6E;AAC7E,+EAA+E;AAC/E,yEAAyE;AACzE,gEAAgE;AAChE,EAAE;AACF,8EAA8E;AAC9E,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,qEAAqE;AACrE,qEAAqE;AACrE,+EAA+E;AAC/E,4EAA4E;AAC5E,8EAA8E;AAC9E,sEAAsE;AACtE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,wEAAwE;AACxE,6EAA6E;AAC7E,4EAA4E;AAC5E,mCAAmC;AACnC,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,uDAAuD;AACvD,yEAAyE;AACzE,8EAA8E;AAC9E,8EAA8E;AAC9E,OAAO,EACL,MAAM,EACN,MAAM,GAGP,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,WAAW,EACX,QAAQ,EACR,cAAc,GAKf,MAAM,uBAAuB,CAAC;AAW/B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,UAAU,CACxB,MAAc,EACd,GAA6B;IAE7B,OAAO,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAoBD;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO;QACL,GAAG,EAAE,CAAC,OAAO,CAAU;QACvB,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,CAAU;QAClD,KAAK,EACH,CACE,MAAc,EACd,MAAc,EACd,WAAoB,EACpB,EAAE,CACJ,CAAC,GAA6B,EAAmB,EAAE,CACjD,WAAW,KAAK,SAAS;YACvB,CAAC,CAAE,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;YACvD,CAAC,CAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;KAC3E,CAAC;AACJ,CAAC;AAeD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAC7B,MAA0D,EAC1D,IAA0D;IAE1D,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,EAAE,CACtB,QAAQ,CAAC;QACP,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC;QACrB,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;QACxB,GAAG,OAAO;KACX,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,kBAAkB,CAChC,aAA8B,EAC9B,IAA0D;IAE1D,OAAO,CAAC,OAAO,EAAE,EAAE;QACjB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;QACrC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,IAAI,EAAE,CAAC;QAC7C,OAAO,WAAW,CAAC;YACjB,UAAU,EAAE,CAAC,GAA6B,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;YACxD,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE;gBACrB,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,aAAa,EAAE,CAAC,CAAC;gBAC3D,SAAS,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;YACvB,CAAC;YACD,GAAG,IAAI;SACR,CAAC,CAAC;IACL,CAAC,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Every export below is a React hook or builds one, so this module belongs in\n// the CLIENT graph. Without the directive the Next.js App Router compiles it\n// into the SERVER graph, where it binds a SECOND @tanstack/react-query\n// instance and every generated hook throws \"No QueryClient set\" at runtime —\n// invisible to tsc, eslint AND `next build`. published-surface.test.ts asserts\n// the directive survives into dist/. It is NOT applied package-wide: the\n// ./interceptors subpath exists to be importable without React.\n//\n// The React Query wrapper machinery behind forge's generated per-service hook\n// files (`src/hooks/<svc>-service-hooks_gen.ts`).\n//\n// WHAT WAS HERE BEFORE. Each generated file re-emitted the same three things\n// per RPC: a query-key entry, a useQuery or useMutation wrapper, and — for\n// mutations — the entity-scoped invalidateQueries plus the onSuccess\n// composition, with the reasoning for each re-inlined as comments. A\n// five-service project rendered five copies of machinery that differed only in\n// RPC names and message types. This module IS that machinery, written once.\n// The generated file keeps exactly what varies per RPC: the name, the request\n// schema, the response type, query-vs-mutation, and the entity scope.\n//\n// WHY A SUBPATH AND NOT THE BARREL. This module imports @tanstack/react-query.\n// Every browser frontend forge scaffolds depends on it — but the barrel is\n// also what a consumer imports for interceptors and errors alone, and\n// anchoring React Query there would make a non-React consumer resolve a\n// package it never installed. Same reasoning as ./mock-transport and ./otel:\n// an engine with its own dependency footprint gets its own entry point, and\n// React Query is an OPTIONAL peer.\n//\n// WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts — the\n// app-wide MutationCache in src/lib/query-client.ts is the single error-toast\n// chokepoint, and pages that render errors inline pass\n// `meta: { silenceErrorToast: true }`. It never defaults success-side UX\n// either: \"CreateOrder succeeded\" leaks RPC names into the UI, so the toast /\n// redirect / inline confirmation stays the call site's via options.onSuccess.\nimport {\n create,\n toJson,\n type DescMessage,\n type MessageInitShape,\n} from \"@bufbuild/protobuf\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationOptions,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\n\nimport type { ConnectClientError } from \"./errors.js\";\n\n/**\n * A generated query key. The tuple is [service, entity?, method,\n * protojson(req)] for a method key and [service] / [service, entity] for the\n * broader scopes.\n */\nexport type ServiceQueryKey = readonly unknown[];\n\n/**\n * requestKey projects a request init into protojson for use inside a query\n * key. Two reasons this is not the raw request object:\n *\n * - React Query's default hasher is JSON.stringify, which THROWS on bigint\n * fields (any proto int64) — the raw message crashes the cache.\n * - protojson normalizes representations, so { id: 1 } and { id: \"1\" } land\n * in ONE cache entry instead of silently splitting the cache.\n *\n * Exported because a hand-written hook that wants to share a generated hook's\n * cache entry has to hash its request the identical way.\n */\nexport function requestKey<Schema extends DescMessage>(\n schema: Schema,\n req: MessageInitShape<Schema>,\n): unknown {\n return toJson(schema, create(schema, req));\n}\n\n/** The three key-scope builders a generated key factory is assembled from. */\nexport interface ServiceKeyBuilders {\n /** Every query on this service: [service]. */\n readonly all: readonly [string];\n /** Every query for one CRUD entity: [service, entity]. Mutations invalidate this. */\n entity(name: string): readonly [string, string];\n /**\n * One query: [service, entity?, method, protojson(req)]. Returns the\n * per-request key FUNCTION, which is what both the query hook and any\n * call-site prefetch/setQueryData use.\n */\n query<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): (req: MessageInitShape<Schema>) => ServiceQueryKey;\n}\n\n/**\n * serviceKeys returns the builders for one service's query-key factory.\n *\n * Cache-key semantics are a compatibility contract, not an implementation\n * detail: an existing app's\n * `invalidateQueries({ queryKey: orderServiceKeys.order })` has to keep\n * matching the keys its queries were registered under across a version bump.\n * So the tuple order and contents produced here are fixed, and identical to\n * what forge previously inlined into every generated hook file.\n */\nexport function serviceKeys(service: string): ServiceKeyBuilders {\n return {\n all: [service] as const,\n entity: (name: string) => [service, name] as const,\n query:\n <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ) =>\n (req: MessageInitShape<Schema>): ServiceQueryKey =>\n entityScope === undefined\n ? ([service, method, requestKey(schema, req)] as const)\n : ([service, entityScope, method, requestKey(schema, req)] as const),\n };\n}\n\n/** A generated query hook: `useGetOrder(req, options?)`. */\nexport type ServiceQueryHook<Schema extends DescMessage, Response> = (\n req: MessageInitShape<Schema>,\n options?: Partial<UseQueryOptions<Response, ConnectClientError>>,\n) => UseQueryResult<Response, ConnectClientError>;\n\n/** A generated mutation hook: `useCreateOrder(options?)`. */\nexport type ServiceMutationHook<Schema extends DescMessage, Response> = (\n options?: Partial<\n UseMutationOptions<Response, ConnectClientError, MessageInitShape<Schema>>\n >,\n) => UseMutationResult<Response, ConnectClientError, MessageInitShape<Schema>>;\n\n/**\n * createQueryHook builds one useQuery wrapper.\n *\n * The signature it returns is the one call sites already use — `(req,\n * options?)`, with the error typed as ConnectClientError rather than React\n * Query's default `Error`. That type is declared on the OPTIONS parameter\n * because React Query takes the result's error type from the same TError it\n * reads there, so `query.error` is a ConnectClientError at every call site\n * with nothing to pass. Typing it as `Error` hid `.reason`, `.codeName`, `.status`\n * and `.retryable`, and left `err.message.includes(...)` as the only\n * expression that compiled — the one thing the error contract forbids.\n */\nexport function createQueryHook<Schema extends DescMessage, Response>(\n keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceQueryHook<Schema, Response> {\n return (req, options) =>\n useQuery({\n queryKey: keyFor(req),\n queryFn: () => call(req),\n ...options,\n });\n}\n\n/**\n * createMutationHook builds one useMutation wrapper with scoped invalidation.\n *\n * invalidateKey is the entity scope when the RPC maps to a CRUD entity, and\n * the whole-service scope otherwise — a bespoke mutation may touch anything,\n * so over-invalidating is the safe default there.\n *\n * The invalidation is composed BEFORE the caller's onSuccess and is never\n * replaced by it: onSuccess is destructured out and re-invoked after the\n * invalidate, while the rest of the options object is spread separately. A\n * caller passing onSuccess therefore ADDS behaviour, rather than silently\n * disabling the cache refresh — which is exactly what a plain `...options`\n * spread over a default onSuccess would do.\n */\nexport function createMutationHook<Schema extends DescMessage, Response>(\n invalidateKey: ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceMutationHook<Schema, Response> {\n return (options) => {\n const queryClient = useQueryClient();\n const { onSuccess, ...rest } = options ?? {};\n return useMutation({\n mutationFn: (req: MessageInitShape<Schema>) => call(req),\n onSuccess: (...args) => {\n queryClient.invalidateQueries({ queryKey: invalidateKey });\n onSuccess?.(...args);\n },\n ...rest,\n });\n };\n}\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.7.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.",