@reliantlabs/forge-web-runtime 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +192 -0
  2. package/dist/basepath.d.ts +57 -0
  3. package/dist/basepath.js +102 -0
  4. package/dist/basepath.js.map +1 -0
  5. package/dist/error-boundary.d.ts +31 -0
  6. package/dist/error-boundary.js +62 -0
  7. package/dist/error-boundary.js.map +1 -0
  8. package/dist/errors.d.ts +90 -0
  9. package/dist/errors.js +199 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +31 -0
  12. package/dist/index.js +50 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/interceptors.d.ts +49 -0
  15. package/dist/interceptors.js +154 -0
  16. package/dist/interceptors.js.map +1 -0
  17. package/dist/mock-transport.d.ts +115 -0
  18. package/dist/mock-transport.js +320 -0
  19. package/dist/mock-transport.js.map +1 -0
  20. package/dist/otel.d.ts +31 -0
  21. package/dist/otel.js +94 -0
  22. package/dist/otel.js.map +1 -0
  23. package/dist/providers.d.ts +19 -0
  24. package/dist/providers.js +25 -0
  25. package/dist/providers.js.map +1 -0
  26. package/dist/resource.d.ts +33 -0
  27. package/dist/resource.js +77 -0
  28. package/dist/resource.js.map +1 -0
  29. package/dist/route-guard.d.ts +9 -0
  30. package/dist/route-guard.js +21 -0
  31. package/dist/route-guard.js.map +1 -0
  32. package/dist/service-hooks.d.ts +78 -0
  33. package/dist/service-hooks.js +112 -0
  34. package/dist/service-hooks.js.map +1 -0
  35. package/dist/session.d.ts +45 -0
  36. package/dist/session.js +79 -0
  37. package/dist/session.js.map +1 -0
  38. package/dist/telemetry.d.ts +25 -0
  39. package/dist/telemetry.js +128 -0
  40. package/dist/telemetry.js.map +1 -0
  41. package/dist/toast-host.d.ts +36 -0
  42. package/dist/toast-host.js +34 -0
  43. package/dist/toast-host.js.map +1 -0
  44. package/dist/trace.d.ts +21 -0
  45. package/dist/trace.js +106 -0
  46. package/dist/trace.js.map +1 -0
  47. package/interceptors/package.json +7 -0
  48. package/package.json +100 -0
package/dist/errors.js ADDED
@@ -0,0 +1,199 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // Typed client errors. Connect surfaces failures as ConnectError with a
4
+ // numeric Code; the rest of the app should reason about a stable, typed
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
8
+ // human-presentable message.
9
+ import { Code, ConnectError } from "@connectrpc/connect";
10
+ /**
11
+ * FORGE_ERROR_REASON_HEADER is the Connect trailer/metadata key carrying a
12
+ * stable, machine-readable reason code (snake_case) the backend sets for
13
+ * every error. UI/routing keys off `error.reason` instead of matching
14
+ * human-readable message strings — the message text is display-only and
15
+ * may change. The backend owns the setter; the frontend only reads it.
16
+ */
17
+ export const FORGE_ERROR_REASON_HEADER = "x-forge-error-reason";
18
+ /**
19
+ * stripServerFraming removes the transport framing that must never surface
20
+ * to a user: the leading "[code] " gRPC-code prefix connect-es puts on
21
+ * `ConnectError.message`. What survives is the message the server wrote,
22
+ * which is exactly what a user should read.
23
+ *
24
+ * This is the SINGLE implementation — a project never wants a different
25
+ * answer here, and a copy frozen into a scaffold could never be corrected
26
+ * once shipped. Idempotent: framing that has already been stripped strips
27
+ * to itself.
28
+ */
29
+ export function stripServerFraming(message) {
30
+ return message.replace(/^\[[a-z_]+\]\s*/i, "").trim();
31
+ }
32
+ /** The copy shown when an error carries no presentable message of its own. */
33
+ const DEFAULT_USER_MESSAGE = "Something went wrong. Please try again.";
34
+ /**
35
+ * userMessage — turn any thrown value into copy fit for end users.
36
+ *
37
+ * `ConnectError.message` prefixes the gRPC code ("[not_found] no such task");
38
+ * `rawMessage` is the server's human-readable text without that framing, and
39
+ * stripServerFraming drops the prefix from whichever of the two is available.
40
+ * Use this everywhere an error is SHOWN (banners, toasts) instead of
41
+ * `err.message`.
42
+ *
43
+ * Pass `fallback` to override the default copy for a surface that wants its
44
+ * own wording.
45
+ */
46
+ export function userMessage(err, fallback = DEFAULT_USER_MESSAGE) {
47
+ if (err instanceof ConnectError) {
48
+ return stripServerFraming(err.rawMessage || err.message) || fallback;
49
+ }
50
+ if (err instanceof Error) {
51
+ return stripServerFraming(err.message) || fallback;
52
+ }
53
+ return err == null ? fallback : stripServerFraming(String(err)) || fallback;
54
+ }
55
+ /** Connect codes worth a transparent transport-level retry. */
56
+ const RETRYABLE = new Set([
57
+ Code.Unavailable,
58
+ Code.DeadlineExceeded,
59
+ Code.Aborted,
60
+ Code.ResourceExhausted,
61
+ ]);
62
+ /** Rough Connect-code → HTTP-status mapping for display/telemetry only. */
63
+ const HTTP_STATUS = {
64
+ [Code.InvalidArgument]: 400,
65
+ [Code.Unauthenticated]: 401,
66
+ [Code.PermissionDenied]: 403,
67
+ [Code.NotFound]: 404,
68
+ [Code.AlreadyExists]: 409,
69
+ [Code.ResourceExhausted]: 429,
70
+ [Code.Unimplemented]: 501,
71
+ [Code.Unavailable]: 503,
72
+ [Code.DeadlineExceeded]: 504,
73
+ [Code.Internal]: 500,
74
+ };
75
+ /**
76
+ * isConnectClientErrorShape — the STRUCTURAL test behind
77
+ * {@link ConnectClientError}'s `instanceof`.
78
+ *
79
+ * A class object is per module instance, not per package. npm nests a
80
+ * transitive dependency on any version conflict, and a bundler can pull one
81
+ * package in as both ESM and CJS, so a tree holding two copies of this
82
+ * package holds two distinct ConnectClientError classes. Matching on shape
83
+ * instead of identity is what makes the two agree: the shape is the
84
+ * contract, the constructor is not.
85
+ */
86
+ function isConnectClientErrorShape(v) {
87
+ if (!(v instanceof Error))
88
+ return false;
89
+ if (Object.getPrototypeOf(v) === ConnectClientError.prototype)
90
+ return true;
91
+ const e = v;
92
+ return (v.name === "ConnectClientError" &&
93
+ typeof e.code === "string" &&
94
+ typeof e.status === "number" &&
95
+ typeof e.retryable === "boolean" &&
96
+ (typeof e.reason === "string" || e.reason === null));
97
+ }
98
+ /**
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.
102
+ */
103
+ export class ConnectClientError extends Error {
104
+ /** 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;
108
+ /** Approximate HTTP status, for telemetry and generic UI. */
109
+ status;
110
+ /** True when a transparent retry is reasonable (transient failures). */
111
+ retryable;
112
+ /**
113
+ * Stable machine-readable reason code from the backend
114
+ * (x-forge-error-reason metadata), or null. Key UI/routing off THIS,
115
+ * never off `message` — message text is display-only.
116
+ */
117
+ reason;
118
+ 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;
126
+ }
127
+ /**
128
+ * `instanceof` matches on SHAPE, not on class identity — so every check
129
+ * spelled `err instanceof ConnectClientError`, here and in app code, keeps
130
+ * working when two copies of this package end up in one bundle (see
131
+ * {@link isConnectClientErrorShape}). Without it, normalizeError's
132
+ * 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.
138
+ */
139
+ static [Symbol.hasInstance](v) {
140
+ return isConnectClientErrorShape(v);
141
+ }
142
+ }
143
+ /** isRetryable reports whether a Connect code merits a transport retry. */
144
+ export function isRetryableCode(code) {
145
+ return RETRYABLE.has(code);
146
+ }
147
+ /**
148
+ * normalizeError coerces any thrown value into a ConnectClientError. Already-
149
+ * normalized errors pass through unchanged so the interceptor chain never
150
+ * double-wraps.
151
+ *
152
+ * Both `instanceof` checks below match on SHAPE rather than class identity —
153
+ * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one
154
+ * @connectrpc/connect ships — so neither branch is missed when a second copy
155
+ * of either package lands in the bundle.
156
+ */
157
+ export function normalizeError(err) {
158
+ if (err instanceof ConnectClientError) {
159
+ return err;
160
+ }
161
+ if (err instanceof ConnectError) {
162
+ return new ConnectClientError({
163
+ // Strip the "[code] " prefix so the message is user-presentable at
164
+ // every layer.
165
+ 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,
172
+ cause: err,
173
+ });
174
+ }
175
+ if (err instanceof Error) {
176
+ // A network fault (fetch rejected: offline, DNS, CORS preflight, TLS)
177
+ // surfaces as a TypeError — that, and only that, is a safe transparent
178
+ // retry. A generic Error is NOT blanket-retried (blanket-retrying every
179
+ // bare Error widened the blast radius to non-idempotent failures).
180
+ const networkFault = err instanceof TypeError;
181
+ return new ConnectClientError({
182
+ message: stripServerFraming(err.message),
183
+ code: networkFault ? "unavailable" : "unknown",
184
+ status: networkFault ? 503 : 500,
185
+ retryable: networkFault,
186
+ reason: null,
187
+ cause: err,
188
+ });
189
+ }
190
+ return new ConnectClientError({
191
+ message: typeof err === "string" ? stripServerFraming(err) : "Unknown error",
192
+ code: "unknown",
193
+ status: 500,
194
+ retryable: false,
195
+ reason: null,
196
+ cause: err,
197
+ });
198
+ }
199
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +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(err: unknown, fallback: string = DEFAULT_USER_MESSAGE): 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: 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"]}
@@ -0,0 +1,31 @@
1
+ /** Transport interceptor stack. */
2
+ export { authInterceptor, buildRuntimeInterceptors, errorNormalizeInterceptor, headerInterceptor, retryInterceptor, traceInterceptor, type RuntimeInterceptorConfig, } from "./interceptors.js";
3
+ /**
4
+ * Typed client errors, and the backend-framing strippers every surface that
5
+ * SHOWS an error goes through.
6
+ */
7
+ export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, stripServerFraming, userMessage, } from "./errors.js";
8
+ /**
9
+ * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute
10
+ * URLs handed to external systems that round-trip back into the app.
11
+ *
12
+ * Barrel-exported rather than behind a subpath: three string functions with
13
+ * no imports, so there is no dependency footprint for a subpath to fence off.
14
+ */
15
+ export { createBasePath, joinBasePath, normalizeBasePath, type BasePathHelpers, } from "./basepath.js";
16
+ /** W3C trace-context propagation. */
17
+ export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
18
+ /** Session: the signed-in user, derived from the app's auth state and JWT claims. */
19
+ export { decodeJwt, SessionProvider, useSession, type JwtClaims, type Session, type SessionAuth, } from "./session.js";
20
+ /** The app-shell provider set: session + error boundary + toast host in one. */
21
+ export { RuntimeShell, type RuntimeShellProps } from "./providers.js";
22
+ /** Error boundary with a designed crash fallback, for a route or a subtree. */
23
+ export { DefaultErrorFallback, RuntimeErrorBoundary, } from "./error-boundary.js";
24
+ /** The toast queue. The app supplies the event source and the markup. */
25
+ export { RuntimeToastHost, type RuntimeToast, type RuntimeToastHostProps, type RuntimeToastPayload, type RuntimeToastSink, type RuntimeToastVariant, } from "./toast-host.js";
26
+ /** Route guarding: render a page only to a signed-in caller. */
27
+ export { RouteGuard, type RouteGuardProps } from "./route-guard.js";
28
+ /** Generic data-table container: the tristate ladder, filter and cursor pagination. */
29
+ export { Resource, type ResourceColumn, type ResourceProps, type ResourceStatus, } from "./resource.js";
30
+ /** Client RUM (opt-in export). */
31
+ export { initClientTelemetry, type TelemetryConfig, type TelemetryEvent, } from "./telemetry.js";
package/dist/index.js ADDED
@@ -0,0 +1,50 @@
1
+ // @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // Public barrel. Import batteries from "@reliantlabs/forge-web-runtime".
4
+ //
5
+ // Everything here is MECHANISM: the transport interceptor stack, the session,
6
+ // the error boundary, the toast queue, the <Resource> tristate ladder, W3C
7
+ // trace propagation, client RUM. Nothing here reaches into an application —
8
+ // app-shaped values (auth state, event subscriptions, toast markup) arrive as
9
+ // props, so an app can restyle or replace its own files freely.
10
+ //
11
+ // Presentation that a project is meant to own — the component library under
12
+ // src/components/ui, globals.css, nav, layout — deliberately stays in the
13
+ // scaffold and is NOT part of this package.
14
+ // The per-group docs below are JSDoc, not `//`, on purpose: TypeScript's
15
+ // declaration emit preserves JSDoc and DROPS line comments, and dist/ is now
16
+ // the whole of what this package publishes. `forge project libraries` reads
17
+ // this inventory out of the published barrel, and a consumer's editor shows
18
+ // it on hover — both would get symbols with no explanation otherwise.
19
+ /** Transport interceptor stack. */
20
+ export { authInterceptor, buildRuntimeInterceptors, errorNormalizeInterceptor, headerInterceptor, retryInterceptor, traceInterceptor, } from "./interceptors.js";
21
+ /**
22
+ * Typed client errors, and the backend-framing strippers every surface that
23
+ * SHOWS an error goes through.
24
+ */
25
+ export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, stripServerFraming, userMessage, } from "./errors.js";
26
+ /**
27
+ * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute
28
+ * URLs handed to external systems that round-trip back into the app.
29
+ *
30
+ * Barrel-exported rather than behind a subpath: three string functions with
31
+ * no imports, so there is no dependency footprint for a subpath to fence off.
32
+ */
33
+ export { createBasePath, joinBasePath, normalizeBasePath, } from "./basepath.js";
34
+ /** W3C trace-context propagation. */
35
+ export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
36
+ /** Session: the signed-in user, derived from the app's auth state and JWT claims. */
37
+ export { decodeJwt, SessionProvider, useSession, } from "./session.js";
38
+ /** The app-shell provider set: session + error boundary + toast host in one. */
39
+ export { RuntimeShell } from "./providers.js";
40
+ /** Error boundary with a designed crash fallback, for a route or a subtree. */
41
+ export { DefaultErrorFallback, RuntimeErrorBoundary, } from "./error-boundary.js";
42
+ /** The toast queue. The app supplies the event source and the markup. */
43
+ export { RuntimeToastHost, } from "./toast-host.js";
44
+ /** Route guarding: render a page only to a signed-in caller. */
45
+ export { RouteGuard } from "./route-guard.js";
46
+ /** Generic data-table container: the tristate ladder, filter and cursor pagination. */
47
+ export { Resource, } from "./resource.js";
48
+ /** Client RUM (opt-in export). */
49
+ export { initClientTelemetry, } from "./telemetry.js";
50
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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,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,kCAAkC;AAClC,OAAO,EACL,mBAAmB,GAGpB,MAAM,gBAAgB,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/** 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/** Client RUM (opt-in export). */\nexport {\n initClientTelemetry,\n type TelemetryConfig,\n type TelemetryEvent,\n} from \"./telemetry.js\";\n"]}
@@ -0,0 +1,49 @@
1
+ import { ConnectClientError } from "./errors.js";
2
+ import type { Interceptor } from "@connectrpc/connect";
3
+ /**
4
+ * RuntimeInterceptorConfig is the seam the app wires its runtime dependencies
5
+ * through. Everything is optional: an unconfigured chain still propagates
6
+ * traceparent and normalizes errors.
7
+ */
8
+ export interface RuntimeInterceptorConfig {
9
+ /** Returns the current bearer token, or null when unauthenticated. */
10
+ getToken?: () => Promise<string | null> | string | null;
11
+ /** Returns the active brand, attached as the X-Brand header. */
12
+ getBrand?: () => string | null | undefined;
13
+ /** Invoked with the typed error on every failed RPC (telemetry sink). */
14
+ onError?: (err: ConnectClientError) => void;
15
+ /** Max transparent retries for retryable unary failures. Default 2. */
16
+ maxRetries?: number;
17
+ /** Base backoff in ms; grows exponentially with jitter. Default 200. */
18
+ retryBaseMs?: number;
19
+ }
20
+ /** Attaches the bearer token from the app's auth provider. */
21
+ export declare function authInterceptor(cfg: RuntimeInterceptorConfig): Interceptor;
22
+ /** Attaches brand context headers when the app supplies them. */
23
+ export declare function headerInterceptor(cfg: RuntimeInterceptorConfig): Interceptor;
24
+ /**
25
+ * Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins
26
+ * the same distributed trace. Always emits a valid header — see trace.ts.
27
+ */
28
+ export declare function traceInterceptor(): Interceptor;
29
+ /** Converts any thrown failure into a typed ConnectClientError. */
30
+ export declare function errorNormalizeInterceptor(cfg: RuntimeInterceptorConfig): Interceptor;
31
+ /**
32
+ * Transparently retries retryable failures with exponential backoff +
33
+ * jitter — for IDEMPOTENT requests ONLY (see retryEligible). NOT part of the
34
+ * default chain: React Query owns retries (see the retry-ownership note at
35
+ * the top of this file). Exported for callers using the raw transport
36
+ * outside React Query. Streaming RPCs are never retried (a partially-
37
+ * consumed stream can't be safely replayed).
38
+ */
39
+ export declare function retryInterceptor(cfg: RuntimeInterceptorConfig): Interceptor;
40
+ /**
41
+ * buildRuntimeInterceptors composes the full stack in the correct order.
42
+ * Pass the result to createConnectTransport's `interceptors` option.
43
+ *
44
+ * retryInterceptor is deliberately NOT included — React Query is the single
45
+ * retry owner (see the retry-ownership note at the top of this file).
46
+ */
47
+ export declare function buildRuntimeInterceptors(cfg?: RuntimeInterceptorConfig): Interceptor[];
48
+ export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.js";
49
+ export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
@@ -0,0 +1,154 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // This module is also the package's SECOND ENTRY POINT, published as
4
+ // "@reliantlabs/forge-web-runtime/interceptors". It is the DOM-free, React-free
5
+ // half of the runtime: the transport layer and nothing else. Importing it
6
+ // pulls in exactly ./errors and ./trace — no React, no @types/react, no
7
+ // window/document. That matters for React Native, where Expo pins React 18
8
+ // while the package's components are typed against React 19: a mobile app
9
+ // importing the full barrel would typecheck the components it never renders
10
+ // and fail. Everything re-exported at the bottom of this file is part of
11
+ // this entry point's surface; keep it React-free (guarded by
12
+ // interceptors-entry.test.ts).
13
+ //
14
+ // The Connect transport interceptor stack. This is the one place every
15
+ // frontend→backend call flows through, composed once and handed to
16
+ // createConnectTransport by the app's own connect module. Order (outermost
17
+ // first):
18
+ //
19
+ // error-normalize → auth → brand headers → traceparent
20
+ //
21
+ // error-normalize is outermost so every caller sees a typed
22
+ // ConnectClientError instead of a raw ConnectError.
23
+ //
24
+ // RETRY OWNERSHIP — read before adding a retry layer.
25
+ // The single retry owner is the app's React Query client: it
26
+ // structurally knows reads from writes, so it retries transient READ
27
+ // failures and NEVER retries a mutation — no duplicate writes. Each React
28
+ // Query retry re-runs the queryFn, which re-runs this whole interceptor
29
+ // chain (fresh token + traceparent), so nothing is lost by not retrying
30
+ // here. `retryInterceptor` below is therefore NOT in the default chain; it
31
+ // stays exported (idempotency-gated) only for callers who use the raw
32
+ // transport OUTSIDE React Query. Putting it back in the default chain would
33
+ // stack two retry layers AND retry non-idempotent mutations — the bug this
34
+ // split fixed.
35
+ import { MethodOptions_IdempotencyLevel } from "@bufbuild/protobuf/wkt";
36
+ import { ConnectClientError, normalizeError } from "./errors.js";
37
+ import { injectTraceContext } from "./trace.js";
38
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39
+ /** Attaches the bearer token from the app's auth provider. */
40
+ export function authInterceptor(cfg) {
41
+ return (next) => async (req) => {
42
+ const token = cfg.getToken ? await cfg.getToken() : null;
43
+ if (token) {
44
+ req.header.set("Authorization", `Bearer ${token}`);
45
+ }
46
+ return next(req);
47
+ };
48
+ }
49
+ /** Attaches brand context headers when the app supplies them. */
50
+ export function headerInterceptor(cfg) {
51
+ return (next) => async (req) => {
52
+ const brand = cfg.getBrand?.();
53
+ if (brand) {
54
+ req.header.set("X-Brand", brand);
55
+ }
56
+ return next(req);
57
+ };
58
+ }
59
+ /**
60
+ * Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins
61
+ * the same distributed trace. Always emits a valid header — see trace.ts.
62
+ */
63
+ export function traceInterceptor() {
64
+ return (next) => async (req) => {
65
+ injectTraceContext(req.header);
66
+ return next(req);
67
+ };
68
+ }
69
+ /** Converts any thrown failure into a typed ConnectClientError. */
70
+ export function errorNormalizeInterceptor(cfg) {
71
+ return (next) => async (req) => {
72
+ try {
73
+ return await next(req);
74
+ }
75
+ catch (err) {
76
+ const normalized = normalizeError(err);
77
+ cfg.onError?.(normalized);
78
+ throw normalized;
79
+ }
80
+ };
81
+ }
82
+ /**
83
+ * retryEligible reports whether a request is SAFE to retry transparently:
84
+ * a no-side-effects / idempotent method (declared via proto
85
+ * `idempotency_level`), or one carrying an explicit Idempotency-Key header.
86
+ * A plain mutation (the default IDEMPOTENCY_UNKNOWN) is never retried, so a
87
+ * transparent retry can't produce a duplicate write.
88
+ */
89
+ function retryEligible(req) {
90
+ const level = req.method.idempotency;
91
+ if (level === MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS ||
92
+ level === MethodOptions_IdempotencyLevel.IDEMPOTENT) {
93
+ return true;
94
+ }
95
+ return req.header.has("Idempotency-Key");
96
+ }
97
+ /**
98
+ * Transparently retries retryable failures with exponential backoff +
99
+ * jitter — for IDEMPOTENT requests ONLY (see retryEligible). NOT part of the
100
+ * default chain: React Query owns retries (see the retry-ownership note at
101
+ * the top of this file). Exported for callers using the raw transport
102
+ * outside React Query. Streaming RPCs are never retried (a partially-
103
+ * consumed stream can't be safely replayed).
104
+ */
105
+ export function retryInterceptor(cfg) {
106
+ const max = cfg.maxRetries ?? 2;
107
+ const base = cfg.retryBaseMs ?? 200;
108
+ return (next) => async (req) => {
109
+ if (req.stream || !retryEligible(req)) {
110
+ return next(req);
111
+ }
112
+ let attempt = 0;
113
+ for (;;) {
114
+ try {
115
+ return await next(req);
116
+ }
117
+ catch (err) {
118
+ const normalized = err instanceof ConnectClientError ? err : normalizeError(err);
119
+ if (attempt >= max || !normalized.retryable) {
120
+ throw normalized;
121
+ }
122
+ const backoff = base * 2 ** attempt + Math.random() * base;
123
+ await sleep(backoff);
124
+ attempt++;
125
+ }
126
+ }
127
+ };
128
+ }
129
+ /**
130
+ * buildRuntimeInterceptors composes the full stack in the correct order.
131
+ * Pass the result to createConnectTransport's `interceptors` option.
132
+ *
133
+ * retryInterceptor is deliberately NOT included — React Query is the single
134
+ * retry owner (see the retry-ownership note at the top of this file).
135
+ */
136
+ export function buildRuntimeInterceptors(cfg = {}) {
137
+ return [
138
+ errorNormalizeInterceptor(cfg),
139
+ authInterceptor(cfg),
140
+ headerInterceptor(cfg),
141
+ traceInterceptor(),
142
+ ];
143
+ }
144
+ // ── The rest of the transport-layer surface ──────────────────────────────
145
+ //
146
+ // These belong to this entry point because the interceptor stack's contract
147
+ // is expressed in them: errorNormalizeInterceptor THROWS ConnectClientError
148
+ // and RuntimeInterceptorConfig.onError RECEIVES one, so a consumer cannot
149
+ // write a handler without the type; setTraceSampled is traceInterceptor's
150
+ // only knob. A consumer of "@reliantlabs/forge-web-runtime/interceptors" must
151
+ // never have to fall back to the React-typed barrel to complete the job.
152
+ export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normalizeError, } from "./errors.js";
153
+ export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
154
+ //# sourceMappingURL=interceptors.js.map
@@ -0,0 +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 { injectTraceContext } from \"./trace.js\";\n\nimport type { Interceptor, UnaryRequest, StreamRequest } 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 */\nexport function traceInterceptor(): Interceptor {\n return (next) => async (req) => {\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"]}