@reliantlabs/forge-web-runtime 0.12.0 → 0.13.1

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/README.md CHANGED
@@ -107,6 +107,18 @@ an opaque external."
107
107
 
108
108
  ## Browser telemetry (`/telemetry`)
109
109
 
110
+ Raw DOM events (including `mousedown`, `mouseup` and `click`) stay in masked
111
+ session replay rather than creating automatic interaction spans. Document loads,
112
+ fetch/XHR requests and explicitly instrumented meaningful actions still create
113
+ spans. The interceptor stack preserves the caller's OTel context across its own
114
+ authentication and telemetry-startup waits; the SDK propagates the actual network
115
+ span's context to configured API origins.
116
+
117
+ The real Chromium SDK contract test is a separate slow tier:
118
+ `npm run build && npx playwright install chromium && npm run test:browser`.
119
+ It verifies exact action → fetch parent IDs and wire `traceparent`, retains
120
+ mouse events in replay, and checks private text/input sentinels are masked.
121
+
110
122
  `initBrowserTelemetry` starts the HyperDX browser SDK once per page load
111
123
  (errors, console, fetch/XHR, page loads, web vitals, optional replay) and ships
112
124
  to this origin's `/_otel`. It is a no-op without a DOM or an endpoint and never
@@ -126,6 +138,13 @@ await initBrowserTelemetry({
126
138
  });
127
139
  ```
128
140
 
141
+ One visit is one session (`rum.sessionId`), ending after 15 minutes without
142
+ activity or 4 hours. The SDK keeps the session only in a cookie, so on an origin
143
+ that drops cookies (an Electron renderer served from `app://`) it used to start a
144
+ new session every minute. Init now probes whether cookies persist and, when they
145
+ do not, keeps that one cookie in localStorage under the same limits
146
+ (`installSessionCookieFallback`). Origins whose cookies work are untouched.
147
+
129
148
  `track`, `setTelemetryUser` and `disableTelemetry` come from the barrel, so a
130
149
  component can call them without importing the SDK. They are safe anywhere: no-ops
131
150
  on the server, and calls made while the SDK chunk is still loading are held
@@ -163,6 +182,38 @@ on the server, and calls made while the SDK chunk is still loading are held
163
182
  and the visitor id key; `getAttribution()` is the captured record, for a site
164
183
  that stamps campaign values on its own events.
165
184
 
185
+ - Client context: init puts the low-entropy client hints of
186
+ `navigator.userAgentData` on the resource (`browser.brands` as
187
+ `"Google Chrome 131, Chromium 131"` with GREASE brands dropped,
188
+ `browser.platform`, `browser.mobile`), and the viewport and screen in CSS
189
+ pixels on every span (`app.viewport.width|height`, `app.screen.width|height`,
190
+ restamped once a resize settles). Every value is validated and a malformed
191
+ one is dropped. Browser, OS and device type are deliberately not reported:
192
+ the ingest side derives them from the request's `User-Agent` and `Sec-CH-UA*`
193
+ headers, which page script cannot set. A desktop app's preload exposes its
194
+ versions as `window.forgeDesktop` (`contextBridge.exposeInMainWorld("forgeDesktop",
195
+ { appVersion, electronVersion: process.versions.electron, chromiumVersion:
196
+ process.versions.chrome })`), which init stamps as `app.desktop.version`,
197
+ `app.desktop.electron.version` and `app.desktop.chromium.version`; `/node`
198
+ stamps the same three on an Electron main process (`app.desktop.version` is
199
+ its `serviceVersion`).
200
+
201
+ - Benign browser errors (`classifyBenignError`, `forge.errors_filtered`): an
202
+ `AbortError` (a cancelled request, an unmounted component, a navigation) and
203
+ the browser's generic fetch failure (a `TypeError` reading exactly `Failed to
204
+ fetch`, `Load failed` or `NetworkError when attempting to fetch resource.`)
205
+ are kept out of the error pipeline, whether they arrive as an unhandled
206
+ rejection, an uncaught error, `console.error` or `reportException`. A
207
+ `ConnectError` is never filtered. Each filtered error is counted, and the
208
+ counts go out as `forge.errors_filtered` actions (`kind`, `source`, `count`)
209
+ at most once a minute and when the page is hidden, so a real wave of network
210
+ failures still shows. The filter acts on what the SDK RECORDS, never on what
211
+ the app sees: `window.onerror`, `onunhandledrejection`, every `error` /
212
+ `unhandledrejection` listener and the console receive every event exactly as
213
+ without telemetry. `classifyBenignError: () => null` records everything; any
214
+ `(error) => kind | null` replaces the default (for an unhandled rejection or
215
+ uncaught error it is handed `{ name, message }` as the SDK recorded them).
216
+
166
217
  Privacy is forced, not configurable: when replay records, all text and inputs
167
218
  are masked, canvas is never recorded, `img, picture, video, audio, iframe,
168
219
  object, embed` are blocked, images are not inlined and fonts are not collected.
@@ -0,0 +1,33 @@
1
+ /** Why an error was filtered. */
2
+ export type BenignErrorKind = "abort" | "network";
3
+ /** Where a filtered error was seen. */
4
+ export type BenignErrorSource = "unhandledrejection" | "error" | "console.error" | "reportException";
5
+ /** A classifier: the kind of a benign error, or null for one to keep. */
6
+ export type BenignErrorClassifier = (error: unknown) => string | null;
7
+ /** The action name the counts are emitted under. */
8
+ export declare const FILTERED_ERRORS_ACTION = "forge.errors_filtered";
9
+ /** How often, at most, the counts are flushed while the page is visible. */
10
+ export declare const FILTERED_ERRORS_FLUSH_MS = 60000;
11
+ /**
12
+ * The default classifier. See the module header for exactly what it filters
13
+ * and what it never does.
14
+ */
15
+ export declare function classifyBenignError(error: unknown): BenignErrorKind | null;
16
+ /** Emits one action. Never throws on the caller's behalf: the caller guards it. */
17
+ type Emit = (name: string, attributes: Record<string, string | number>) => void;
18
+ /**
19
+ * Counts filtered errors and emits them as `forge.errors_filtered` actions.
20
+ * One instance per running SDK.
21
+ */
22
+ export declare class FilteredErrorCounter {
23
+ private readonly emit;
24
+ private readonly flushMs;
25
+ private counts;
26
+ private timer;
27
+ constructor(emit: Emit, flushMs?: number);
28
+ /** Count one filtered error. */
29
+ note(kind: string, source: BenignErrorSource): void;
30
+ /** Emit every pending count and reset. A no-op with nothing counted. */
31
+ flush(): void;
32
+ }
33
+ export {};
@@ -0,0 +1,115 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // Errors a browser raises that are not defects, kept out of the error pipeline
4
+ // and counted instead.
5
+ //
6
+ // Two shapes made up most of a real app's browser error_events once Sentry's
7
+ // defaults stopped filtering them (reliant, 2026-10-29: 41 of 45 rows):
8
+ //
9
+ // - ABORT. An `AbortError` is a cancelled operation: the user navigated away,
10
+ // a component unmounted, a newer request superseded an older one. When
11
+ // nobody awaited the promise it surfaces as an unhandled rejection
12
+ // ("signal is aborted without reason"), and it describes nothing that broke.
13
+ // - NETWORK. The browser's own generic fetch failure — a TypeError reading
14
+ // "Failed to fetch" (Chromium, now with " (host)" appended), "Load failed"
15
+ // (Safari) or "NetworkError when attempting to fetch resource." (Firefox).
16
+ // The browser says only that the request did not complete: offline, a
17
+ // captive portal, a tab suspended mid-request. One flaky connection writes
18
+ // dozens, and they bury the errors that are bugs.
19
+ //
20
+ // A filtered error is not discarded silently: each one is COUNTED, by kind and
21
+ // by where it was seen, and the counts are emitted as one
22
+ // `forge.errors_filtered` action per kind and source at most once a minute (and
23
+ // when the page is hidden), so a real wave of network failures — an outage, a
24
+ // bad deploy of an API — still shows as a spike in that count.
25
+ //
26
+ // What is never filtered: anything that is not exactly those shapes. A
27
+ // ConnectError (a Connect RPC's error, including one that wraps a failed fetch)
28
+ // is the app's own request failing and carries a code and a server trace; it is
29
+ // kept even when its message reads "Failed to fetch". So is a TypeError with
30
+ // any other message, and a non-Error value.
31
+ //
32
+ // The classifier is a seam: BrowserTelemetryConfig.classifyBenignError replaces
33
+ // it, and `() => null` turns filtering off.
34
+ /** The action name the counts are emitted under. */
35
+ export const FILTERED_ERRORS_ACTION = "forge.errors_filtered";
36
+ /** How often, at most, the counts are flushed while the page is visible. */
37
+ export const FILTERED_ERRORS_FLUSH_MS = 60_000;
38
+ // The browsers' generic fetch failures, whole message only. Chromium appends
39
+ // the host in parentheses ("Failed to fetch (api.example.com)").
40
+ const GENERIC_NETWORK_MESSAGE = /^(?:Failed to fetch|Load failed|NetworkError when attempting to fetch resource\.?|Network request failed)(?: \([^()]*\))?$/;
41
+ function nameOf(error) {
42
+ const name = error.name;
43
+ return typeof name === "string" ? name : "";
44
+ }
45
+ /**
46
+ * The default classifier. See the module header for exactly what it filters
47
+ * and what it never does.
48
+ */
49
+ export function classifyBenignError(error) {
50
+ if (typeof error !== "object" || error === null) {
51
+ return null;
52
+ }
53
+ const name = nameOf(error);
54
+ // A ConnectError is the app's own RPC failing: always kept.
55
+ if (name === "ConnectError" || name === "ConnectClientError") {
56
+ return null;
57
+ }
58
+ if (name === "AbortError") {
59
+ return "abort";
60
+ }
61
+ if (name === "TypeError") {
62
+ const message = error.message;
63
+ if (typeof message === "string" &&
64
+ GENERIC_NETWORK_MESSAGE.test(message.trim())) {
65
+ return "network";
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ /**
71
+ * Counts filtered errors and emits them as `forge.errors_filtered` actions.
72
+ * One instance per running SDK.
73
+ */
74
+ export class FilteredErrorCounter {
75
+ emit;
76
+ flushMs;
77
+ counts = new Map();
78
+ timer = null;
79
+ constructor(emit, flushMs = FILTERED_ERRORS_FLUSH_MS) {
80
+ this.emit = emit;
81
+ this.flushMs = flushMs;
82
+ }
83
+ /** Count one filtered error. */
84
+ note(kind, source) {
85
+ const key = `${kind}\u0000${source}`;
86
+ const entry = this.counts.get(key);
87
+ if (entry) {
88
+ entry.count += 1;
89
+ }
90
+ else {
91
+ this.counts.set(key, { kind, source, count: 1 });
92
+ }
93
+ if (this.timer === null) {
94
+ this.timer = setTimeout(() => this.flush(), this.flushMs);
95
+ }
96
+ }
97
+ /** Emit every pending count and reset. A no-op with nothing counted. */
98
+ flush() {
99
+ if (this.timer !== null) {
100
+ clearTimeout(this.timer);
101
+ this.timer = null;
102
+ }
103
+ const pending = [...this.counts.values()];
104
+ this.counts.clear();
105
+ for (const { kind, source, count } of pending) {
106
+ try {
107
+ this.emit(FILTERED_ERRORS_ACTION, { kind, source, count });
108
+ }
109
+ catch {
110
+ // Telemetry must never break the app.
111
+ }
112
+ }
113
+ }
114
+ }
115
+ //# sourceMappingURL=benign-errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"benign-errors.js","sourceRoot":"","sources":["../src/benign-errors.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Errors a browser raises that are not defects, kept out of the error pipeline\n// and counted instead.\n//\n// Two shapes made up most of a real app's browser error_events once Sentry's\n// defaults stopped filtering them (reliant, 2026-10-29: 41 of 45 rows):\n//\n// - ABORT. An `AbortError` is a cancelled operation: the user navigated away,\n// a component unmounted, a newer request superseded an older one. When\n// nobody awaited the promise it surfaces as an unhandled rejection\n// (\"signal is aborted without reason\"), and it describes nothing that broke.\n// - NETWORK. The browser's own generic fetch failure — a TypeError reading\n// \"Failed to fetch\" (Chromium, now with \" (host)\" appended), \"Load failed\"\n// (Safari) or \"NetworkError when attempting to fetch resource.\" (Firefox).\n// The browser says only that the request did not complete: offline, a\n// captive portal, a tab suspended mid-request. One flaky connection writes\n// dozens, and they bury the errors that are bugs.\n//\n// A filtered error is not discarded silently: each one is COUNTED, by kind and\n// by where it was seen, and the counts are emitted as one\n// `forge.errors_filtered` action per kind and source at most once a minute (and\n// when the page is hidden), so a real wave of network failures — an outage, a\n// bad deploy of an API — still shows as a spike in that count.\n//\n// What is never filtered: anything that is not exactly those shapes. A\n// ConnectError (a Connect RPC's error, including one that wraps a failed fetch)\n// is the app's own request failing and carries a code and a server trace; it is\n// kept even when its message reads \"Failed to fetch\". So is a TypeError with\n// any other message, and a non-Error value.\n//\n// The classifier is a seam: BrowserTelemetryConfig.classifyBenignError replaces\n// it, and `() => null` turns filtering off.\n\n/** Why an error was filtered. */\nexport type BenignErrorKind = \"abort\" | \"network\";\n\n/** Where a filtered error was seen. */\nexport type BenignErrorSource =\n | \"unhandledrejection\"\n | \"error\"\n | \"console.error\"\n | \"reportException\";\n\n/** A classifier: the kind of a benign error, or null for one to keep. */\nexport type BenignErrorClassifier = (error: unknown) => string | null;\n\n/** The action name the counts are emitted under. */\nexport const FILTERED_ERRORS_ACTION = \"forge.errors_filtered\";\n\n/** How often, at most, the counts are flushed while the page is visible. */\nexport const FILTERED_ERRORS_FLUSH_MS = 60_000;\n\n// The browsers' generic fetch failures, whole message only. Chromium appends\n// the host in parentheses (\"Failed to fetch (api.example.com)\").\nconst GENERIC_NETWORK_MESSAGE =\n /^(?:Failed to fetch|Load failed|NetworkError when attempting to fetch resource\\.?|Network request failed)(?: \\([^()]*\\))?$/;\n\nfunction nameOf(error: object): string {\n const name = (error as { name?: unknown }).name;\n return typeof name === \"string\" ? name : \"\";\n}\n\n/**\n * The default classifier. See the module header for exactly what it filters\n * and what it never does.\n */\nexport function classifyBenignError(error: unknown): BenignErrorKind | null {\n if (typeof error !== \"object\" || error === null) {\n return null;\n }\n const name = nameOf(error);\n // A ConnectError is the app's own RPC failing: always kept.\n if (name === \"ConnectError\" || name === \"ConnectClientError\") {\n return null;\n }\n if (name === \"AbortError\") {\n return \"abort\";\n }\n if (name === \"TypeError\") {\n const message = (error as { message?: unknown }).message;\n if (\n typeof message === \"string\" &&\n GENERIC_NETWORK_MESSAGE.test(message.trim())\n ) {\n return \"network\";\n }\n }\n return null;\n}\n\n/** Emits one action. Never throws on the caller's behalf: the caller guards it. */\ntype Emit = (name: string, attributes: Record<string, string | number>) => void;\n\n/**\n * Counts filtered errors and emits them as `forge.errors_filtered` actions.\n * One instance per running SDK.\n */\nexport class FilteredErrorCounter {\n private counts = new Map<\n string,\n { kind: string; source: string; count: number }\n >();\n private timer: ReturnType<typeof setTimeout> | null = null;\n\n constructor(\n private readonly emit: Emit,\n private readonly flushMs: number = FILTERED_ERRORS_FLUSH_MS,\n ) {}\n\n /** Count one filtered error. */\n note(kind: string, source: BenignErrorSource): void {\n const key = `${kind}\\u0000${source}`;\n const entry = this.counts.get(key);\n if (entry) {\n entry.count += 1;\n } else {\n this.counts.set(key, { kind, source, count: 1 });\n }\n if (this.timer === null) {\n this.timer = setTimeout(() => this.flush(), this.flushMs);\n }\n }\n\n /** Emit every pending count and reset. A no-op with nothing counted. */\n flush(): void {\n if (this.timer !== null) {\n clearTimeout(this.timer);\n this.timer = null;\n }\n const pending = [...this.counts.values()];\n this.counts.clear();\n for (const { kind, source, count } of pending) {\n try {\n this.emit(FILTERED_ERRORS_ACTION, { kind, source, count });\n } catch {\n // Telemetry must never break the app.\n }\n }\n }\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /** What a desktop preload exposes as `window.forgeDesktop`. */
2
+ export interface ForgeDesktopInfo {
3
+ appVersion?: string;
4
+ electronVersion?: string;
5
+ chromiumVersion?: string;
6
+ }
7
+ /** How long the window must stop resizing before the new size is stamped. */
8
+ export declare const VIEWPORT_DEBOUNCE_MS = 1000;
9
+ /**
10
+ * The resource attributes a page reports about its client: client hints
11
+ * and, inside a desktop app, its versions. Pure given its inputs; every value
12
+ * is validated and anything malformed is dropped, never truncated into
13
+ * something it was not.
14
+ */
15
+ export declare function clientResourceAttributes(source: {
16
+ userAgentData?: unknown;
17
+ desktop?: unknown;
18
+ }): Record<string, string>;
19
+ /** The span attributes naming this page's viewport and screen, in CSS pixels. */
20
+ export declare function viewportAttributes(win: {
21
+ innerWidth?: number;
22
+ innerHeight?: number;
23
+ screen?: {
24
+ width?: number;
25
+ height?: number;
26
+ };
27
+ }): Record<string, string>;
28
+ /** The client attributes of the running page: never throws, empty without a DOM. */
29
+ export declare function pageClientResourceAttributes(): Record<string, string>;
30
+ /**
31
+ * Calls `stamp` with the page's viewport attributes now and again, debounced,
32
+ * whenever the window is resized to a different size. Returns the function
33
+ * that stops watching. Never throws.
34
+ */
35
+ export declare function watchViewport(stamp: (attributes: Record<string, string>) => void, win?: Window | undefined): () => void;
@@ -0,0 +1,204 @@
1
+ // What a page reports about the client it runs in: its client hints, its
2
+ // viewport and screen, and, inside a desktop app, the app's versions.
3
+ //
4
+ // ── What the page reports, and what it does not ──────────────────────────
5
+ //
6
+ // The structured device facts a console filters on — browser name and
7
+ // version, OS name and version, device type — are NOT reported here. The
8
+ // ingest side derives them from the request's own User-Agent and the
9
+ // browser-set `Sec-CH-UA*` headers (which page script cannot set), and
10
+ // deletes any `user_agent.*`, `os.*` or `reliant.device.type` a page sent
11
+ // before stamping its own. A page can claim anything; the request's headers
12
+ // are the browser's.
13
+ //
14
+ // What only the page can know is reported here, sanitized, under the
15
+ // conventions that say it is page-reported:
16
+ //
17
+ // browser.brands, browser.platform, browser.mobile (resource)
18
+ // navigator.userAgentData, the low-entropy client hints. Brands are
19
+ // "Name Version" strings with GREASE brands ("Not A;Brand") dropped.
20
+ // Absent outside Chromium.
21
+ // app.desktop.version, app.desktop.electron.version,
22
+ // app.desktop.chromium.version (resource)
23
+ // inside a desktop app, from the preload bridge (below).
24
+ // app.viewport.width/height, app.screen.width/height (span attributes)
25
+ // CSS pixels, refreshed (debounced) when the window resizes.
26
+ //
27
+ // browser.language is not repeated: the SDK already stamps it.
28
+ //
29
+ // Nothing here is high-entropy: no getHighEntropyValues() call (it prompts
30
+ // no one but is a fingerprinting surface), no model, no full version list.
31
+ //
32
+ // ── The desktop bridge ───────────────────────────────────────────────────
33
+ //
34
+ // A desktop app's renderer is a page like any other; the app's own version is
35
+ // known only to its main process. The preload exposes it with
36
+ //
37
+ // contextBridge.exposeInMainWorld("forgeDesktop", {
38
+ // appVersion, // app.getVersion(), from main
39
+ // electronVersion: process.versions.electron,
40
+ // chromiumVersion: process.versions.chrome,
41
+ // });
42
+ //
43
+ // and the runtime reads `window.forgeDesktop` once at startup. A sandboxed
44
+ // preload cannot require this package, so the contract is the global, not a
45
+ // helper. Each value must look like a version (VERSION_RE) or it is dropped.
46
+ /** A version string: `1.8.16`, `1.8.16-dev+7d787d80`, `33.2.1`. */
47
+ const VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
48
+ /** A client-hint brand or platform: printable, short, no separators that would confuse a join. */
49
+ const HINT_RE = /^[\x20-\x7e]{1,48}$/;
50
+ /** At most this many brands are kept. Browsers send three. */
51
+ const MAX_BRANDS = 5;
52
+ /** Larger than any real screen in CSS pixels; anything beyond is a lie or a bug. */
53
+ const MAX_PIXELS = 32768;
54
+ /** How long the window must stop resizing before the new size is stamped. */
55
+ export const VIEWPORT_DEBOUNCE_MS = 1000;
56
+ function version(value) {
57
+ return typeof value === "string" && VERSION_RE.test(value)
58
+ ? value
59
+ : undefined;
60
+ }
61
+ function hint(value) {
62
+ if (typeof value !== "string") {
63
+ return undefined;
64
+ }
65
+ const v = value.trim();
66
+ return HINT_RE.test(v) ? v : undefined;
67
+ }
68
+ /**
69
+ * A GREASE brand: Chromium's deliberately bogus entry ("Not A;Brand",
70
+ * "Not)A;Brand", "Not_A Brand") that exists to break naive parsers.
71
+ */
72
+ function isGrease(brand) {
73
+ return /^not[^a-z]*a[^a-z]*brand$/i.test(brand.replace(/\s+/g, " ").trim());
74
+ }
75
+ /**
76
+ * The resource attributes a page reports about its client: client hints
77
+ * and, inside a desktop app, its versions. Pure given its inputs; every value
78
+ * is validated and anything malformed is dropped, never truncated into
79
+ * something it was not.
80
+ */
81
+ export function clientResourceAttributes(source) {
82
+ const out = {};
83
+ const ua = source.userAgentData;
84
+ if (ua && typeof ua === "object") {
85
+ if (Array.isArray(ua.brands)) {
86
+ const brands = [];
87
+ for (const b of ua.brands.slice(0, MAX_BRANDS * 2)) {
88
+ const name = hint(b?.brand);
89
+ const v = version(b?.version);
90
+ if (name && v && !isGrease(name) && !name.includes(",")) {
91
+ brands.push(`${name} ${v}`);
92
+ }
93
+ }
94
+ if (brands.length > 0) {
95
+ // Resource attributes are strings on this SDK; a comma-joined list
96
+ // is the encoding (brand names never contain one; see above).
97
+ out["browser.brands"] = brands.slice(0, MAX_BRANDS).join(", ");
98
+ }
99
+ }
100
+ const platform = hint(ua.platform);
101
+ if (platform) {
102
+ out["browser.platform"] = platform;
103
+ }
104
+ if (typeof ua.mobile === "boolean") {
105
+ out["browser.mobile"] = String(ua.mobile);
106
+ }
107
+ }
108
+ const desktop = source.desktop;
109
+ if (desktop && typeof desktop === "object") {
110
+ const app = version(desktop.appVersion);
111
+ const electron = version(desktop.electronVersion);
112
+ const chromium = version(desktop.chromiumVersion);
113
+ if (app)
114
+ out["app.desktop.version"] = app;
115
+ if (electron)
116
+ out["app.desktop.electron.version"] = electron;
117
+ if (chromium)
118
+ out["app.desktop.chromium.version"] = chromium;
119
+ }
120
+ return out;
121
+ }
122
+ function pixels(value) {
123
+ if (typeof value !== "number" || !Number.isFinite(value)) {
124
+ return undefined;
125
+ }
126
+ const n = Math.round(value);
127
+ return n > 0 && n <= MAX_PIXELS ? String(n) : undefined;
128
+ }
129
+ /** The span attributes naming this page's viewport and screen, in CSS pixels. */
130
+ export function viewportAttributes(win) {
131
+ const out = {};
132
+ const vw = pixels(win.innerWidth);
133
+ const vh = pixels(win.innerHeight);
134
+ const sw = pixels(win.screen?.width);
135
+ const sh = pixels(win.screen?.height);
136
+ // A dimension is kept only with its pair: "1440 x ?" is not a size.
137
+ if (vw && vh) {
138
+ out["app.viewport.width"] = vw;
139
+ out["app.viewport.height"] = vh;
140
+ }
141
+ if (sw && sh) {
142
+ out["app.screen.width"] = sw;
143
+ out["app.screen.height"] = sh;
144
+ }
145
+ return out;
146
+ }
147
+ /** The client attributes of the running page: never throws, empty without a DOM. */
148
+ export function pageClientResourceAttributes() {
149
+ if (typeof window === "undefined") {
150
+ return {};
151
+ }
152
+ try {
153
+ return clientResourceAttributes({
154
+ userAgentData: typeof navigator !== "undefined"
155
+ ? navigator.userAgentData
156
+ : undefined,
157
+ desktop: window.forgeDesktop,
158
+ });
159
+ }
160
+ catch {
161
+ return {};
162
+ }
163
+ }
164
+ /**
165
+ * Calls `stamp` with the page's viewport attributes now and again, debounced,
166
+ * whenever the window is resized to a different size. Returns the function
167
+ * that stops watching. Never throws.
168
+ */
169
+ export function watchViewport(stamp, win = typeof window !== "undefined" ? window : undefined) {
170
+ if (!win) {
171
+ return () => { };
172
+ }
173
+ let last = "";
174
+ const emit = () => {
175
+ try {
176
+ const attrs = viewportAttributes(win);
177
+ const key = JSON.stringify(attrs);
178
+ if (key !== last && Object.keys(attrs).length > 0) {
179
+ last = key;
180
+ stamp(attrs);
181
+ }
182
+ }
183
+ catch {
184
+ // Telemetry must never break the app.
185
+ }
186
+ };
187
+ emit();
188
+ if (typeof win.addEventListener !== "function") {
189
+ return () => { };
190
+ }
191
+ let timer;
192
+ const onResize = () => {
193
+ if (timer !== undefined)
194
+ clearTimeout(timer);
195
+ timer = setTimeout(emit, VIEWPORT_DEBOUNCE_MS);
196
+ };
197
+ win.addEventListener("resize", onResize, { passive: true });
198
+ return () => {
199
+ if (timer !== undefined)
200
+ clearTimeout(timer);
201
+ win.removeEventListener("resize", onResize);
202
+ };
203
+ }
204
+ //# sourceMappingURL=client-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-context.js","sourceRoot":"","sources":["../src/client-context.ts"],"names":[],"mappings":"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","sourcesContent":["// What a page reports about the client it runs in: its client hints, its\n// viewport and screen, and, inside a desktop app, the app's versions.\n//\n// ── What the page reports, and what it does not ──────────────────────────\n//\n// The structured device facts a console filters on — browser name and\n// version, OS name and version, device type — are NOT reported here. The\n// ingest side derives them from the request's own User-Agent and the\n// browser-set `Sec-CH-UA*` headers (which page script cannot set), and\n// deletes any `user_agent.*`, `os.*` or `reliant.device.type` a page sent\n// before stamping its own. A page can claim anything; the request's headers\n// are the browser's.\n//\n// What only the page can know is reported here, sanitized, under the\n// conventions that say it is page-reported:\n//\n// browser.brands, browser.platform, browser.mobile (resource)\n// navigator.userAgentData, the low-entropy client hints. Brands are\n// \"Name Version\" strings with GREASE brands (\"Not A;Brand\") dropped.\n// Absent outside Chromium.\n// app.desktop.version, app.desktop.electron.version,\n// app.desktop.chromium.version (resource)\n// inside a desktop app, from the preload bridge (below).\n// app.viewport.width/height, app.screen.width/height (span attributes)\n// CSS pixels, refreshed (debounced) when the window resizes.\n//\n// browser.language is not repeated: the SDK already stamps it.\n//\n// Nothing here is high-entropy: no getHighEntropyValues() call (it prompts\n// no one but is a fingerprinting surface), no model, no full version list.\n//\n// ── The desktop bridge ───────────────────────────────────────────────────\n//\n// A desktop app's renderer is a page like any other; the app's own version is\n// known only to its main process. The preload exposes it with\n//\n// contextBridge.exposeInMainWorld(\"forgeDesktop\", {\n// appVersion, // app.getVersion(), from main\n// electronVersion: process.versions.electron,\n// chromiumVersion: process.versions.chrome,\n// });\n//\n// and the runtime reads `window.forgeDesktop` once at startup. A sandboxed\n// preload cannot require this package, so the contract is the global, not a\n// helper. Each value must look like a version (VERSION_RE) or it is dropped.\n\n/** What a desktop preload exposes as `window.forgeDesktop`. */\nexport interface ForgeDesktopInfo {\n appVersion?: string;\n electronVersion?: string;\n chromiumVersion?: string;\n}\n\n/** A version string: `1.8.16`, `1.8.16-dev+7d787d80`, `33.2.1`. */\nconst VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;\n\n/** A client-hint brand or platform: printable, short, no separators that would confuse a join. */\nconst HINT_RE = /^[\\x20-\\x7e]{1,48}$/;\n\n/** At most this many brands are kept. Browsers send three. */\nconst MAX_BRANDS = 5;\n\n/** Larger than any real screen in CSS pixels; anything beyond is a lie or a bug. */\nconst MAX_PIXELS = 32768;\n\n/** How long the window must stop resizing before the new size is stamped. */\nexport const VIEWPORT_DEBOUNCE_MS = 1000;\n\ninterface UADataBrand {\n brand?: unknown;\n version?: unknown;\n}\n\ninterface UAData {\n brands?: unknown;\n mobile?: unknown;\n platform?: unknown;\n}\n\nfunction version(value: unknown): string | undefined {\n return typeof value === \"string\" && VERSION_RE.test(value)\n ? value\n : undefined;\n}\n\nfunction hint(value: unknown): string | undefined {\n if (typeof value !== \"string\") {\n return undefined;\n }\n const v = value.trim();\n return HINT_RE.test(v) ? v : undefined;\n}\n\n/**\n * A GREASE brand: Chromium's deliberately bogus entry (\"Not A;Brand\",\n * \"Not)A;Brand\", \"Not_A Brand\") that exists to break naive parsers.\n */\nfunction isGrease(brand: string): boolean {\n return /^not[^a-z]*a[^a-z]*brand$/i.test(brand.replace(/\\s+/g, \" \").trim());\n}\n\n/**\n * The resource attributes a page reports about its client: client hints\n * and, inside a desktop app, its versions. Pure given its inputs; every value\n * is validated and anything malformed is dropped, never truncated into\n * something it was not.\n */\nexport function clientResourceAttributes(source: {\n userAgentData?: unknown;\n desktop?: unknown;\n}): Record<string, string> {\n const out: Record<string, string> = {};\n const ua = source.userAgentData as UAData | undefined;\n if (ua && typeof ua === \"object\") {\n if (Array.isArray(ua.brands)) {\n const brands: string[] = [];\n for (const b of ua.brands.slice(0, MAX_BRANDS * 2) as UADataBrand[]) {\n const name = hint(b?.brand);\n const v = version(b?.version);\n if (name && v && !isGrease(name) && !name.includes(\",\")) {\n brands.push(`${name} ${v}`);\n }\n }\n if (brands.length > 0) {\n // Resource attributes are strings on this SDK; a comma-joined list\n // is the encoding (brand names never contain one; see above).\n out[\"browser.brands\"] = brands.slice(0, MAX_BRANDS).join(\", \");\n }\n }\n const platform = hint(ua.platform);\n if (platform) {\n out[\"browser.platform\"] = platform;\n }\n if (typeof ua.mobile === \"boolean\") {\n out[\"browser.mobile\"] = String(ua.mobile);\n }\n }\n const desktop = source.desktop as ForgeDesktopInfo | undefined;\n if (desktop && typeof desktop === \"object\") {\n const app = version(desktop.appVersion);\n const electron = version(desktop.electronVersion);\n const chromium = version(desktop.chromiumVersion);\n if (app) out[\"app.desktop.version\"] = app;\n if (electron) out[\"app.desktop.electron.version\"] = electron;\n if (chromium) out[\"app.desktop.chromium.version\"] = chromium;\n }\n return out;\n}\n\nfunction pixels(value: unknown): string | undefined {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return undefined;\n }\n const n = Math.round(value);\n return n > 0 && n <= MAX_PIXELS ? String(n) : undefined;\n}\n\n/** The span attributes naming this page's viewport and screen, in CSS pixels. */\nexport function viewportAttributes(win: {\n innerWidth?: number;\n innerHeight?: number;\n screen?: { width?: number; height?: number };\n}): Record<string, string> {\n const out: Record<string, string> = {};\n const vw = pixels(win.innerWidth);\n const vh = pixels(win.innerHeight);\n const sw = pixels(win.screen?.width);\n const sh = pixels(win.screen?.height);\n // A dimension is kept only with its pair: \"1440 x ?\" is not a size.\n if (vw && vh) {\n out[\"app.viewport.width\"] = vw;\n out[\"app.viewport.height\"] = vh;\n }\n if (sw && sh) {\n out[\"app.screen.width\"] = sw;\n out[\"app.screen.height\"] = sh;\n }\n return out;\n}\n\n/** The client attributes of the running page: never throws, empty without a DOM. */\nexport function pageClientResourceAttributes(): Record<string, string> {\n if (typeof window === \"undefined\") {\n return {};\n }\n try {\n return clientResourceAttributes({\n userAgentData:\n typeof navigator !== \"undefined\"\n ? (navigator as unknown as { userAgentData?: unknown }).userAgentData\n : undefined,\n desktop: (window as unknown as { forgeDesktop?: unknown }).forgeDesktop,\n });\n } catch {\n return {};\n }\n}\n\n/**\n * Calls `stamp` with the page's viewport attributes now and again, debounced,\n * whenever the window is resized to a different size. Returns the function\n * that stops watching. Never throws.\n */\nexport function watchViewport(\n stamp: (attributes: Record<string, string>) => void,\n win: Window | undefined = typeof window !== \"undefined\" ? window : undefined,\n): () => void {\n if (!win) {\n return () => {};\n }\n let last = \"\";\n const emit = () => {\n try {\n const attrs = viewportAttributes(win);\n const key = JSON.stringify(attrs);\n if (key !== last && Object.keys(attrs).length > 0) {\n last = key;\n stamp(attrs);\n }\n } catch {\n // Telemetry must never break the app.\n }\n };\n emit();\n if (typeof win.addEventListener !== \"function\") {\n return () => {};\n }\n let timer: ReturnType<typeof setTimeout> | undefined;\n const onResize = () => {\n if (timer !== undefined) clearTimeout(timer);\n timer = setTimeout(emit, VIEWPORT_DEBOUNCE_MS);\n };\n win.addEventListener(\"resize\", onResize, { passive: true });\n return () => {\n if (timer !== undefined) clearTimeout(timer);\n win.removeEventListener(\"resize\", onResize);\n };\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -35,7 +35,7 @@ export { RuntimeToastHost, type RuntimeToast, type RuntimeToastHostProps, type R
35
35
  /** Route guarding: render a page only to a signed-in caller. */
36
36
  export { RouteGuard, type RouteGuardProps } from "./route-guard.js";
37
37
  /** Generic data-table container: the tristate ladder, filter and cursor pagination. */
38
- export { Resource, type ResourceColumn, type ResourceProps, type ResourceStatus, } from "./resource.js";
38
+ export { Resource, type ResourceColumn, type ResourceSort, type ResourceProps, type ResourceStatus, } from "./resource.js";
39
39
  /**
40
40
  * Record a caught error with the browser telemetry SDK, when one is running.
41
41
  * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives
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,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;;;;;;;GAOG;AACH,OAAO,EACL,uBAAuB,EACvB,2BAA2B,EAC3B,UAAU,EACV,sBAAsB,EACtB,cAAc,EACd,eAAe,GAIhB,MAAM,kBAAkB,CAAC;AAE1B;;;;;;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 * Visitor identity and campaign attribution: `withAttribution(url)` appends\n * this browser's `rid` and captured `utm_*` to an outbound link (a marketing\n * site pointing at the app); the runtime reads them on arrival and emits one\n * `user_identified` event at first sign-in. Consent-gated like track().\n * `getAttribution()` is the captured record (to stamp on a site's own events);\n * the storage keys are exported so a site that withdraws consent can erase them.\n */\nexport {\n ATTRIBUTION_STORAGE_KEY,\n IDENTIFIED_USER_STORAGE_KEY,\n UTM_PARAMS,\n VISITOR_ID_STORAGE_KEY,\n getAttribution,\n withAttribution,\n type AppClient,\n type Attribution,\n type AttributionTouch,\n} from \"./attribution.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,GAKT,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;;;;;;;GAOG;AACH,OAAO,EACL,uBAAuB,EACvB,2BAA2B,EAC3B,UAAU,EACV,sBAAsB,EACtB,cAAc,EACd,eAAe,GAIhB,MAAM,kBAAkB,CAAC;AAE1B;;;;;;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 ResourceSort,\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 * Visitor identity and campaign attribution: `withAttribution(url)` appends\n * this browser's `rid` and captured `utm_*` to an outbound link (a marketing\n * site pointing at the app); the runtime reads them on arrival and emits one\n * `user_identified` event at first sign-in. Consent-gated like track().\n * `getAttribution()` is the captured record (to stamp on a site's own events);\n * the storage keys are exported so a site that withdraws consent can erase them.\n */\nexport {\n ATTRIBUTION_STORAGE_KEY,\n IDENTIFIED_USER_STORAGE_KEY,\n UTM_PARAMS,\n VISITOR_ID_STORAGE_KEY,\n getAttribution,\n withAttribution,\n type AppClient,\n type Attribution,\n type AttributionTouch,\n} from \"./attribution.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"]}
@@ -33,6 +33,7 @@
33
33
  // stack two retry layers AND retry non-idempotent mutations — the bug this
34
34
  // split fixed.
35
35
  import { MethodOptions_IdempotencyLevel } from "@bufbuild/protobuf/wkt";
36
+ import { context } from "@opentelemetry/api";
36
37
  import { ConnectClientError, normalizeError } from "./errors.js";
37
38
  import { telemetryReady } from "./telemetry-gate.js";
38
39
  import { injectTraceContext, traceIdFrom } from "./trace.js";
@@ -40,11 +41,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
41
  /** Attaches the bearer token from the app's auth provider. */
41
42
  export function authInterceptor(cfg) {
42
43
  return (next) => async (req) => {
44
+ const parent = context.active();
43
45
  const token = cfg.getToken ? await cfg.getToken() : null;
44
46
  if (token) {
45
47
  req.header.set("Authorization", `Bearer ${token}`);
46
48
  }
47
- return next(req);
49
+ // Browser stack context managers cannot carry context through native
50
+ // await. Restore the caller's context before entering the next boundary.
51
+ return context.with(parent, () => next(req));
48
52
  };
49
53
  }
50
54
  /** Attaches brand context headers when the app supplies them. */
@@ -67,9 +71,12 @@ export function headerInterceptor(cfg) {
67
71
  */
68
72
  export function traceInterceptor() {
69
73
  return (next) => async (req) => {
74
+ const parent = context.active();
70
75
  await telemetryReady();
71
- injectTraceContext(req.header);
72
- return next(req);
76
+ return context.with(parent, () => {
77
+ injectTraceContext(req.header);
78
+ return next(req);
79
+ });
73
80
  };
74
81
  }
75
82
  /** Converts any thrown failure into a typed ConnectClientError. */