@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.
- package/README.md +192 -0
- package/dist/basepath.d.ts +57 -0
- package/dist/basepath.js +102 -0
- package/dist/basepath.js.map +1 -0
- package/dist/error-boundary.d.ts +31 -0
- package/dist/error-boundary.js +62 -0
- package/dist/error-boundary.js.map +1 -0
- package/dist/errors.d.ts +90 -0
- package/dist/errors.js +199 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptors.d.ts +49 -0
- package/dist/interceptors.js +154 -0
- package/dist/interceptors.js.map +1 -0
- package/dist/mock-transport.d.ts +115 -0
- package/dist/mock-transport.js +320 -0
- package/dist/mock-transport.js.map +1 -0
- package/dist/otel.d.ts +31 -0
- package/dist/otel.js +94 -0
- package/dist/otel.js.map +1 -0
- package/dist/providers.d.ts +19 -0
- package/dist/providers.js +25 -0
- package/dist/providers.js.map +1 -0
- package/dist/resource.d.ts +33 -0
- package/dist/resource.js +77 -0
- package/dist/resource.js.map +1 -0
- package/dist/route-guard.d.ts +9 -0
- package/dist/route-guard.js +21 -0
- package/dist/route-guard.js.map +1 -0
- package/dist/service-hooks.d.ts +78 -0
- package/dist/service-hooks.js +112 -0
- package/dist/service-hooks.js.map +1 -0
- package/dist/session.d.ts +45 -0
- package/dist/session.js +79 -0
- package/dist/session.js.map +1 -0
- package/dist/telemetry.d.ts +25 -0
- package/dist/telemetry.js +128 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/toast-host.d.ts +36 -0
- package/dist/toast-host.js +34 -0
- package/dist/toast-host.js.map +1 -0
- package/dist/trace.d.ts +21 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/interceptors/package.json +7 -0
- 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;
|
package/dist/basepath.js
ADDED
|
@@ -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'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"]}
|
package/dist/errors.d.ts
ADDED
|
@@ -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;
|