@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/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # @reliantlabs/forge-web-runtime
2
+
3
+ The forge frontend runtime — the web twin of `forge/pkg`. Framework-agnostic
4
+ React/TypeScript mechanism shared by every frontend forge scaffolds (Next.js
5
+ App Router and Vite SPA alike).
6
+
7
+ ```ts
8
+ import {
9
+ buildRuntimeInterceptors, // Connect transport stack
10
+ ConnectClientError, // typed client errors
11
+ RuntimeShell, // session + error boundary + toast host
12
+ useSession, // the signed-in user
13
+ RouteGuard, // signed-in render gate
14
+ Resource, // the list tristate + cursor pagination
15
+ initClientTelemetry, // opt-in client RUM
16
+ } from "@reliantlabs/forge-web-runtime";
17
+ ```
18
+
19
+ ## What is in here, and what is deliberately not
20
+
21
+ **In:** mechanism. The interceptor stack (auth, brand headers, W3C
22
+ traceparent, error normalization, idempotency-gated retry), the session
23
+ derived from JWT claims, the error boundary, the toast queue, the
24
+ `<Resource>` tristate/pagination container, trace-context propagation, and
25
+ client RUM.
26
+
27
+ **Out:** presentation a project is meant to own. The component library under
28
+ `src/components/ui`, `globals.css`, the nav and the app layout all stay in the
29
+ scaffold, because forking them is the point.
30
+
31
+ The dependency runs one way. Nothing here imports application code — auth
32
+ state, event subscriptions and toast markup all arrive as **props**, so an app
33
+ can rename, restyle or delete its own files without breaking the runtime.
34
+
35
+ ## What this package publishes
36
+
37
+ `dist/` — compiled ESM plus `.d.ts` — and nothing else. `src/` is not in the
38
+ tarball.
39
+
40
+ That is a contract, not a packaging detail. This package used to publish its
41
+ TypeScript sources: `types` pointed at `src/index.ts`, so **every consumer
42
+ typechecked this package's internals against its own React**. A React 18 app
43
+ (Expo SDK 52 pins 18.3) compiling sources written against `@types/react` 19
44
+ failed on files it never rendered —
45
+
46
+ ```
47
+ providers.tsx: 'RuntimeErrorBoundary' cannot be used as a JSX component
48
+ resource.tsx: Type '0n' is not assignable to type 'ReactNode'
49
+ ```
50
+
51
+ — and no amount of version pinning fixes the shape of that: a package that
52
+ ships sources makes its own dependency versions its consumers' problem,
53
+ forever, for every consumer. Publishing declarations ends it. A consumer
54
+ typechecks against the `.d.ts`, which `skipLibCheck` does not re-check, and
55
+ whose `react` references resolve in the consumer's own program.
56
+
57
+ `src/published-surface.test.ts` is the gate: it fails if any entry point
58
+ points back into `src/`, or if `src` reappears in `files`.
59
+
60
+ ### One `.d.ts` for React 18 and 19
61
+
62
+ `peerDependencies` declares `react: "^18.3.0 || ^19.0.0"`, and both halves are
63
+ real: forge's Next.js and Vite scaffolds are on 19, its Expo scaffold on 18.3.
64
+
65
+ The two `@types/react` majors disagree about where `JSX.Element` lives — 19
66
+ declares it inside the `react` module, 18.3 declares it globally and
67
+ re-exports it from `react/jsx-runtime`. An **inferred** component return type
68
+ is therefore emitted as `import("react").JSX.Element` when compiled against 19
69
+ and `import("react/jsx-runtime").JSX.Element` when compiled against 18, so the
70
+ published `.d.ts` would silently depend on whichever major happened to be
71
+ installed at build time.
72
+
73
+ So every exported component **declares `: ReactElement`**, a name both majors
74
+ export from `react`. With that, the emitted `.d.ts` is byte-for-byte identical
75
+ under either — the peer range is a fact about the artifact rather than a hope.
76
+ `src/published-surface.test.ts` fails on any `JSX.Element` reaching `dist/`,
77
+ and `npm run typecheck` compiles the package against **both** majors
78
+ (`tsconfig.react18.json` redirects `react` at an aliased `@types/react-18`).
79
+
80
+ ### Peer dependencies
81
+
82
+ `react`, `@connectrpc/connect`, `@bufbuild/protobuf` and `@opentelemetry/api`
83
+ are peers, resolved from the consuming app. `next` deliberately is **not** — a
84
+ Vite SPA consumes this package identically.
85
+
86
+ Next.js consumers keep `transpilePackages: ["@reliantlabs/forge-web-runtime"]`
87
+ (forge's generated `next.config.ts` sets it). The reason changed with the
88
+ build — it is no longer "these are `.tsx` files" but "these modules carry
89
+ `"use client"` and Next has to process them rather than treat the package as
90
+ an opaque external."
91
+
92
+ ## The DOM-free interceptors entry point
93
+
94
+ ```ts
95
+ import {
96
+ buildRuntimeInterceptors, // the Connect transport stack
97
+ ConnectClientError, // what the stack throws
98
+ setTraceSampled, // traceInterceptor's knob
99
+ } from "@reliantlabs/forge-web-runtime/interceptors";
100
+ ```
101
+
102
+ The `./interceptors` subpath is the transport layer on its own: `src/errors.ts`
103
+ and `src/trace.ts` are the only modules it reaches. It imports **no React and
104
+ no `@types/react`**, so a consumer never typechecks components it does not
105
+ render.
106
+
107
+ That is what keeps the package honest on React Native. A mobile app renders
108
+ none of the components here — they emit `<table>`, `<input>`, `<div>` — so
109
+ reaching them would drag a bundle's worth of dead code and a React binding
110
+ onto a platform whose renderer is not react-dom. The subpath skips them
111
+ entirely. `src/interceptors.ts` is guarded by
112
+ `src/interceptors-entry.test.ts`, which walks the entry point's real import
113
+ graph and fails if React ever appears in it.
114
+
115
+ (This subpath was *also* the workaround for React 18 vs 19 type skew, back
116
+ when the package published its sources. That is no longer why it exists — see
117
+ "One `.d.ts` for React 18 and 19" above; the barrel typechecks fine from Expo
118
+ now. It stays because dead components in a mobile bundle is a separate and
119
+ still-real cost.)
120
+
121
+ React Native resolvers predate `exports`: Expo SDK 52 / React Native 0.76 runs
122
+ Metro with `unstable_enablePackageExports` off, and `expo/tsconfig.base` sets
123
+ `moduleResolution: node10`. Neither reads the `exports` map. The
124
+ `interceptors/package.json` directory shim is what they resolve instead — same
125
+ build output, reached the old way. Delete it and mobile breaks while web keeps
126
+ working.
127
+
128
+ ## The `--color-*` contract
129
+
130
+ Several components render semantic Tailwind utilities. They are part of this
131
+ package's public contract: the consuming app's stylesheet MUST declare these
132
+ custom properties in its `@theme` block, and Tailwind v4 must be told to scan
133
+ this package — **it does not scan `node_modules`**:
134
+
135
+ ```css
136
+ /* frontends/<name>/src/app/globals.css (Next.js) */
137
+ @source "../../node_modules/@reliantlabs/forge-web-runtime";
138
+
139
+ /* frontends/<name>/src/index.css (Vite SPA) */
140
+ @source "../node_modules/@reliantlabs/forge-web-runtime";
141
+ ```
142
+
143
+ Without that directive every utility only this package uses is dropped from
144
+ the built CSS and the components render unstyled.
145
+
146
+ Required tokens:
147
+
148
+ | Token | Used for |
149
+ | --- | --- |
150
+ | `--color-surface` | table / panel background |
151
+ | `--color-surface-muted` | table head, row hover |
152
+ | `--color-border` | table and panel rules |
153
+ | `--color-ink` | primary text |
154
+ | `--color-ink-muted` | secondary text, empty states |
155
+ | `--color-ink-subtle` | placeholders, disabled controls |
156
+ | `--color-accent` | focus ring, active filter border |
157
+ | `--color-danger` | error text, destructive fill |
158
+ | `--color-danger-hover` | destructive fill hover |
159
+ | `--color-danger-surface` | error panel background |
160
+ | `--color-danger-border` | error panel border |
161
+ | `--color-danger-ink` | error text on a danger surface |
162
+ | `--color-on-danger` | text on a danger fill |
163
+
164
+ ## Local development
165
+
166
+ A dev forge build writes an npm `file:` dependency on this directory into a
167
+ generated frontend's `package.json`, so npm creates and maintains the link
168
+ itself and edits here are picked up with nothing published. The path is
169
+ written relative or home-anchored, never absolute. A release build writes an
170
+ ordinary version range instead.
171
+
172
+ `forge project libraries` prints where the package resolved to and what every
173
+ module exports — read it instead of searching the disk.
174
+
175
+ ### The live-edit loop
176
+
177
+ The bridge resolves `dist/`, so **run `npm run dev` here while you edit** —
178
+ `tsc --watch`, a few hundred milliseconds per incremental build. The linked
179
+ app's bundler is already watching this directory (Metro via `watchFolders`,
180
+ Next via `transpilePackages`, Vite because a linked dep is treated as source),
181
+ so a save still lands in the running app; it just arrives one compile later.
182
+
183
+ You do not have to remember to build before the *first* install. `prepare`
184
+ (`scripts/build.mjs`) runs when npm materialises the `file:` link and again
185
+ before `npm publish` — the two moments a consumable `dist/` has to exist. On a
186
+ checkout that has never been developed it installs this package's own
187
+ devDependencies once so `tsc` is there at all, because npm does not install a
188
+ link target's devDependencies and forge's CI never runs `npm install` here.
189
+ The script never runs for a registry install: the tarball already has `dist/`.
190
+
191
+ `npm test` builds first (`pretest`), so `src/published-surface.test.ts` always
192
+ asserts against a current artifact rather than whatever was last on disk.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * normalizeBasePath canonicalises a raw base-path value: ensure a leading
3
+ * "/", drop any trailing "/". "" and "/" both mean "served from the host
4
+ * root" and both normalise to "".
5
+ *
6
+ * normalizeBasePath("admin") -> "/admin"
7
+ * normalizeBasePath("/admin/") -> "/admin"
8
+ * normalizeBasePath("/") -> ""
9
+ */
10
+ export declare function normalizeBasePath(raw: string): string;
11
+ /**
12
+ * joinBasePath prefixes an app-internal path with an ALREADY-NORMALISED base
13
+ * path (see normalizeBasePath).
14
+ *
15
+ * Contract:
16
+ * - Idempotent: a path already under basePath is returned unchanged, so
17
+ * double-wrapping never double-prefixes.
18
+ * - Absolute http(s) URLs pass through untouched.
19
+ * - Accepts paths with or without a leading "/".
20
+ *
21
+ * Examples (basePath "/admin"):
22
+ * joinBasePath("/admin", "/billing/success") -> "/admin/billing/success"
23
+ * joinBasePath("/admin", "billing/success") -> "/admin/billing/success"
24
+ * joinBasePath("/admin", "/admin/billing") -> "/admin/billing" (idempotent)
25
+ * joinBasePath("/admin", "/") -> "/admin"
26
+ * joinBasePath("/admin", "https://x.test/y") -> "https://x.test/y"
27
+ *
28
+ * Most callers want the bound one-argument form from createBasePath() rather
29
+ * than repeating the prefix at every call site.
30
+ */
31
+ export declare function joinBasePath(basePath: string, path: string): string;
32
+ /** What createBasePath() hands back: the normalised prefix and a joiner bound to it. */
33
+ export interface BasePathHelpers {
34
+ /**
35
+ * The effective mount prefix, normalised. "" means the frontend is served
36
+ * from the host root.
37
+ */
38
+ BASE_PATH: string;
39
+ /** joinBasePath, bound to BASE_PATH — the one-argument form apps call. */
40
+ joinBasePath: (path: string) => string;
41
+ }
42
+ /**
43
+ * createBasePath binds the prefix once, at module scope, and returns the pair
44
+ * an app actually imports.
45
+ *
46
+ * This is the seam forge's generated `src/lib/basepath_gen.ts` is reduced to:
47
+ *
48
+ * export const { BASE_PATH, joinBasePath } = createBasePath(
49
+ * process.env.NEXT_PUBLIC_BASE_PATH ?? "/admin",
50
+ * );
51
+ *
52
+ * The `process.env.NEXT_PUBLIC_*` read STAYS in project code and cannot move
53
+ * here: Next.js inlines those literals by substituting them into the sources
54
+ * it compiles, so a library reading process.env at runtime finds nothing in a
55
+ * browser bundle. The value is the project's; the behaviour is this package's.
56
+ */
57
+ export declare function createBasePath(raw: string): BasePathHelpers;
@@ -0,0 +1,102 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // The mount-prefix primitives: normalising a raw `base_path` value, and
4
+ // prefixing an app-internal path with it.
5
+ //
6
+ // WHY THIS IS A LIBRARY AND NOT A GENERATED FILE. The prefix ITSELF is
7
+ // project-specific — it comes from forge.yaml's `frontends[].base_path`, with
8
+ // NEXT_PUBLIC_BASE_PATH as the build-time override. The RULES for handling it
9
+ // are not: leading slash, no trailing slash, idempotent joins, absolute URLs
10
+ // untouched. Those rules are identical in every project, and every one of the
11
+ // edge cases below is one somebody has shipped a broken deploy over. So the
12
+ // project keeps the value and this module keeps the behaviour.
13
+ //
14
+ // WHEN AN APP NEEDS THIS AT ALL. Next.js prepends the configured basePath to
15
+ // `<Link href>` and `useRouter().push()` on its own — keep passing
16
+ // app-relative paths there. This is for the URLs Next.js never sees:
17
+ //
18
+ // - window.location-built absolute URLs handed to external systems that
19
+ // round-trip back into the app: payment-provider redirect / return URLs
20
+ // (Stripe success_url / cancel_url), OAuth redirect_uri, share links,
21
+ // magic-link emails;
22
+ // - raw fetch() / EventSource / <a> / <img> paths built by string
23
+ // concatenation rather than rendered through Next's router.
24
+ //
25
+ // The anti-pattern this exists to kill is the hand-baked prefix literal
26
+ // (`"/admin" + path`) and the bare `/route` string in a hand-built URL — they
27
+ // bypass the prefix, or double it, the day the mount point moves.
28
+ //
29
+ // This module is in the BARREL, not behind a subpath: it is three string
30
+ // functions with no imports at all, so there is no optional dependency for a
31
+ // subpath to fence off and nothing for a bundler to fail to shake out.
32
+ /**
33
+ * normalizeBasePath canonicalises a raw base-path value: ensure a leading
34
+ * "/", drop any trailing "/". "" and "/" both mean "served from the host
35
+ * root" and both normalise to "".
36
+ *
37
+ * normalizeBasePath("admin") -> "/admin"
38
+ * normalizeBasePath("/admin/") -> "/admin"
39
+ * normalizeBasePath("/") -> ""
40
+ */
41
+ export function normalizeBasePath(raw) {
42
+ if (!raw || raw === "/")
43
+ return "";
44
+ const withLeading = raw.startsWith("/") ? raw : `/${raw}`;
45
+ return withLeading.endsWith("/") ? withLeading.slice(0, -1) : withLeading;
46
+ }
47
+ /**
48
+ * joinBasePath prefixes an app-internal path with an ALREADY-NORMALISED base
49
+ * path (see normalizeBasePath).
50
+ *
51
+ * Contract:
52
+ * - Idempotent: a path already under basePath is returned unchanged, so
53
+ * double-wrapping never double-prefixes.
54
+ * - Absolute http(s) URLs pass through untouched.
55
+ * - Accepts paths with or without a leading "/".
56
+ *
57
+ * Examples (basePath "/admin"):
58
+ * joinBasePath("/admin", "/billing/success") -> "/admin/billing/success"
59
+ * joinBasePath("/admin", "billing/success") -> "/admin/billing/success"
60
+ * joinBasePath("/admin", "/admin/billing") -> "/admin/billing" (idempotent)
61
+ * joinBasePath("/admin", "/") -> "/admin"
62
+ * joinBasePath("/admin", "https://x.test/y") -> "https://x.test/y"
63
+ *
64
+ * Most callers want the bound one-argument form from createBasePath() rather
65
+ * than repeating the prefix at every call site.
66
+ */
67
+ export function joinBasePath(basePath, path) {
68
+ if (/^https?:\/\//i.test(path))
69
+ return path;
70
+ const withLeading = path.startsWith("/") ? path : `/${path}`;
71
+ if (!basePath)
72
+ return withLeading;
73
+ if (withLeading === basePath || withLeading.startsWith(`${basePath}/`)) {
74
+ return withLeading;
75
+ }
76
+ if (withLeading === "/")
77
+ return basePath;
78
+ return `${basePath}${withLeading}`;
79
+ }
80
+ /**
81
+ * createBasePath binds the prefix once, at module scope, and returns the pair
82
+ * an app actually imports.
83
+ *
84
+ * This is the seam forge's generated `src/lib/basepath_gen.ts` is reduced to:
85
+ *
86
+ * export const { BASE_PATH, joinBasePath } = createBasePath(
87
+ * process.env.NEXT_PUBLIC_BASE_PATH ?? "/admin",
88
+ * );
89
+ *
90
+ * The `process.env.NEXT_PUBLIC_*` read STAYS in project code and cannot move
91
+ * here: Next.js inlines those literals by substituting them into the sources
92
+ * it compiles, so a library reading process.env at runtime finds nothing in a
93
+ * browser bundle. The value is the project's; the behaviour is this package's.
94
+ */
95
+ export function createBasePath(raw) {
96
+ const BASE_PATH = normalizeBasePath(raw);
97
+ return {
98
+ BASE_PATH,
99
+ joinBasePath: (path) => joinBasePath(BASE_PATH, path),
100
+ };
101
+ }
102
+ //# sourceMappingURL=basepath.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"basepath.js","sourceRoot":"","sources":["../src/basepath.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,wEAAwE;AACxE,0CAA0C;AAC1C,EAAE;AACF,uEAAuE;AACvE,8EAA8E;AAC9E,8EAA8E;AAC9E,6EAA6E;AAC7E,8EAA8E;AAC9E,4EAA4E;AAC5E,+DAA+D;AAC/D,EAAE;AACF,6EAA6E;AAC7E,mEAAmE;AACnE,qEAAqE;AACrE,EAAE;AACF,0EAA0E;AAC1E,4EAA4E;AAC5E,0EAA0E;AAC1E,yBAAyB;AACzB,oEAAoE;AACpE,gEAAgE;AAChE,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,kEAAkE;AAClE,EAAE;AACF,yEAAyE;AACzE,6EAA6E;AAC7E,uEAAuE;AAEvE;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,IAAI,CAAC,GAAG,IAAI,GAAG,KAAK,GAAG;QAAE,OAAO,EAAE,CAAC;IACnC,MAAM,WAAW,GAAG,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,GAAG,EAAE,CAAC;IAC1D,OAAO,WAAW,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;AAC5E,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,IAAY;IACzD,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC5C,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;IAC7D,IAAI,CAAC,QAAQ;QAAE,OAAO,WAAW,CAAC;IAClC,IAAI,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,UAAU,CAAC,GAAG,QAAQ,GAAG,CAAC,EAAE,CAAC;QACvE,OAAO,WAAW,CAAC;IACrB,CAAC;IACD,IAAI,WAAW,KAAK,GAAG;QAAE,OAAO,QAAQ,CAAC;IACzC,OAAO,GAAG,QAAQ,GAAG,WAAW,EAAE,CAAC;AACrC,CAAC;AAaD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,MAAM,SAAS,GAAG,iBAAiB,CAAC,GAAG,CAAC,CAAC;IACzC,OAAO;QACL,SAAS;QACT,YAAY,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,YAAY,CAAC,SAAS,EAAE,IAAI,CAAC;KAC9D,CAAC;AACJ,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The mount-prefix primitives: normalising a raw `base_path` value, and\n// prefixing an app-internal path with it.\n//\n// WHY THIS IS A LIBRARY AND NOT A GENERATED FILE. The prefix ITSELF is\n// project-specific — it comes from forge.yaml's `frontends[].base_path`, with\n// NEXT_PUBLIC_BASE_PATH as the build-time override. The RULES for handling it\n// are not: leading slash, no trailing slash, idempotent joins, absolute URLs\n// untouched. Those rules are identical in every project, and every one of the\n// edge cases below is one somebody has shipped a broken deploy over. So the\n// project keeps the value and this module keeps the behaviour.\n//\n// WHEN AN APP NEEDS THIS AT ALL. Next.js prepends the configured basePath to\n// `<Link href>` and `useRouter().push()` on its own — keep passing\n// app-relative paths there. This is for the URLs Next.js never sees:\n//\n// - window.location-built absolute URLs handed to external systems that\n// round-trip back into the app: payment-provider redirect / return URLs\n// (Stripe success_url / cancel_url), OAuth redirect_uri, share links,\n// magic-link emails;\n// - raw fetch() / EventSource / <a> / <img> paths built by string\n// concatenation rather than rendered through Next's router.\n//\n// The anti-pattern this exists to kill is the hand-baked prefix literal\n// (`\"/admin\" + path`) and the bare `/route` string in a hand-built URL — they\n// bypass the prefix, or double it, the day the mount point moves.\n//\n// This module is in the BARREL, not behind a subpath: it is three string\n// functions with no imports at all, so there is no optional dependency for a\n// subpath to fence off and nothing for a bundler to fail to shake out.\n\n/**\n * normalizeBasePath canonicalises a raw base-path value: ensure a leading\n * \"/\", drop any trailing \"/\". \"\" and \"/\" both mean \"served from the host\n * root\" and both normalise to \"\".\n *\n * normalizeBasePath(\"admin\") -> \"/admin\"\n * normalizeBasePath(\"/admin/\") -> \"/admin\"\n * normalizeBasePath(\"/\") -> \"\"\n */\nexport function normalizeBasePath(raw: string): string {\n if (!raw || raw === \"/\") return \"\";\n const withLeading = raw.startsWith(\"/\") ? raw : `/${raw}`;\n return withLeading.endsWith(\"/\") ? withLeading.slice(0, -1) : withLeading;\n}\n\n/**\n * joinBasePath prefixes an app-internal path with an ALREADY-NORMALISED base\n * path (see normalizeBasePath).\n *\n * Contract:\n * - Idempotent: a path already under basePath is returned unchanged, so\n * double-wrapping never double-prefixes.\n * - Absolute http(s) URLs pass through untouched.\n * - Accepts paths with or without a leading \"/\".\n *\n * Examples (basePath \"/admin\"):\n * joinBasePath(\"/admin\", \"/billing/success\") -> \"/admin/billing/success\"\n * joinBasePath(\"/admin\", \"billing/success\") -> \"/admin/billing/success\"\n * joinBasePath(\"/admin\", \"/admin/billing\") -> \"/admin/billing\" (idempotent)\n * joinBasePath(\"/admin\", \"/\") -> \"/admin\"\n * joinBasePath(\"/admin\", \"https://x.test/y\") -> \"https://x.test/y\"\n *\n * Most callers want the bound one-argument form from createBasePath() rather\n * than repeating the prefix at every call site.\n */\nexport function joinBasePath(basePath: string, path: string): string {\n if (/^https?:\\/\\//i.test(path)) return path;\n const withLeading = path.startsWith(\"/\") ? path : `/${path}`;\n if (!basePath) return withLeading;\n if (withLeading === basePath || withLeading.startsWith(`${basePath}/`)) {\n return withLeading;\n }\n if (withLeading === \"/\") return basePath;\n return `${basePath}${withLeading}`;\n}\n\n/** What createBasePath() hands back: the normalised prefix and a joiner bound to it. */\nexport interface BasePathHelpers {\n /**\n * The effective mount prefix, normalised. \"\" means the frontend is served\n * from the host root.\n */\n BASE_PATH: string;\n /** joinBasePath, bound to BASE_PATH — the one-argument form apps call. */\n joinBasePath: (path: string) => string;\n}\n\n/**\n * createBasePath binds the prefix once, at module scope, and returns the pair\n * an app actually imports.\n *\n * This is the seam forge's generated `src/lib/basepath_gen.ts` is reduced to:\n *\n * export const { BASE_PATH, joinBasePath } = createBasePath(\n * process.env.NEXT_PUBLIC_BASE_PATH ?? \"/admin\",\n * );\n *\n * The `process.env.NEXT_PUBLIC_*` read STAYS in project code and cannot move\n * here: Next.js inlines those literals by substituting them into the sources\n * it compiles, so a library reading process.env at runtime finds nothing in a\n * browser bundle. The value is the project's; the behaviour is this package's.\n */\nexport function createBasePath(raw: string): BasePathHelpers {\n const BASE_PATH = normalizeBasePath(raw);\n return {\n BASE_PATH,\n joinBasePath: (path: string) => joinBasePath(BASE_PATH, path),\n };\n}\n"]}
@@ -0,0 +1,31 @@
1
+ import { Component, type ErrorInfo, type ReactElement, type ReactNode } from "react";
2
+ interface FallbackProps {
3
+ error: Error;
4
+ reset: () => void;
5
+ }
6
+ interface Props {
7
+ children: ReactNode;
8
+ /** Custom fallback UI. Defaults to DefaultErrorFallback. */
9
+ fallback?: (props: FallbackProps) => ReactNode;
10
+ /** Reporter hook — wire to telemetry (initClientTelemetry) or Sentry. */
11
+ onError?: (error: Error, info: ErrorInfo) => void;
12
+ /** When any value in this array changes, the boundary resets. */
13
+ resetKeys?: unknown[];
14
+ /** Compact single-line fallback (for small subtrees). */
15
+ compact?: boolean;
16
+ }
17
+ interface State {
18
+ error: Error | null;
19
+ }
20
+ export declare class RuntimeErrorBoundary extends Component<Props, State> {
21
+ constructor(props: Props);
22
+ static getDerivedStateFromError(error: Error): State;
23
+ componentDidCatch(error: Error, info: ErrorInfo): void;
24
+ componentDidUpdate(prev: Props): void;
25
+ reset: () => void;
26
+ render(): ReactNode;
27
+ }
28
+ export declare function DefaultErrorFallback({ error, reset, compact, }: FallbackProps & {
29
+ compact?: boolean;
30
+ }): ReactElement;
31
+ export {};
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
4
+ //
5
+ // A React error boundary with a designed fallback. Next.js file-convention
6
+ // boundaries (app/error.tsx, app/global-error.tsx) catch route render
7
+ // crashes; this component boundary lets you ALSO isolate a subtree — a
8
+ // dashboard widget, a <Resource> table — so one crashing panel degrades to a
9
+ // small fallback instead of taking the whole route (or the app) white.
10
+ import { Component, } from "react";
11
+ export class RuntimeErrorBoundary extends Component {
12
+ constructor(props) {
13
+ super(props);
14
+ this.state = { error: null };
15
+ }
16
+ static getDerivedStateFromError(error) {
17
+ return { error };
18
+ }
19
+ componentDidCatch(error, info) {
20
+ // Always log; the app can additionally forward to a sink via onError.
21
+ console.error("[runtime] error boundary caught:", error, info);
22
+ this.props.onError?.(error, info);
23
+ }
24
+ componentDidUpdate(prev) {
25
+ if (this.state.error && !shallowEqual(prev.resetKeys, this.props.resetKeys)) {
26
+ this.reset();
27
+ }
28
+ }
29
+ reset = () => {
30
+ this.setState({ error: null });
31
+ };
32
+ render() {
33
+ const { error } = this.state;
34
+ if (!error) {
35
+ return this.props.children;
36
+ }
37
+ if (this.props.fallback) {
38
+ return this.props.fallback({ error, reset: this.reset });
39
+ }
40
+ return (_jsx(DefaultErrorFallback, { error: error, reset: this.reset, compact: this.props.compact }));
41
+ }
42
+ }
43
+ function shallowEqual(a, b) {
44
+ if (a === b) {
45
+ return true;
46
+ }
47
+ if (!a || !b || a.length !== b.length) {
48
+ return false;
49
+ }
50
+ return a.every((v, i) => Object.is(v, b[i]));
51
+ }
52
+ // The `: ReactElement` here and on every other exported component is
53
+ // load-bearing, not decoration — see README, "One .d.ts for React 18 and 19".
54
+ // Inferring it makes the published declaration name a type whose SPELLING
55
+ // differs between the two @types/react majors this package supports.
56
+ export function DefaultErrorFallback({ error, reset, compact, }) {
57
+ if (compact) {
58
+ return (_jsxs("div", { role: "alert", className: "flex items-center justify-between gap-3 rounded-md border border-danger-border bg-danger-surface px-3 py-2 text-sm text-danger-ink", children: [_jsx("span", { className: "truncate", children: "Couldn't load this section." }), _jsx("button", { type: "button", onClick: reset, className: "shrink-0 font-medium text-danger underline hover:text-danger-hover", children: "Retry" })] }));
59
+ }
60
+ return (_jsxs("div", { role: "alert", className: "flex min-h-[12rem] flex-col items-center justify-center gap-3 rounded-lg border border-danger-border bg-danger-surface p-8 text-center", children: [_jsx("h2", { className: "text-lg font-semibold text-danger-ink", children: "Something went wrong" }), _jsx("p", { className: "max-w-md text-sm text-danger-ink", children: "This section ran into an unexpected error. You can try again \u2014 the rest of the app is still working." }), _jsx("p", { className: "max-w-md font-mono text-xs text-danger", children: error.message }), _jsx("button", { type: "button", onClick: reset, className: "mt-1 rounded-md bg-danger px-4 py-2 text-sm font-medium text-on-danger hover:bg-danger-hover", children: "Try again" })] }));
61
+ }
62
+ //# sourceMappingURL=error-boundary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-boundary.js","sourceRoot":"","sources":["../src/error-boundary.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\n// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// A React error boundary with a designed fallback. Next.js file-convention\n// boundaries (app/error.tsx, app/global-error.tsx) catch route render\n// crashes; this component boundary lets you ALSO isolate a subtree — a\n// dashboard widget, a <Resource> table — so one crashing panel degrades to a\n// small fallback instead of taking the whole route (or the app) white.\nimport {\n Component,\n type ErrorInfo,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\ninterface FallbackProps {\n error: Error;\n reset: () => void;\n}\n\ninterface Props {\n children: ReactNode;\n /** Custom fallback UI. Defaults to DefaultErrorFallback. */\n fallback?: (props: FallbackProps) => ReactNode;\n /** Reporter hook — wire to telemetry (initClientTelemetry) or Sentry. */\n onError?: (error: Error, info: ErrorInfo) => void;\n /** When any value in this array changes, the boundary resets. */\n resetKeys?: unknown[];\n /** Compact single-line fallback (for small subtrees). */\n compact?: boolean;\n}\n\ninterface State {\n error: Error | null;\n}\n\nexport class RuntimeErrorBoundary extends Component<Props, State> {\n constructor(props: Props) {\n super(props);\n this.state = { error: null };\n }\n\n static getDerivedStateFromError(error: Error): State {\n return { error };\n }\n\n componentDidCatch(error: Error, info: ErrorInfo): void {\n // Always log; the app can additionally forward to a sink via onError.\n console.error(\"[runtime] error boundary caught:\", error, info);\n this.props.onError?.(error, info);\n }\n\n componentDidUpdate(prev: Props): void {\n if (this.state.error && !shallowEqual(prev.resetKeys, this.props.resetKeys)) {\n this.reset();\n }\n }\n\n reset = (): void => {\n this.setState({ error: null });\n };\n\n render(): ReactNode {\n const { error } = this.state;\n if (!error) {\n return this.props.children;\n }\n if (this.props.fallback) {\n return this.props.fallback({ error, reset: this.reset });\n }\n return (\n <DefaultErrorFallback\n error={error}\n reset={this.reset}\n compact={this.props.compact}\n />\n );\n }\n}\n\nfunction shallowEqual(a?: unknown[], b?: unknown[]): boolean {\n if (a === b) {\n return true;\n }\n if (!a || !b || a.length !== b.length) {\n return false;\n }\n return a.every((v, i) => Object.is(v, b[i]));\n}\n\n// The `: ReactElement` here and on every other exported component is\n// load-bearing, not decoration — see README, \"One .d.ts for React 18 and 19\".\n// Inferring it makes the published declaration name a type whose SPELLING\n// differs between the two @types/react majors this package supports.\nexport function DefaultErrorFallback({\n error,\n reset,\n compact,\n}: FallbackProps & { compact?: boolean }): ReactElement {\n if (compact) {\n return (\n <div\n role=\"alert\"\n className=\"flex items-center justify-between gap-3 rounded-md border border-danger-border bg-danger-surface px-3 py-2 text-sm text-danger-ink\"\n >\n <span className=\"truncate\">Couldn&apos;t load this section.</span>\n <button\n type=\"button\"\n onClick={reset}\n className=\"shrink-0 font-medium text-danger underline hover:text-danger-hover\"\n >\n Retry\n </button>\n </div>\n );\n }\n return (\n <div\n role=\"alert\"\n className=\"flex min-h-[12rem] flex-col items-center justify-center gap-3 rounded-lg border border-danger-border bg-danger-surface p-8 text-center\"\n >\n <h2 className=\"text-lg font-semibold text-danger-ink\">\n Something went wrong\n </h2>\n <p className=\"max-w-md text-sm text-danger-ink\">\n This section ran into an unexpected error. You can try again — the rest\n of the app is still working.\n </p>\n <p className=\"max-w-md font-mono text-xs text-danger\">{error.message}</p>\n <button\n type=\"button\"\n onClick={reset}\n className=\"mt-1 rounded-md bg-danger px-4 py-2 text-sm font-medium text-on-danger hover:bg-danger-hover\"\n >\n Try again\n </button>\n </div>\n );\n}\n"]}
@@ -0,0 +1,90 @@
1
+ import { Code } from "@connectrpc/connect";
2
+ /**
3
+ * FORGE_ERROR_REASON_HEADER is the Connect trailer/metadata key carrying a
4
+ * stable, machine-readable reason code (snake_case) the backend sets for
5
+ * every error. UI/routing keys off `error.reason` instead of matching
6
+ * human-readable message strings — the message text is display-only and
7
+ * may change. The backend owns the setter; the frontend only reads it.
8
+ */
9
+ export declare const FORGE_ERROR_REASON_HEADER = "x-forge-error-reason";
10
+ /**
11
+ * stripServerFraming removes the transport framing that must never surface
12
+ * to a user: the leading "[code] " gRPC-code prefix connect-es puts on
13
+ * `ConnectError.message`. What survives is the message the server wrote,
14
+ * which is exactly what a user should read.
15
+ *
16
+ * This is the SINGLE implementation — a project never wants a different
17
+ * answer here, and a copy frozen into a scaffold could never be corrected
18
+ * once shipped. Idempotent: framing that has already been stripped strips
19
+ * to itself.
20
+ */
21
+ export declare function stripServerFraming(message: string): string;
22
+ /**
23
+ * userMessage — turn any thrown value into copy fit for end users.
24
+ *
25
+ * `ConnectError.message` prefixes the gRPC code ("[not_found] no such task");
26
+ * `rawMessage` is the server's human-readable text without that framing, and
27
+ * stripServerFraming drops the prefix from whichever of the two is available.
28
+ * Use this everywhere an error is SHOWN (banners, toasts) instead of
29
+ * `err.message`.
30
+ *
31
+ * Pass `fallback` to override the default copy for a surface that wants its
32
+ * own wording.
33
+ */
34
+ export declare function userMessage(err: unknown, fallback?: string): string;
35
+ /**
36
+ * ConnectClientError is the single typed error shape the app catches. It is
37
+ * a plain Error subclass so it works with React Query, error boundaries, and
38
+ * `instanceof` alike.
39
+ */
40
+ export declare class ConnectClientError extends Error {
41
+ /** Canonical Connect code name, e.g. "unavailable", "not_found". */
42
+ readonly code: string;
43
+ /** Numeric Connect code (Code enum), or undefined for non-Connect errors. */
44
+ readonly connectCode?: Code;
45
+ /** Approximate HTTP status, for telemetry and generic UI. */
46
+ readonly status: number;
47
+ /** True when a transparent retry is reasonable (transient failures). */
48
+ readonly retryable: boolean;
49
+ /**
50
+ * Stable machine-readable reason code from the backend
51
+ * (x-forge-error-reason metadata), or null. Key UI/routing off THIS,
52
+ * never off `message` — message text is display-only.
53
+ */
54
+ readonly reason: string | null;
55
+ constructor(args: {
56
+ message: string;
57
+ code: string;
58
+ connectCode?: Code;
59
+ status: number;
60
+ retryable: boolean;
61
+ reason?: string | null;
62
+ cause?: unknown;
63
+ });
64
+ /**
65
+ * `instanceof` matches on SHAPE, not on class identity — so every check
66
+ * spelled `err instanceof ConnectClientError`, here and in app code, keeps
67
+ * working when two copies of this package end up in one bundle (see
68
+ * {@link isConnectClientErrorShape}). Without it, normalizeError's
69
+ * pass-through guard misses an already-normalized error from the other
70
+ * copy; that error's `name` is "ConnectClientError" rather than
71
+ * "ConnectError", so it misses the ConnectError branch too and is re-wrapped
72
+ * through the generic Error branch — silently downgrading every error in the
73
+ * app to code "unknown", status 500, no connectCode and no reason.
74
+ * @connectrpc/connect does the same on ConnectError, for the same reason.
75
+ */
76
+ static [Symbol.hasInstance](v: unknown): boolean;
77
+ }
78
+ /** isRetryable reports whether a Connect code merits a transport retry. */
79
+ export declare function isRetryableCode(code: Code): boolean;
80
+ /**
81
+ * normalizeError coerces any thrown value into a ConnectClientError. Already-
82
+ * normalized errors pass through unchanged so the interceptor chain never
83
+ * double-wraps.
84
+ *
85
+ * Both `instanceof` checks below match on SHAPE rather than class identity —
86
+ * ours via ConnectClientError's Symbol.hasInstance, ConnectError's via the one
87
+ * @connectrpc/connect ships — so neither branch is missed when a second copy
88
+ * of either package lands in the bundle.
89
+ */
90
+ export declare function normalizeError(err: unknown): ConnectClientError;