@reliantlabs/forge-web-runtime 0.3.1 → 0.4.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.
@@ -1 +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 (\n this.state.error &&\n !shallowEqual(prev.resetKeys, this.props.resetKeys)\n ) {\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"]}
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\nimport { reportException } from \"./error-reporter.js\";\nimport { userMessage } from \"./errors.js\";\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 /** Extra reporter hook, called after the error is recorded with the browser telemetry SDK. */\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, and record with the browser telemetry SDK (a no-op when\n // none is running); the app can additionally forward to a sink via onError.\n console.error(\"[runtime] error boundary caught:\", error, info);\n reportException(error, {\n \"react.component_stack\": info.componentStack ?? \"\",\n \"error.handled_by\": \"RuntimeErrorBoundary\",\n });\n this.props.onError?.(error, info);\n }\n\n componentDidUpdate(prev: Props): void {\n if (\n this.state.error &&\n !shallowEqual(prev.resetKeys, this.props.resetKeys)\n ) {\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 {/* userMessage, never error.message — the same display rule as\n <Resource>'s error rung. */}\n <p className=\"max-w-md text-sm text-danger-ink\">{userMessage(error)}</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,27 @@
1
+ /** Span/log attribute values the recorder accepts. */
2
+ export type ExceptionAttributes = Record<string, string | number | boolean>;
3
+ type ExceptionReporter = (error: unknown, attributes?: ExceptionAttributes) => void;
4
+ /**
5
+ * Install (or clear, with null) the recorder reportException forwards to.
6
+ * Called by initBrowserTelemetry; an app has no reason to call it directly.
7
+ */
8
+ export declare function setExceptionReporter(next: ExceptionReporter | null): void;
9
+ /**
10
+ * reportException hands a caught error to the browser telemetry SDK, if one
11
+ * is running. Never throws: reporting an error must not become a second
12
+ * error.
13
+ *
14
+ * Returns true when the SDK has the error and false when no SDK is running —
15
+ * so a caller can fall back to console.error and an error never vanishes:
16
+ *
17
+ * if (!reportException(error)) console.error(error);
18
+ *
19
+ * Use it where React or Next catch an error for you (error boundaries,
20
+ * `error.tsx`). The SDK already records uncaught exceptions, unhandled
21
+ * rejections and anything passed to console.error, and React logs a
22
+ * boundary-caught error to console.error itself, so the recorder installed
23
+ * by `/telemetry` skips an error the SDK has already seen: one failure is
24
+ * one record, never two.
25
+ */
26
+ export declare function reportException(error: unknown, attributes?: ExceptionAttributes): boolean;
27
+ export {};
@@ -0,0 +1,55 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // The seam between "something threw" and "somebody recorded it".
4
+ //
5
+ // The error boundary and the Next.js error files live in the barrel, which
6
+ // every frontend imports — including the Vite SPA and React Native ones that
7
+ // never install the HyperDX browser SDK. So the barrel cannot import the SDK.
8
+ // It imports this dependency-free registry instead, and the `/telemetry`
9
+ // subpath (which does import the SDK) installs the real recorder here once the
10
+ // SDK has initialised. Until then — SSR, telemetry off, SDK still loading —
11
+ // reportException is a no-op, which is what makes it safe to call from any
12
+ // catch block unconditionally.
13
+ /**
14
+ * Reports which recorder is installed, if any. reportException's return
15
+ * value is "the telemetry SDK has this error" — true both when it was just
16
+ * recorded and when it had already been recorded by another route.
17
+ */
18
+ let reporter = null;
19
+ /**
20
+ * Install (or clear, with null) the recorder reportException forwards to.
21
+ * Called by initBrowserTelemetry; an app has no reason to call it directly.
22
+ */
23
+ export function setExceptionReporter(next) {
24
+ reporter = next;
25
+ }
26
+ /**
27
+ * reportException hands a caught error to the browser telemetry SDK, if one
28
+ * is running. Never throws: reporting an error must not become a second
29
+ * error.
30
+ *
31
+ * Returns true when the SDK has the error and false when no SDK is running —
32
+ * so a caller can fall back to console.error and an error never vanishes:
33
+ *
34
+ * if (!reportException(error)) console.error(error);
35
+ *
36
+ * Use it where React or Next catch an error for you (error boundaries,
37
+ * `error.tsx`). The SDK already records uncaught exceptions, unhandled
38
+ * rejections and anything passed to console.error, and React logs a
39
+ * boundary-caught error to console.error itself, so the recorder installed
40
+ * by `/telemetry` skips an error the SDK has already seen: one failure is
41
+ * one record, never two.
42
+ */
43
+ export function reportException(error, attributes) {
44
+ if (!reporter) {
45
+ return false;
46
+ }
47
+ try {
48
+ reporter(error, attributes);
49
+ }
50
+ catch {
51
+ // Telemetry must never break the app.
52
+ }
53
+ return true;
54
+ }
55
+ //# sourceMappingURL=error-reporter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-reporter.js","sourceRoot":"","sources":["../src/error-reporter.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,iEAAiE;AACjE,EAAE;AACF,2EAA2E;AAC3E,6EAA6E;AAC7E,8EAA8E;AAC9E,yEAAyE;AACzE,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,+BAA+B;AAU/B;;;;GAIG;AAEH,IAAI,QAAQ,GAA6B,IAAI,CAAC;AAE9C;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAA8B;IACjE,QAAQ,GAAG,IAAI,CAAC;AAClB,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,eAAe,CAC7B,KAAc,EACd,UAAgC;IAEhC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC;QACH,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,sCAAsC;IACxC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The seam between \"something threw\" and \"somebody recorded it\".\n//\n// The error boundary and the Next.js error files live in the barrel, which\n// every frontend imports — including the Vite SPA and React Native ones that\n// never install the HyperDX browser SDK. So the barrel cannot import the SDK.\n// It imports this dependency-free registry instead, and the `/telemetry`\n// subpath (which does import the SDK) installs the real recorder here once the\n// SDK has initialised. Until then — SSR, telemetry off, SDK still loading —\n// reportException is a no-op, which is what makes it safe to call from any\n// catch block unconditionally.\n\n/** Span/log attribute values the recorder accepts. */\nexport type ExceptionAttributes = Record<string, string | number | boolean>;\n\ntype ExceptionReporter = (\n error: unknown,\n attributes?: ExceptionAttributes,\n) => void;\n\n/**\n * Reports which recorder is installed, if any. reportException's return\n * value is \"the telemetry SDK has this error\" — true both when it was just\n * recorded and when it had already been recorded by another route.\n */\n\nlet reporter: ExceptionReporter | null = null;\n\n/**\n * Install (or clear, with null) the recorder reportException forwards to.\n * Called by initBrowserTelemetry; an app has no reason to call it directly.\n */\nexport function setExceptionReporter(next: ExceptionReporter | null): void {\n reporter = next;\n}\n\n/**\n * reportException hands a caught error to the browser telemetry SDK, if one\n * is running. Never throws: reporting an error must not become a second\n * error.\n *\n * Returns true when the SDK has the error and false when no SDK is running —\n * so a caller can fall back to console.error and an error never vanishes:\n *\n * if (!reportException(error)) console.error(error);\n *\n * Use it where React or Next catch an error for you (error boundaries,\n * `error.tsx`). The SDK already records uncaught exceptions, unhandled\n * rejections and anything passed to console.error, and React logs a\n * boundary-caught error to console.error itself, so the recorder installed\n * by `/telemetry` skips an error the SDK has already seen: one failure is\n * one record, never two.\n */\nexport function reportException(\n error: unknown,\n attributes?: ExceptionAttributes,\n): boolean {\n if (!reporter) {\n return false;\n }\n try {\n reporter(error, attributes);\n } catch {\n // Telemetry must never break the app.\n }\n return true;\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -13,6 +13,15 @@ export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normali
13
13
  * no imports, so there is no dependency footprint for a subpath to fence off.
14
14
  */
15
15
  export { createBasePath, joinBasePath, normalizeBasePath, type BasePathHelpers, } from "./basepath.js";
16
+ /**
17
+ * Money and basis points as integers: format minor units through Intl, and
18
+ * parse form input back to minor units without rounding.
19
+ *
20
+ * The web counterpart of forge/pkg/money — an int64 count of the currency's
21
+ * minor unit plus an ISO 4217 code, exact past 2^53. Barrel-exported for the
22
+ * same reason as the base-path helpers: it imports nothing.
23
+ */
24
+ export { basisPointsToInput, currencyMinorDigits, formatBasisPoints, formatMinorUnits, minorUnitsToInput, parseBasisPoints, parseMinorUnits, type BasisPointsOptions, type MoneyOptions, type ParseBasisPointsOptions, type ParseMoneyOptions, } from "./money.js";
16
25
  /** W3C trace-context propagation. */
17
26
  export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
18
27
  /** Session: the signed-in user, derived from the app's auth state and JWT claims. */
@@ -27,8 +36,12 @@ export { RuntimeToastHost, type RuntimeToast, type RuntimeToastHostProps, type R
27
36
  export { RouteGuard, type RouteGuardProps } from "./route-guard.js";
28
37
  /** Generic data-table container: the tristate ladder, filter and cursor pagination. */
29
38
  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";
39
+ /**
40
+ * Record a caught error with the browser telemetry SDK, when one is running.
41
+ * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives
42
+ * through the "/telemetry" subpath, never the barrel.
43
+ */
44
+ export { reportException, setExceptionReporter, type ExceptionAttributes, } from "./error-reporter.js";
32
45
  /**
33
46
  * Dev-only browser log forwarding: mirrors console output and uncaught errors
34
47
  * to the dev server, which prints them into
package/dist/index.js CHANGED
@@ -31,6 +31,15 @@ export { ConnectClientError, FORGE_ERROR_REASON_HEADER, isRetryableCode, normali
31
31
  * no imports, so there is no dependency footprint for a subpath to fence off.
32
32
  */
33
33
  export { createBasePath, joinBasePath, normalizeBasePath, } from "./basepath.js";
34
+ /**
35
+ * Money and basis points as integers: format minor units through Intl, and
36
+ * parse form input back to minor units without rounding.
37
+ *
38
+ * The web counterpart of forge/pkg/money — an int64 count of the currency's
39
+ * minor unit plus an ISO 4217 code, exact past 2^53. Barrel-exported for the
40
+ * same reason as the base-path helpers: it imports nothing.
41
+ */
42
+ export { basisPointsToInput, currencyMinorDigits, formatBasisPoints, formatMinorUnits, minorUnitsToInput, parseBasisPoints, parseMinorUnits, } from "./money.js";
34
43
  /** W3C trace-context propagation. */
35
44
  export { freshTraceparent, injectTraceContext, setTraceSampled, TRACEPARENT_RE, } from "./trace.js";
36
45
  /** Session: the signed-in user, derived from the app's auth state and JWT claims. */
@@ -45,8 +54,12 @@ export { RuntimeToastHost, } from "./toast-host.js";
45
54
  export { RouteGuard } from "./route-guard.js";
46
55
  /** Generic data-table container: the tristate ladder, filter and cursor pagination. */
47
56
  export { Resource, } from "./resource.js";
48
- /** Client RUM (opt-in export). */
49
- export { initClientTelemetry, } from "./telemetry.js";
57
+ /**
58
+ * Record a caught error with the browser telemetry SDK, when one is running.
59
+ * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives
60
+ * through the "/telemetry" subpath, never the barrel.
61
+ */
62
+ export { reportException, setExceptionReporter, } from "./error-reporter.js";
50
63
  /**
51
64
  * Dev-only browser log forwarding: mirrors console output and uncaught errors
52
65
  * to the dev server, which prints them into
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAChE,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,4CAA4C;AAE5C,yEAAyE;AACzE,6EAA6E;AAC7E,4EAA4E;AAC5E,4EAA4E;AAC5E,sEAAsE;AAEtE,mCAAmC;AACnC,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,GAEjB,MAAM,mBAAmB,CAAC;AAE3B;;;GAGG;AACH,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,iBAAiB,GAElB,MAAM,eAAe,CAAC;AAEvB,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;AAExB;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,GAGpB,MAAM,aAAa,CAAC","sourcesContent":["// @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Public barrel. Import batteries from \"@reliantlabs/forge-web-runtime\".\n//\n// Everything here is MECHANISM: the transport interceptor stack, the session,\n// the error boundary, the toast queue, the <Resource> tristate ladder, W3C\n// trace propagation, client RUM. Nothing here reaches into an application —\n// app-shaped values (auth state, event subscriptions, toast markup) arrive as\n// props, so an app can restyle or replace its own files freely.\n//\n// Presentation that a project is meant to own — the component library under\n// src/components/ui, globals.css, nav, layout — deliberately stays in the\n// scaffold and is NOT part of this package.\n\n// The per-group docs below are JSDoc, not `//`, on purpose: TypeScript's\n// declaration emit preserves JSDoc and DROPS line comments, and dist/ is now\n// the whole of what this package publishes. `forge project libraries` reads\n// this inventory out of the published barrel, and a consumer's editor shows\n// it on hover — both would get symbols with no explanation otherwise.\n\n/** Transport interceptor stack. */\nexport {\n authInterceptor,\n buildRuntimeInterceptors,\n errorNormalizeInterceptor,\n headerInterceptor,\n retryInterceptor,\n traceInterceptor,\n type RuntimeInterceptorConfig,\n} from \"./interceptors.js\";\n\n/**\n * Typed client errors, and the backend-framing strippers every surface that\n * SHOWS an error goes through.\n */\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n stripServerFraming,\n userMessage,\n} from \"./errors.js\";\n\n/**\n * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute\n * URLs handed to external systems that round-trip back into the app.\n *\n * Barrel-exported rather than behind a subpath: three string functions with\n * no imports, so there is no dependency footprint for a subpath to fence off.\n */\nexport {\n createBasePath,\n joinBasePath,\n normalizeBasePath,\n type BasePathHelpers,\n} from \"./basepath.js\";\n\n/** 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\n/**\n * Dev-only browser log forwarding: mirrors console output and uncaught errors\n * to the dev server, which prints them into\n * `.forge/logs/<env>/frontend_<name>.log`. No-ops unless `dev: true`, and the\n * receiving endpoint exists only in a dev server — so a production bundle\n * neither posts nor has anywhere to post to.\n */\nexport {\n DEV_LOG_ENDPOINT,\n devLoggingInstalled,\n installDevLogging,\n uninstallDevLogging,\n type DevLoggingOptions,\n type DevLogPayload,\n} from \"./devlog.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAChE,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,4CAA4C;AAE5C,yEAAyE;AACzE,6EAA6E;AAC7E,4EAA4E;AAC5E,4EAA4E;AAC5E,sEAAsE;AAEtE,mCAAmC;AACnC,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,GAEjB,MAAM,mBAAmB,CAAC;AAE3B;;;GAGG;AACH,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,iBAAiB,GAElB,MAAM,eAAe,CAAC;AAEvB;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,GAKhB,MAAM,YAAY,CAAC;AAEpB,qCAAqC;AACrC,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB,qFAAqF;AACrF,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,GAIX,MAAM,cAAc,CAAC;AAEtB,gFAAgF;AAChF,OAAO,EAAE,YAAY,EAA0B,MAAM,gBAAgB,CAAC;AAEtE,+EAA+E;AAC/E,OAAO,EACL,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAE7B,yEAAyE;AACzE,OAAO,EACL,gBAAgB,GAMjB,MAAM,iBAAiB,CAAC;AAEzB,gEAAgE;AAChE,OAAO,EAAE,UAAU,EAAwB,MAAM,kBAAkB,CAAC;AAEpE,uFAAuF;AACvF,OAAO,EACL,QAAQ,GAIT,MAAM,eAAe,CAAC;AAEvB;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,GAErB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,GAGpB,MAAM,aAAa,CAAC","sourcesContent":["// @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Public barrel. Import batteries from \"@reliantlabs/forge-web-runtime\".\n//\n// Everything here is MECHANISM: the transport interceptor stack, the session,\n// the error boundary, the toast queue, the <Resource> tristate ladder, W3C\n// trace propagation, client RUM. Nothing here reaches into an application —\n// app-shaped values (auth state, event subscriptions, toast markup) arrive as\n// props, so an app can restyle or replace its own files freely.\n//\n// Presentation that a project is meant to own — the component library under\n// src/components/ui, globals.css, nav, layout — deliberately stays in the\n// scaffold and is NOT part of this package.\n\n// The per-group docs below are JSDoc, not `//`, on purpose: TypeScript's\n// declaration emit preserves JSDoc and DROPS line comments, and dist/ is now\n// the whole of what this package publishes. `forge project libraries` reads\n// this inventory out of the published barrel, and a consumer's editor shows\n// it on hover — both would get symbols with no explanation otherwise.\n\n/** Transport interceptor stack. */\nexport {\n authInterceptor,\n buildRuntimeInterceptors,\n errorNormalizeInterceptor,\n headerInterceptor,\n retryInterceptor,\n traceInterceptor,\n type RuntimeInterceptorConfig,\n} from \"./interceptors.js\";\n\n/**\n * Typed client errors, and the backend-framing strippers every surface that\n * SHOWS an error goes through.\n */\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n stripServerFraming,\n userMessage,\n} from \"./errors.js\";\n\n/**\n * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute\n * URLs handed to external systems that round-trip back into the app.\n *\n * Barrel-exported rather than behind a subpath: three string functions with\n * no imports, so there is no dependency footprint for a subpath to fence off.\n */\nexport {\n createBasePath,\n joinBasePath,\n normalizeBasePath,\n type BasePathHelpers,\n} from \"./basepath.js\";\n\n/**\n * Money and basis points as integers: format minor units through Intl, and\n * parse form input back to minor units without rounding.\n *\n * The web counterpart of forge/pkg/money — an int64 count of the currency's\n * minor unit plus an ISO 4217 code, exact past 2^53. Barrel-exported for the\n * same reason as the base-path helpers: it imports nothing.\n */\nexport {\n basisPointsToInput,\n currencyMinorDigits,\n formatBasisPoints,\n formatMinorUnits,\n minorUnitsToInput,\n parseBasisPoints,\n parseMinorUnits,\n type BasisPointsOptions,\n type MoneyOptions,\n type ParseBasisPointsOptions,\n type ParseMoneyOptions,\n} from \"./money.js\";\n\n/** W3C trace-context propagation. */\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n TRACEPARENT_RE,\n} from \"./trace.js\";\n\n/** Session: the signed-in user, derived from the app's auth state and JWT claims. */\nexport {\n decodeJwt,\n SessionProvider,\n useSession,\n type JwtClaims,\n type Session,\n type SessionAuth,\n} from \"./session.js\";\n\n/** The app-shell provider set: session + error boundary + toast host in one. */\nexport { RuntimeShell, type RuntimeShellProps } from \"./providers.js\";\n\n/** Error boundary with a designed crash fallback, for a route or a subtree. */\nexport {\n DefaultErrorFallback,\n RuntimeErrorBoundary,\n} from \"./error-boundary.js\";\n\n/** The toast queue. The app supplies the event source and the markup. */\nexport {\n RuntimeToastHost,\n type RuntimeToast,\n type RuntimeToastHostProps,\n type RuntimeToastPayload,\n type RuntimeToastSink,\n type RuntimeToastVariant,\n} from \"./toast-host.js\";\n\n/** Route guarding: render a page only to a signed-in caller. */\nexport { RouteGuard, type RouteGuardProps } from \"./route-guard.js\";\n\n/** Generic data-table container: the tristate ladder, filter and cursor pagination. */\nexport {\n Resource,\n type ResourceColumn,\n type ResourceProps,\n type ResourceStatus,\n} from \"./resource.js\";\n\n/**\n * Record a caught error with the browser telemetry SDK, when one is running.\n * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives\n * through the \"/telemetry\" subpath, never the barrel.\n */\nexport {\n reportException,\n setExceptionReporter,\n type ExceptionAttributes,\n} from \"./error-reporter.js\";\n\n/**\n * Dev-only browser log forwarding: mirrors console output and uncaught errors\n * to the dev server, which prints them into\n * `.forge/logs/<env>/frontend_<name>.log`. No-ops unless `dev: true`, and the\n * receiving endpoint exists only in a dev server — so a production bundle\n * neither posts nor has anywhere to post to.\n */\nexport {\n DEV_LOG_ENDPOINT,\n devLoggingInstalled,\n installDevLogging,\n uninstallDevLogging,\n type DevLoggingOptions,\n type DevLogPayload,\n} from \"./devlog.js\";\n"]}
@@ -20,7 +20,14 @@ export interface MockScenarioRegistry {
20
20
  byName(name: string): MockScenario | undefined;
21
21
  defaultScenario: MockScenario;
22
22
  }
23
- /** One CRUD arm of an entity's dispatch table. */
23
+ /**
24
+ * One CRUD arm of an entity's dispatch table.
25
+ *
26
+ * The List arm needs nothing beyond these three fields to honour filters,
27
+ * paging and total_count: the REQUEST schema arrives on every call as the
28
+ * Connect method's `input`, and the entity schema is the element type of
29
+ * `itemsField` on `responseSchema`.
30
+ */
24
31
  export interface MockListRpc {
25
32
  rpc: string;
26
33
  responseSchema: DescMessage;
@@ -21,12 +21,18 @@
21
21
  // 4. Anything else rejects with Code.Unimplemented so callers see a
22
22
  // clear error instead of a silent empty response.
23
23
  //
24
+ // A fixture List answers the way the backend's generated CRUD List does
25
+ // (pkg/crud HandleList), so a filtered table, a paged table and a "how many"
26
+ // tile read the same against fixtures as against a server — see
27
+ // `serveList` for the exact contract. Custom (non-CRUD) RPCs are not
28
+ // interpreted at all; they stay hand-written scenario handlers.
29
+ //
24
30
  // Imported through the "@reliantlabs/forge-web-runtime/mock-transport" subpath,
25
31
  // never the barrel: a production bundle must be able to shake the fixtures
26
32
  // and this engine out entirely, which is why the generated `connect.ts`
27
33
  // reaches its own shim through a dynamic `require()` gated on the mock-mode
28
34
  // env var.
29
- import { create, } from "@bufbuild/protobuf";
35
+ import { create, ScalarType, } from "@bufbuild/protobuf";
30
36
  import { Code, ConnectError, } from "@connectrpc/connect";
31
37
  /**
32
38
  * Mutable session stores, keyed by descriptor identity.
@@ -117,6 +123,191 @@ function makeUnaryResponse(method, message) {
117
123
  trailer: new Headers(),
118
124
  };
119
125
  }
126
+ // ── List semantics ─────────────────────────────────────────────────────
127
+ //
128
+ // What the backend's generated List does, and therefore what the fixture
129
+ // List does (pkg/crud HandleList + the generated Filters closure):
130
+ //
131
+ // - Every `optional` request field that is SET filters by equality on the
132
+ // entity field of the same name. Enums compare by value. A field with
133
+ // IMPLICIT presence (no `optional`) is never a filter — the backend
134
+ // generates none for it, so the mock must not invent one.
135
+ // - `search` matches case-insensitively against the entity's string
136
+ // fields and enum value names (enum columns are stored as names).
137
+ // - `order_by` (snake_case column, comma-separated, optional ASC/DESC per
138
+ // column) plus `descending` sorts; an unknown column is InvalidArgument.
139
+ // - `page_size` defaults to 50 and is clamped to 100. `total_count` is the
140
+ // filtered count BEFORE paging. `next_page_token` is minted only for the
141
+ // default (primary-key) order — an ordered list is single-page on the
142
+ // backend, and a mock that paged it would hide that.
143
+ //
144
+ // Paging, ordering and search arrive through these request fields, by the
145
+ // names forge's CRUD List requests declare. They are never equality filters.
146
+ const LIST_CONTROL_FIELDS = new Set([
147
+ "pageSize",
148
+ "pageToken",
149
+ "orderBy",
150
+ "descending",
151
+ "search",
152
+ ]);
153
+ /** pkg/crud ListOp's defaults: 0 means 50, and no page exceeds 100. */
154
+ const DEFAULT_PAGE_SIZE = 50;
155
+ const MAX_PAGE_SIZE = 100;
156
+ /** FeatureSet_FieldPresence.EXPLICIT — proto3 `optional`, oneofs, messages. */
157
+ const EXPLICIT_PRESENCE = 1;
158
+ /** The entity message a List response repeats, read off its descriptor. */
159
+ function listItemSchema(list) {
160
+ const items = list.responseSchema.field[list.itemsField];
161
+ return items?.fieldKind === "list" && items.listKind === "message"
162
+ ? items.message
163
+ : undefined;
164
+ }
165
+ /** Scalars and enums compare by value; messages, bytes, lists and maps do not. */
166
+ function isComparable(field) {
167
+ if (field.fieldKind === "enum")
168
+ return true;
169
+ return field.fieldKind === "scalar" && field.scalar !== ScalarType.BYTES;
170
+ }
171
+ function sameValue(a, b) {
172
+ if (a === b)
173
+ return true;
174
+ // A 64-bit field is a bigint — or a string under jstype=JS_STRING — and
175
+ // the two sides are not guaranteed to agree on which.
176
+ if (a == null || b == null)
177
+ return false;
178
+ return ((typeof a === "bigint" || typeof b === "bigint") && String(a) === String(b));
179
+ }
180
+ function enumName(field, value) {
181
+ return field.fieldKind === "enum"
182
+ ? field.enum.values.find((v) => v.number === value)?.name
183
+ : undefined;
184
+ }
185
+ /** The value a column sorts by, as Postgres would order the stored column. */
186
+ function sortKey(field, value) {
187
+ if (value == null)
188
+ return undefined;
189
+ // Enum columns hold the value NAME, so they sort alphabetically by name.
190
+ if (field.fieldKind === "enum")
191
+ return enumName(field, value) ?? "";
192
+ if (field.fieldKind === "scalar")
193
+ return value;
194
+ if (field.fieldKind === "message" &&
195
+ field.message.typeName === "google.protobuf.Timestamp") {
196
+ const ts = value;
197
+ return BigInt(ts.seconds) * 1000000000n + BigInt(ts.nanos);
198
+ }
199
+ return undefined;
200
+ }
201
+ /** Postgres's default: NULL sorts after every value ascending. */
202
+ function compareKeys(a, b) {
203
+ if (a === undefined || b === undefined) {
204
+ return a === b ? 0 : a === undefined ? 1 : -1;
205
+ }
206
+ const x = a;
207
+ const y = b;
208
+ return x < y ? -1 : x > y ? 1 : 0;
209
+ }
210
+ function invalidArgument(message) {
211
+ return new ConnectError(`mock-transport: ${message}`, Code.InvalidArgument);
212
+ }
213
+ /** Parse `order_by` the way orm.ValidateOrderBy reads it. */
214
+ function parseOrderBy(clause, descending, itemSchema) {
215
+ return clause.split(",").map((part) => {
216
+ const [column = "", direction] = part.trim().split(/\s+/);
217
+ const field = itemSchema.fields.find((f) => f.name === column || f.localName === column);
218
+ if (!field) {
219
+ throw invalidArgument(`unknown order-by column "${column}"`);
220
+ }
221
+ const dir = direction?.toUpperCase();
222
+ if (dir !== undefined && dir !== "ASC" && dir !== "DESC") {
223
+ throw invalidArgument(`invalid order-by direction "${direction}"`);
224
+ }
225
+ return { field, desc: dir === undefined ? descending : dir === "DESC" };
226
+ });
227
+ }
228
+ /** A count in whatever JS type the response's total_count field takes. */
229
+ function countValue(field, n) {
230
+ if (field.fieldKind !== "scalar")
231
+ return n;
232
+ switch (field.scalar) {
233
+ case ScalarType.INT64:
234
+ case ScalarType.UINT64:
235
+ case ScalarType.SINT64:
236
+ case ScalarType.FIXED64:
237
+ case ScalarType.SFIXED64:
238
+ return field.longAsString ? String(n) : BigInt(n);
239
+ default:
240
+ return n;
241
+ }
242
+ }
243
+ /**
244
+ * Answer one List call from the entity's rows. See the List semantics note
245
+ * above for the contract; the engine's tests pin each clause.
246
+ */
247
+ function serveList(rows, list, input, requestSchema) {
248
+ const req = (input ?? {});
249
+ const itemSchema = listItemSchema(list);
250
+ let matched = rows;
251
+ if (itemSchema && requestSchema) {
252
+ for (const filter of requestSchema.fields) {
253
+ if (LIST_CONTROL_FIELDS.has(filter.localName))
254
+ continue;
255
+ if (filter.presence !== EXPLICIT_PRESENCE || !isComparable(filter)) {
256
+ continue;
257
+ }
258
+ const want = req[filter.localName];
259
+ if (want == null)
260
+ continue; // unset: no filter
261
+ const target = itemSchema.field[filter.localName];
262
+ if (!target || !isComparable(target))
263
+ continue;
264
+ matched = matched.filter((row) => sameValue(row[filter.localName], want));
265
+ }
266
+ }
267
+ const search = typeof req.search === "string" ? req.search.trim().toLowerCase() : "";
268
+ if (itemSchema && search) {
269
+ const searchable = itemSchema.fields.filter((f) => f.fieldKind === "enum" ||
270
+ (f.fieldKind === "scalar" && f.scalar === ScalarType.STRING));
271
+ matched = matched.filter((row) => searchable.some((f) => {
272
+ const value = row[f.localName];
273
+ const text = f.fieldKind === "enum" ? enumName(f, value) : value;
274
+ return typeof text === "string" && text.toLowerCase().includes(search);
275
+ }));
276
+ }
277
+ const orderBy = typeof req.orderBy === "string" ? req.orderBy.trim() : "";
278
+ if (itemSchema && orderBy) {
279
+ const keys = parseOrderBy(orderBy, req.descending === true, itemSchema);
280
+ matched = [...matched].sort((a, b) => {
281
+ for (const { field, desc } of keys) {
282
+ const c = compareKeys(sortKey(field, a[field.localName]), sortKey(field, b[field.localName]));
283
+ if (c !== 0)
284
+ return desc ? -c : c;
285
+ }
286
+ return 0;
287
+ });
288
+ }
289
+ const total = matched.length;
290
+ let offset = 0;
291
+ const token = typeof req.pageToken === "string" ? req.pageToken : "";
292
+ if (token) {
293
+ // The mock's own cursor: an offset into the filtered rows. Opaque to the
294
+ // client, exactly like the backend's keyset cursor.
295
+ if (!/^\d+$/.test(token))
296
+ throw invalidArgument("invalid page token");
297
+ offset = Number(token);
298
+ }
299
+ const requested = Number(req.pageSize ?? 0);
300
+ const pageSize = requested > 0 ? Math.min(requested, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
301
+ const end = offset + pageSize;
302
+ const init = { [list.itemsField]: matched.slice(offset, end) };
303
+ const totalField = list.responseSchema.field.totalCount;
304
+ if (totalField)
305
+ init.totalCount = countValue(totalField, total);
306
+ if (list.responseSchema.field.nextPageToken && !orderBy && end < total) {
307
+ init.nextPageToken = String(end);
308
+ }
309
+ return create(list.responseSchema, init);
310
+ }
120
311
  /**
121
312
  * Compile the entity descriptors into a flat `${service}/${rpc}` → handler
122
313
  * map. Building it once per transport keeps per-RPC dispatch to a single
@@ -128,10 +319,8 @@ function buildFixtureTable(entities) {
128
319
  const pk = entity.pkField;
129
320
  const key = (rpc) => `${entity.service}/${rpc}`;
130
321
  if (entity.list) {
131
- const { rpc, responseSchema, itemsField } = entity.list;
132
- table.set(key(rpc), () => create(responseSchema, {
133
- [itemsField]: Array.from(storeFor(entity).values()),
134
- }));
322
+ const list = entity.list;
323
+ table.set(key(list.rpc), (input, requestSchema) => serveList(Array.from(storeFor(entity).values()), list, input, requestSchema));
135
324
  }
136
325
  if (entity.get) {
137
326
  const { rpc, responseSchema, entityField } = entity.get;
@@ -262,7 +451,10 @@ export function createMockTransport(options) {
262
451
  // (Code.NotFound), never a silent wrong-record fallback.
263
452
  const fixture = fixtures.get(key);
264
453
  if (fixture) {
265
- return makeUnaryResponse(method, fixture(input));
454
+ // `method.input` is typed as always present, but a hand-built method
455
+ // object (a test, a custom harness) may omit it.
456
+ const requestSchema = method.input;
457
+ return makeUnaryResponse(method, fixture(input, requestSchema));
266
458
  }
267
459
  // 4) Nothing matched. Surface a clear error instead of hanging.
268
460
  return Promise.reject(new ConnectError(`mock-transport: no scenario handler or entity fixture for ${key}`, Code.Unimplemented));