@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
@@ -0,0 +1,19 @@
1
+ import type { RuntimeToastHostProps } from "./toast-host.js";
2
+ import type { SessionAuth } from "./session.js";
3
+ import type { ErrorInfo, ReactElement, ReactNode } from "react";
4
+ export interface RuntimeShellProps {
5
+ children: ReactNode;
6
+ /**
7
+ * Current auth state — pass your own `useAuth()` value. The runtime derives
8
+ * the session from it rather than importing your auth module.
9
+ */
10
+ auth: SessionAuth;
11
+ /**
12
+ * Toast wiring: how to subscribe to your event bus and how to render your
13
+ * toast component. Omit it and no toast host is mounted.
14
+ */
15
+ toast?: RuntimeToastHostProps;
16
+ /** Forwarded to the top-level error boundary (wire to telemetry). */
17
+ onError?: (error: Error, info: ErrorInfo) => void;
18
+ }
19
+ export declare function RuntimeShell({ children, auth, toast, onError, }: RuntimeShellProps): ReactElement;
@@ -0,0 +1,25 @@
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
+ // RuntimeShell is the provider set the app shell mounts INSIDE its existing
6
+ // providers (QueryClient + event bus + auth). It adds, in one place:
7
+ //
8
+ // - SessionProvider — the signed-in user derived from the JWT
9
+ // - RuntimeErrorBoundary — a designed crash fallback (not a white screen)
10
+ // - RuntimeToastHost — the toast queue behind "toast:show" events
11
+ // (mutation errors, success toasts) that were
12
+ // previously emitted into the void
13
+ //
14
+ // Everything app-shaped arrives as a PROP: the auth state, the event
15
+ // subscription, the toast markup. The runtime never reaches into src/lib or
16
+ // src/components, so renaming or deleting those files can never break it.
17
+ //
18
+ // Keep the shell thin: compose here, don't grow app logic into it.
19
+ import { RuntimeErrorBoundary } from "./error-boundary.js";
20
+ import { SessionProvider } from "./session.js";
21
+ import { RuntimeToastHost } from "./toast-host.js";
22
+ export function RuntimeShell({ children, auth, toast, onError, }) {
23
+ return (_jsxs(SessionProvider, { auth: auth, children: [_jsx(RuntimeErrorBoundary, { onError: onError, children: children }), toast ? (_jsx(RuntimeToastHost, { subscribe: toast.subscribe, render: toast.render })) : null] }));
24
+ }
25
+ //# sourceMappingURL=providers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"providers.js","sourceRoot":"","sources":["../src/providers.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,sEAAsE;AACtE,EAAE;AACF,4EAA4E;AAC5E,qEAAqE;AACrE,EAAE;AACF,oEAAoE;AACpE,4EAA4E;AAC5E,wEAAwE;AACxE,yEAAyE;AACzE,8DAA8D;AAC9D,EAAE;AACF,qEAAqE;AACrE,4EAA4E;AAC5E,0EAA0E;AAC1E,EAAE;AACF,mEAAmE;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAsBnD,MAAM,UAAU,YAAY,CAAC,EAC3B,QAAQ,EACR,IAAI,EACJ,KAAK,EACL,OAAO,GACW;IAClB,OAAO,CACL,MAAC,eAAe,IAAC,IAAI,EAAE,IAAI,aACzB,KAAC,oBAAoB,IAAC,OAAO,EAAE,OAAO,YAAG,QAAQ,GAAwB,EACxE,KAAK,CAAC,CAAC,CAAC,CACP,KAAC,gBAAgB,IAAC,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,GAAI,CACvE,CAAC,CAAC,CAAC,IAAI,IACQ,CACnB,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// RuntimeShell is the provider set the app shell mounts INSIDE its existing\n// providers (QueryClient + event bus + auth). It adds, in one place:\n//\n// - SessionProvider — the signed-in user derived from the JWT\n// - RuntimeErrorBoundary — a designed crash fallback (not a white screen)\n// - RuntimeToastHost — the toast queue behind \"toast:show\" events\n// (mutation errors, success toasts) that were\n// previously emitted into the void\n//\n// Everything app-shaped arrives as a PROP: the auth state, the event\n// subscription, the toast markup. The runtime never reaches into src/lib or\n// src/components, so renaming or deleting those files can never break it.\n//\n// Keep the shell thin: compose here, don't grow app logic into it.\nimport { RuntimeErrorBoundary } from \"./error-boundary.js\";\nimport { SessionProvider } from \"./session.js\";\nimport { RuntimeToastHost } from \"./toast-host.js\";\n\nimport type { RuntimeToastHostProps } from \"./toast-host.js\";\nimport type { SessionAuth } from \"./session.js\";\nimport type { ErrorInfo, ReactElement, ReactNode } from \"react\";\n\nexport interface RuntimeShellProps {\n children: ReactNode;\n /**\n * Current auth state — pass your own `useAuth()` value. The runtime derives\n * the session from it rather than importing your auth module.\n */\n auth: SessionAuth;\n /**\n * Toast wiring: how to subscribe to your event bus and how to render your\n * toast component. Omit it and no toast host is mounted.\n */\n toast?: RuntimeToastHostProps;\n /** Forwarded to the top-level error boundary (wire to telemetry). */\n onError?: (error: Error, info: ErrorInfo) => void;\n}\n\nexport function RuntimeShell({\n children,\n auth,\n toast,\n onError,\n}: RuntimeShellProps): ReactElement {\n return (\n <SessionProvider auth={auth}>\n <RuntimeErrorBoundary onError={onError}>{children}</RuntimeErrorBoundary>\n {toast ? (\n <RuntimeToastHost subscribe={toast.subscribe} render={toast.render} />\n ) : null}\n </SessionProvider>\n );\n}\n"]}
@@ -0,0 +1,33 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export interface ResourceColumn<T> {
3
+ header: string;
4
+ cell: (row: T) => ReactNode;
5
+ /** Extra classes for the cell + header (alignment, width). */
6
+ className?: string;
7
+ }
8
+ export type ResourceStatus = "loading" | "error" | "success";
9
+ export interface ResourceProps<T> {
10
+ status: ResourceStatus;
11
+ data: T[] | undefined;
12
+ columns: ResourceColumn<T>[];
13
+ rowKey: (row: T) => string;
14
+ title?: string;
15
+ /** Header-right slot, e.g. a "New" button. */
16
+ actions?: ReactNode;
17
+ error?: Error | null;
18
+ onRetry?: () => void;
19
+ onRowClick?: (row: T) => void;
20
+ emptyTitle?: string;
21
+ emptyMessage?: string;
22
+ filter?: string;
23
+ onFilterChange?: (value: string) => void;
24
+ filterPlaceholder?: string;
25
+ filterDebounceMs?: number;
26
+ onNextPage?: () => void;
27
+ onPrevPage?: () => void;
28
+ hasNextPage?: boolean;
29
+ hasPrevPage?: boolean;
30
+ /** True during a background refetch (page change / filter change). */
31
+ isFetching?: boolean;
32
+ }
33
+ export declare function Resource<T>({ status, data, columns, rowKey, title, actions, error, onRetry, onRowClick, emptyTitle, emptyMessage, filter, onFilterChange, filterPlaceholder, filterDebounceMs, onNextPage, onPrevPage, hasNextPage, hasPrevPage, isFetching, }: ResourceProps<T>): ReactElement;
@@ -0,0 +1,77 @@
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
+ // <Resource> — the owned-once data-table container. It encapsulates the
6
+ // loading / error / empty / data tristate ladder, a server-side (debounced)
7
+ // filter, and cursor pagination, so list pages stop re-hand-rolling the
8
+ // ladder and stop client-side-filtering a single page cap.
9
+ //
10
+ // Pairs with the app's useQueryResource hook: map a query result to
11
+ // { status, data, error } and hand it here. Pagination is
12
+ // cursor-based (page_token / next_page_token in Connect list RPCs): the page
13
+ // owns the cursor stack and passes onNextPage/onPrevPage.
14
+ import { useEffect, useState, } from "react";
15
+ export function Resource({ status, data, columns, rowKey, title, actions, error, onRetry, onRowClick, emptyTitle = "Nothing here yet", emptyMessage = "No records match.", filter, onFilterChange, filterPlaceholder = "Search…", filterDebounceMs = 300, onNextPage, onPrevPage, hasNextPage, hasPrevPage, isFetching, }) {
16
+ const [query, setQuery] = useState(filter ?? "");
17
+ // Keep local input in sync when the parent resets the filter externally.
18
+ useEffect(() => {
19
+ setQuery(filter ?? "");
20
+ }, [filter]);
21
+ // Debounce filter changes so each keystroke doesn't fire an RPC.
22
+ useEffect(() => {
23
+ if (!onFilterChange || query === (filter ?? "")) {
24
+ return;
25
+ }
26
+ const handle = setTimeout(() => onFilterChange(query), filterDebounceMs);
27
+ return () => clearTimeout(handle);
28
+ }, [query, filter, onFilterChange, filterDebounceMs]);
29
+ const showFooter = Boolean(onNextPage || onPrevPage);
30
+ const colCount = columns.length;
31
+ const captionText = title ?? "Results";
32
+ return (_jsxs("section", { className: "flex flex-col gap-4", children: [(title || actions) && (_jsxs("header", { className: "flex items-center justify-between gap-4", children: [title ? (_jsx("h1", { className: "text-xl font-semibold text-ink", children: title })) : (_jsx("span", {})), actions ? _jsx("div", { className: "flex items-center gap-2", children: actions }) : null] })), onFilterChange ? (_jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: filterPlaceholder, "aria-label": title ? `Search ${title.toLowerCase()}` : "Search", className: "w-full max-w-sm rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink shadow-sm focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" })) : null, _jsx("div", { className: "overflow-x-auto rounded-lg border border-border bg-surface", children: _jsxs("table", { className: "min-w-full divide-y divide-border text-sm", children: [_jsx("caption", { className: "sr-only", children: captionText }), _jsx("thead", { className: "bg-surface-muted", children: _jsx("tr", { children: columns.map((col, i) => (_jsx("th", { scope: "col", className: `px-4 py-2.5 text-left font-medium text-ink-muted ${col.className ?? ""}`, children: col.header }, i))) }) }), _jsx("tbody", { className: "divide-y divide-border", children: status === "loading" ? (_jsx(SkeletonRows, { rows: 5, cols: colCount })) : status === "error" ? (_jsx("tr", { children: _jsx("td", { colSpan: colCount, className: "px-4 py-10", children: _jsx(ErrorState, { error: error, onRetry: onRetry }) }) })) : !data || data.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: colCount, className: "px-4 py-12", children: _jsx(EmptyState, { title: emptyTitle, message: emptyMessage }) }) })) : (data.map((row) => (_jsx("tr", { onClick: onRowClick ? () => onRowClick(row) : undefined,
33
+ // Keyboard-operable when the row navigates: Enter/Space
34
+ // activate it, and it takes focus in tab order.
35
+ role: onRowClick ? "button" : undefined, tabIndex: onRowClick ? 0 : undefined, onKeyDown: onRowClick
36
+ ? (e) => {
37
+ if (e.key === "Enter" || e.key === " ") {
38
+ e.preventDefault();
39
+ onRowClick(row);
40
+ }
41
+ }
42
+ : undefined, className: onRowClick
43
+ ? "cursor-pointer transition-colors hover:bg-surface-muted focus:bg-surface-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
44
+ : undefined, children: columns.map((col, i) => (_jsx("td", { className: `px-4 py-2.5 text-ink ${col.className ?? ""}`, children: col.cell(row) }, i))) }, rowKey(row))))) })] }) }), showFooter ? (_jsxs("footer", { className: "flex items-center justify-end gap-2", children: [isFetching ? (_jsx("span", { className: "mr-auto text-xs text-ink-subtle", children: "Loading\u2026" })) : null, _jsx("button", { type: "button", onClick: onPrevPage, disabled: !hasPrevPage, className: "rounded-md border border-border px-3 py-1.5 text-sm font-medium text-ink-muted hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-40", children: "Previous" }), _jsx("button", { type: "button", onClick: onNextPage, disabled: !hasNextPage, className: "rounded-md border border-border px-3 py-1.5 text-sm font-medium text-ink-muted hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-40", children: "Next" })] })) : null] }));
45
+ }
46
+ // SkeletonRows is the loading rung of the tristate ladder: a shimmer the
47
+ // width of the table while the first page is in flight.
48
+ //
49
+ // It is ONE row with a colSpan cell, matching the error and empty rungs
50
+ // above — not `rows` × `cols` real table cells. That shape is what makes it
51
+ // accessible, and the reasoning is worth keeping:
52
+ //
53
+ // - The shimmer is pure decoration. It carries no data, so it is wrapped
54
+ // in aria-hidden and the cell carries one sr-only "Loading…" instead.
55
+ // Assistive tech hears the state once, not once per placeholder.
56
+ // - A grid of empty <td>s announced as a table of blank cells, and read
57
+ // as a real 5-row result set by anything counting rows. The placeholder
58
+ // geometry is now plain <div>s inside the single cell, so the table has
59
+ // exactly the structure it claims to have at every moment.
60
+ // - Empty cells also tripped jsx-a11y/control-has-associated-label under
61
+ // the eslint config forge scaffolds. That was the symptom that surfaced
62
+ // this; the row-shape mismatch above is the actual defect.
63
+ //
64
+ // aria-hidden goes on the inner <div>, never on the <td> or <tr>: jsx-a11y
65
+ // treats table elements as focusable, so hiding one is
66
+ // no-aria-hidden-on-focusable — correctly, since a hidden focusable node is
67
+ // a focus trap for screen-reader users.
68
+ function SkeletonRows({ rows, cols }) {
69
+ return (_jsx("tr", { children: _jsxs("td", { colSpan: cols, className: "p-0", children: [_jsx("span", { className: "sr-only", children: "Loading\u2026" }), _jsx("div", { "aria-hidden": "true", className: "divide-y divide-border", children: Array.from({ length: rows }).map((_, r) => (_jsx("div", { className: "flex items-center gap-4 px-4 py-3", children: Array.from({ length: cols }).map((__, c) => (_jsx("div", { className: "h-4 flex-1 animate-pulse rounded bg-surface-muted" }, c))) }, r))) })] }) }));
70
+ }
71
+ function ErrorState({ error, onRetry, }) {
72
+ return (_jsxs("div", { role: "alert", className: "flex flex-col items-center gap-2 text-center", children: [_jsx("p", { className: "text-sm font-medium text-danger-ink", children: "Couldn't load data" }), error?.message ? (_jsx("p", { className: "max-w-md font-mono text-xs text-danger", children: error.message })) : null, onRetry ? (_jsx("button", { type: "button", onClick: onRetry, className: "mt-1 rounded-md bg-danger px-3 py-1.5 text-sm font-medium text-on-danger hover:bg-danger-hover", children: "Try again" })) : null] }));
73
+ }
74
+ function EmptyState({ title, message }) {
75
+ return (_jsxs("div", { className: "flex flex-col items-center gap-1 text-center", children: [_jsx("p", { className: "text-sm font-medium text-ink", children: title }), _jsx("p", { className: "text-sm text-ink-muted", children: message })] }));
76
+ }
77
+ //# sourceMappingURL=resource.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.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// <Resource> — the owned-once data-table container. It encapsulates the\n// loading / error / empty / data tristate ladder, a server-side (debounced)\n// filter, and cursor pagination, so list pages stop re-hand-rolling the\n// ladder and stop client-side-filtering a single page cap.\n//\n// Pairs with the app's useQueryResource hook: map a query result to\n// { status, data, error } and hand it here. Pagination is\n// cursor-based (page_token / next_page_token in Connect list RPCs): the page\n// owns the cursor stack and passes onNextPage/onPrevPage.\nimport {\n useEffect,\n useState,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\nexport interface ResourceColumn<T> {\n header: string;\n cell: (row: T) => ReactNode;\n /** Extra classes for the cell + header (alignment, width). */\n className?: string;\n}\n\nexport type ResourceStatus = \"loading\" | \"error\" | \"success\";\n\nexport interface ResourceProps<T> {\n status: ResourceStatus;\n data: T[] | undefined;\n columns: ResourceColumn<T>[];\n rowKey: (row: T) => string;\n\n title?: string;\n /** Header-right slot, e.g. a \"New\" button. */\n actions?: ReactNode;\n error?: Error | null;\n onRetry?: () => void;\n onRowClick?: (row: T) => void;\n\n emptyTitle?: string;\n emptyMessage?: string;\n\n // Server-side filter (debounced). Omit onFilterChange to hide the search box.\n filter?: string;\n onFilterChange?: (value: string) => void;\n filterPlaceholder?: string;\n filterDebounceMs?: number;\n\n // Cursor pagination. Omit the handlers to hide the footer.\n onNextPage?: () => void;\n onPrevPage?: () => void;\n hasNextPage?: boolean;\n hasPrevPage?: boolean;\n /** True during a background refetch (page change / filter change). */\n isFetching?: boolean;\n}\n\nexport function Resource<T>({\n status,\n data,\n columns,\n rowKey,\n title,\n actions,\n error,\n onRetry,\n onRowClick,\n emptyTitle = \"Nothing here yet\",\n emptyMessage = \"No records match.\",\n filter,\n onFilterChange,\n filterPlaceholder = \"Search…\",\n filterDebounceMs = 300,\n onNextPage,\n onPrevPage,\n hasNextPage,\n hasPrevPage,\n isFetching,\n}: ResourceProps<T>): ReactElement {\n const [query, setQuery] = useState(filter ?? \"\");\n\n // Keep local input in sync when the parent resets the filter externally.\n useEffect(() => {\n setQuery(filter ?? \"\");\n }, [filter]);\n\n // Debounce filter changes so each keystroke doesn't fire an RPC.\n useEffect(() => {\n if (!onFilterChange || query === (filter ?? \"\")) {\n return;\n }\n const handle = setTimeout(() => onFilterChange(query), filterDebounceMs);\n return () => clearTimeout(handle);\n }, [query, filter, onFilterChange, filterDebounceMs]);\n\n const showFooter = Boolean(onNextPage || onPrevPage);\n const colCount = columns.length;\n const captionText = title ?? \"Results\";\n\n return (\n <section className=\"flex flex-col gap-4\">\n {(title || actions) && (\n <header className=\"flex items-center justify-between gap-4\">\n {title ? (\n <h1 className=\"text-xl font-semibold text-ink\">{title}</h1>\n ) : (\n <span />\n )}\n {actions ? <div className=\"flex items-center gap-2\">{actions}</div> : null}\n </header>\n )}\n\n {onFilterChange ? (\n <input\n type=\"search\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={filterPlaceholder}\n aria-label={title ? `Search ${title.toLowerCase()}` : \"Search\"}\n className=\"w-full max-w-sm rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink shadow-sm focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent\"\n />\n ) : null}\n\n <div className=\"overflow-x-auto rounded-lg border border-border bg-surface\">\n <table className=\"min-w-full divide-y divide-border text-sm\">\n <caption className=\"sr-only\">{captionText}</caption>\n <thead className=\"bg-surface-muted\">\n <tr>\n {columns.map((col, i) => (\n <th\n key={i}\n scope=\"col\"\n className={`px-4 py-2.5 text-left font-medium text-ink-muted ${col.className ?? \"\"}`}\n >\n {col.header}\n </th>\n ))}\n </tr>\n </thead>\n <tbody className=\"divide-y divide-border\">\n {status === \"loading\" ? (\n <SkeletonRows rows={5} cols={colCount} />\n ) : status === \"error\" ? (\n <tr>\n <td colSpan={colCount} className=\"px-4 py-10\">\n <ErrorState error={error} onRetry={onRetry} />\n </td>\n </tr>\n ) : !data || data.length === 0 ? (\n <tr>\n <td colSpan={colCount} className=\"px-4 py-12\">\n <EmptyState title={emptyTitle} message={emptyMessage} />\n </td>\n </tr>\n ) : (\n data.map((row) => (\n <tr\n key={rowKey(row)}\n onClick={onRowClick ? () => onRowClick(row) : undefined}\n // Keyboard-operable when the row navigates: Enter/Space\n // activate it, and it takes focus in tab order.\n role={onRowClick ? \"button\" : undefined}\n tabIndex={onRowClick ? 0 : undefined}\n onKeyDown={\n onRowClick\n ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onRowClick(row);\n }\n }\n : undefined\n }\n className={\n onRowClick\n ? \"cursor-pointer transition-colors hover:bg-surface-muted focus:bg-surface-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent\"\n : undefined\n }\n >\n {columns.map((col, i) => (\n <td\n key={i}\n className={`px-4 py-2.5 text-ink ${col.className ?? \"\"}`}\n >\n {col.cell(row)}\n </td>\n ))}\n </tr>\n ))\n )}\n </tbody>\n </table>\n </div>\n\n {showFooter ? (\n <footer className=\"flex items-center justify-end gap-2\">\n {isFetching ? (\n <span className=\"mr-auto text-xs text-ink-subtle\">Loading…</span>\n ) : null}\n <button\n type=\"button\"\n onClick={onPrevPage}\n disabled={!hasPrevPage}\n className=\"rounded-md border border-border px-3 py-1.5 text-sm font-medium text-ink-muted hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-40\"\n >\n Previous\n </button>\n <button\n type=\"button\"\n onClick={onNextPage}\n disabled={!hasNextPage}\n className=\"rounded-md border border-border px-3 py-1.5 text-sm font-medium text-ink-muted hover:bg-surface-muted disabled:cursor-not-allowed disabled:opacity-40\"\n >\n Next\n </button>\n </footer>\n ) : null}\n </section>\n );\n}\n\n// SkeletonRows is the loading rung of the tristate ladder: a shimmer the\n// width of the table while the first page is in flight.\n//\n// It is ONE row with a colSpan cell, matching the error and empty rungs\n// above — not `rows` × `cols` real table cells. That shape is what makes it\n// accessible, and the reasoning is worth keeping:\n//\n// - The shimmer is pure decoration. It carries no data, so it is wrapped\n// in aria-hidden and the cell carries one sr-only \"Loading…\" instead.\n// Assistive tech hears the state once, not once per placeholder.\n// - A grid of empty <td>s announced as a table of blank cells, and read\n// as a real 5-row result set by anything counting rows. The placeholder\n// geometry is now plain <div>s inside the single cell, so the table has\n// exactly the structure it claims to have at every moment.\n// - Empty cells also tripped jsx-a11y/control-has-associated-label under\n// the eslint config forge scaffolds. That was the symptom that surfaced\n// this; the row-shape mismatch above is the actual defect.\n//\n// aria-hidden goes on the inner <div>, never on the <td> or <tr>: jsx-a11y\n// treats table elements as focusable, so hiding one is\n// no-aria-hidden-on-focusable — correctly, since a hidden focusable node is\n// a focus trap for screen-reader users.\nfunction SkeletonRows({ rows, cols }: { rows: number; cols: number }) {\n return (\n <tr>\n <td colSpan={cols} className=\"p-0\">\n <span className=\"sr-only\">Loading…</span>\n <div aria-hidden=\"true\" className=\"divide-y divide-border\">\n {Array.from({ length: rows }).map((_, r) => (\n <div key={r} className=\"flex items-center gap-4 px-4 py-3\">\n {Array.from({ length: cols }).map((__, c) => (\n <div\n key={c}\n className=\"h-4 flex-1 animate-pulse rounded bg-surface-muted\"\n />\n ))}\n </div>\n ))}\n </div>\n </td>\n </tr>\n );\n}\n\nfunction ErrorState({\n error,\n onRetry,\n}: {\n error?: Error | null;\n onRetry?: () => void;\n}) {\n return (\n <div role=\"alert\" className=\"flex flex-col items-center gap-2 text-center\">\n <p className=\"text-sm font-medium text-danger-ink\">Couldn&apos;t load data</p>\n {error?.message ? (\n <p className=\"max-w-md font-mono text-xs text-danger\">{error.message}</p>\n ) : null}\n {onRetry ? (\n <button\n type=\"button\"\n onClick={onRetry}\n className=\"mt-1 rounded-md bg-danger px-3 py-1.5 text-sm font-medium text-on-danger hover:bg-danger-hover\"\n >\n Try again\n </button>\n ) : null}\n </div>\n );\n}\n\nfunction EmptyState({ title, message }: { title: string; message: string }) {\n return (\n <div className=\"flex flex-col items-center gap-1 text-center\">\n <p className=\"text-sm font-medium text-ink\">{title}</p>\n <p className=\"text-sm text-ink-muted\">{message}</p>\n </div>\n );\n}\n"]}
@@ -0,0 +1,9 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ export interface RouteGuardProps {
3
+ children: ReactNode;
4
+ /** Shown while the session is still loading. */
5
+ loading?: ReactNode;
6
+ /** Shown when nobody is signed in. Defaults to a simple notice. */
7
+ fallback?: ReactNode;
8
+ }
9
+ export declare function RouteGuard({ children, loading, fallback, }: RouteGuardProps): ReactElement;
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, 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
+ // Sign-in gating for a page or section, driven by the session forge already
6
+ // derives from the JWT. This gates RENDERING only — the backend remains the
7
+ // source of truth; a determined client can always call the RPC, and it will be
8
+ // rejected server-side. Wrap a page/section in <RouteGuard> to show it only to
9
+ // a signed-in caller.
10
+ import { useSession } from "./session.js";
11
+ export function RouteGuard({ children, loading, fallback, }) {
12
+ const session = useSession();
13
+ if (session.isLoading) {
14
+ return _jsx(_Fragment, { children: loading ?? null });
15
+ }
16
+ if (!session.isAuthenticated) {
17
+ return (_jsx(_Fragment, { children: fallback ?? (_jsxs("div", { className: "flex min-h-[12rem] flex-col items-center justify-center gap-1 text-center", children: [_jsx("p", { className: "text-sm font-medium text-ink", children: "Not signed in" }), _jsx("p", { className: "text-sm text-ink-muted", children: "Sign in to view this page." })] })) }));
18
+ }
19
+ return _jsx(_Fragment, { children: children });
20
+ }
21
+ //# sourceMappingURL=route-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-guard.js","sourceRoot":"","sources":["../src/route-guard.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,sEAAsE;AACtE,EAAE;AACF,4EAA4E;AAC5E,4EAA4E;AAC5E,+EAA+E;AAC/E,+EAA+E;AAC/E,sBAAsB;AACtB,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAY1C,MAAM,UAAU,UAAU,CAAC,EACzB,QAAQ,EACR,OAAO,EACP,QAAQ,GACQ;IAChB,MAAM,OAAO,GAAG,UAAU,EAAE,CAAC;IAE7B,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;QACtB,OAAO,4BAAG,OAAO,IAAI,IAAI,GAAI,CAAC;IAChC,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,eAAe,EAAE,CAAC;QAC7B,OAAO,CACL,4BACG,QAAQ,IAAI,CACX,eAAK,SAAS,EAAC,2EAA2E,aACxF,YAAG,SAAS,EAAC,8BAA8B,8BAAkB,EAC7D,YAAG,SAAS,EAAC,wBAAwB,2CAEjC,IACA,CACP,GACA,CACJ,CAAC;IACJ,CAAC;IACD,OAAO,4BAAG,QAAQ,GAAI,CAAC;AACzB,CAAC","sourcesContent":["\"use client\";\n\n// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Sign-in gating for a page or section, driven by the session forge already\n// derives from the JWT. This gates RENDERING only — the backend remains the\n// source of truth; a determined client can always call the RPC, and it will be\n// rejected server-side. Wrap a page/section in <RouteGuard> to show it only to\n// a signed-in caller.\nimport { useSession } from \"./session.js\";\n\nimport type { ReactElement, ReactNode } from \"react\";\n\nexport interface RouteGuardProps {\n children: ReactNode;\n /** Shown while the session is still loading. */\n loading?: ReactNode;\n /** Shown when nobody is signed in. Defaults to a simple notice. */\n fallback?: ReactNode;\n}\n\nexport function RouteGuard({\n children,\n loading,\n fallback,\n}: RouteGuardProps): ReactElement {\n const session = useSession();\n\n if (session.isLoading) {\n return <>{loading ?? null}</>;\n }\n if (!session.isAuthenticated) {\n return (\n <>\n {fallback ?? (\n <div className=\"flex min-h-[12rem] flex-col items-center justify-center gap-1 text-center\">\n <p className=\"text-sm font-medium text-ink\">Not signed in</p>\n <p className=\"text-sm text-ink-muted\">\n Sign in to view this page.\n </p>\n </div>\n )}\n </>\n );\n }\n return <>{children}</>;\n}\n"]}
@@ -0,0 +1,78 @@
1
+ import { type DescMessage, type MessageInitShape } from "@bufbuild/protobuf";
2
+ import { type UseMutationOptions, type UseMutationResult, type UseQueryOptions, type UseQueryResult } from "@tanstack/react-query";
3
+ import type { ConnectClientError } from "./errors.js";
4
+ /**
5
+ * A generated query key. The tuple is [service, entity?, method,
6
+ * protojson(req)] for a method key and [service] / [service, entity] for the
7
+ * broader scopes.
8
+ */
9
+ export type ServiceQueryKey = readonly unknown[];
10
+ /**
11
+ * requestKey projects a request init into protojson for use inside a query
12
+ * key. Two reasons this is not the raw request object:
13
+ *
14
+ * - React Query's default hasher is JSON.stringify, which THROWS on bigint
15
+ * fields (any proto int64) — the raw message crashes the cache.
16
+ * - protojson normalizes representations, so { id: 1 } and { id: "1" } land
17
+ * in ONE cache entry instead of silently splitting the cache.
18
+ *
19
+ * Exported because a hand-written hook that wants to share a generated hook's
20
+ * cache entry has to hash its request the identical way.
21
+ */
22
+ export declare function requestKey<Schema extends DescMessage>(schema: Schema, req: MessageInitShape<Schema>): unknown;
23
+ /** The three key-scope builders a generated key factory is assembled from. */
24
+ export interface ServiceKeyBuilders {
25
+ /** Every query on this service: [service]. */
26
+ readonly all: readonly [string];
27
+ /** Every query for one CRUD entity: [service, entity]. Mutations invalidate this. */
28
+ entity(name: string): readonly [string, string];
29
+ /**
30
+ * One query: [service, entity?, method, protojson(req)]. Returns the
31
+ * per-request key FUNCTION, which is what both the query hook and any
32
+ * call-site prefetch/setQueryData use.
33
+ */
34
+ query<Schema extends DescMessage>(method: string, schema: Schema, entityScope?: string): (req: MessageInitShape<Schema>) => ServiceQueryKey;
35
+ }
36
+ /**
37
+ * serviceKeys returns the builders for one service's query-key factory.
38
+ *
39
+ * Cache-key semantics are a compatibility contract, not an implementation
40
+ * detail: an existing app's
41
+ * `invalidateQueries({ queryKey: orderServiceKeys.order })` has to keep
42
+ * matching the keys its queries were registered under across a version bump.
43
+ * So the tuple order and contents produced here are fixed, and identical to
44
+ * what forge previously inlined into every generated hook file.
45
+ */
46
+ export declare function serviceKeys(service: string): ServiceKeyBuilders;
47
+ /** A generated query hook: `useGetOrder(req, options?)`. */
48
+ export type ServiceQueryHook<Schema extends DescMessage, Response> = (req: MessageInitShape<Schema>, options?: Partial<UseQueryOptions<Response, ConnectClientError>>) => UseQueryResult<Response, ConnectClientError>;
49
+ /** A generated mutation hook: `useCreateOrder(options?)`. */
50
+ export type ServiceMutationHook<Schema extends DescMessage, Response> = (options?: Partial<UseMutationOptions<Response, ConnectClientError, MessageInitShape<Schema>>>) => UseMutationResult<Response, ConnectClientError, MessageInitShape<Schema>>;
51
+ /**
52
+ * createQueryHook builds one useQuery wrapper.
53
+ *
54
+ * The signature it returns is the one call sites already use — `(req,
55
+ * options?)`, with the error typed as ConnectClientError rather than React
56
+ * Query's default `Error`. That type is declared on the OPTIONS parameter
57
+ * because React Query takes the result's error type from the same TError it
58
+ * reads there, so `query.error` is a ConnectClientError at every call site
59
+ * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`
60
+ * and `.retryable`, and left `err.message.includes(...)` as the only
61
+ * expression that compiled — the one thing the error contract forbids.
62
+ */
63
+ export declare function createQueryHook<Schema extends DescMessage, Response>(keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey, call: (req: MessageInitShape<Schema>) => Promise<Response>): ServiceQueryHook<Schema, Response>;
64
+ /**
65
+ * createMutationHook builds one useMutation wrapper with scoped invalidation.
66
+ *
67
+ * invalidateKey is the entity scope when the RPC maps to a CRUD entity, and
68
+ * the whole-service scope otherwise — a bespoke mutation may touch anything,
69
+ * so over-invalidating is the safe default there.
70
+ *
71
+ * The invalidation is composed BEFORE the caller's onSuccess and is never
72
+ * replaced by it: onSuccess is destructured out and re-invoked after the
73
+ * invalidate, while the rest of the options object is spread separately. A
74
+ * caller passing onSuccess therefore ADDS behaviour, rather than silently
75
+ * disabling the cache refresh — which is exactly what a plain `...options`
76
+ * spread over a default onSuccess would do.
77
+ */
78
+ export declare function createMutationHook<Schema extends DescMessage, Response>(invalidateKey: ServiceQueryKey, call: (req: MessageInitShape<Schema>) => Promise<Response>): ServiceMutationHook<Schema, Response>;
@@ -0,0 +1,112 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // The React Query wrapper machinery behind forge's generated per-service hook
4
+ // files (`src/hooks/<svc>-service-hooks_gen.ts`).
5
+ //
6
+ // WHAT WAS HERE BEFORE. Each generated file re-emitted the same three things
7
+ // per RPC: a query-key entry, a useQuery or useMutation wrapper, and — for
8
+ // mutations — the entity-scoped invalidateQueries plus the onSuccess
9
+ // composition, with the reasoning for each re-inlined as comments. A
10
+ // five-service project rendered five copies of machinery that differed only in
11
+ // RPC names and message types. This module IS that machinery, written once.
12
+ // The generated file keeps exactly what varies per RPC: the name, the request
13
+ // schema, the response type, query-vs-mutation, and the entity scope.
14
+ //
15
+ // WHY A SUBPATH AND NOT THE BARREL. This module imports @tanstack/react-query.
16
+ // Every browser frontend forge scaffolds depends on it — but the barrel is
17
+ // also what a consumer imports for interceptors and errors alone, and
18
+ // anchoring React Query there would make a non-React consumer resolve a
19
+ // package it never installed. Same reasoning as ./mock-transport and ./otel:
20
+ // an engine with its own dependency footprint gets its own entry point, and
21
+ // React Query is an OPTIONAL peer.
22
+ //
23
+ // WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts — the
24
+ // app-wide MutationCache in src/lib/query-client.ts is the single error-toast
25
+ // chokepoint, and pages that render errors inline pass
26
+ // `meta: { silenceErrorToast: true }`. It never defaults success-side UX
27
+ // either: "CreateOrder succeeded" leaks RPC names into the UI, so the toast /
28
+ // redirect / inline confirmation stays the call site's via options.onSuccess.
29
+ import { create, toJson, } from "@bufbuild/protobuf";
30
+ import { useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
31
+ /**
32
+ * requestKey projects a request init into protojson for use inside a query
33
+ * key. Two reasons this is not the raw request object:
34
+ *
35
+ * - React Query's default hasher is JSON.stringify, which THROWS on bigint
36
+ * fields (any proto int64) — the raw message crashes the cache.
37
+ * - protojson normalizes representations, so { id: 1 } and { id: "1" } land
38
+ * in ONE cache entry instead of silently splitting the cache.
39
+ *
40
+ * Exported because a hand-written hook that wants to share a generated hook's
41
+ * cache entry has to hash its request the identical way.
42
+ */
43
+ export function requestKey(schema, req) {
44
+ return toJson(schema, create(schema, req));
45
+ }
46
+ /**
47
+ * serviceKeys returns the builders for one service's query-key factory.
48
+ *
49
+ * Cache-key semantics are a compatibility contract, not an implementation
50
+ * detail: an existing app's
51
+ * `invalidateQueries({ queryKey: orderServiceKeys.order })` has to keep
52
+ * matching the keys its queries were registered under across a version bump.
53
+ * So the tuple order and contents produced here are fixed, and identical to
54
+ * what forge previously inlined into every generated hook file.
55
+ */
56
+ export function serviceKeys(service) {
57
+ return {
58
+ all: [service],
59
+ entity: (name) => [service, name],
60
+ query: (method, schema, entityScope) => (req) => entityScope === undefined
61
+ ? [service, method, requestKey(schema, req)]
62
+ : [service, entityScope, method, requestKey(schema, req)],
63
+ };
64
+ }
65
+ /**
66
+ * createQueryHook builds one useQuery wrapper.
67
+ *
68
+ * The signature it returns is the one call sites already use — `(req,
69
+ * options?)`, with the error typed as ConnectClientError rather than React
70
+ * Query's default `Error`. That type is declared on the OPTIONS parameter
71
+ * because React Query takes the result's error type from the same TError it
72
+ * reads there, so `query.error` is a ConnectClientError at every call site
73
+ * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`
74
+ * and `.retryable`, and left `err.message.includes(...)` as the only
75
+ * expression that compiled — the one thing the error contract forbids.
76
+ */
77
+ export function createQueryHook(keyFor, call) {
78
+ return (req, options) => useQuery({
79
+ queryKey: keyFor(req),
80
+ queryFn: () => call(req),
81
+ ...options,
82
+ });
83
+ }
84
+ /**
85
+ * createMutationHook builds one useMutation wrapper with scoped invalidation.
86
+ *
87
+ * invalidateKey is the entity scope when the RPC maps to a CRUD entity, and
88
+ * the whole-service scope otherwise — a bespoke mutation may touch anything,
89
+ * so over-invalidating is the safe default there.
90
+ *
91
+ * The invalidation is composed BEFORE the caller's onSuccess and is never
92
+ * replaced by it: onSuccess is destructured out and re-invoked after the
93
+ * invalidate, while the rest of the options object is spread separately. A
94
+ * caller passing onSuccess therefore ADDS behaviour, rather than silently
95
+ * disabling the cache refresh — which is exactly what a plain `...options`
96
+ * spread over a default onSuccess would do.
97
+ */
98
+ export function createMutationHook(invalidateKey, call) {
99
+ return (options) => {
100
+ const queryClient = useQueryClient();
101
+ const { onSuccess, ...rest } = options ?? {};
102
+ return useMutation({
103
+ mutationFn: (req) => call(req),
104
+ onSuccess: (...args) => {
105
+ queryClient.invalidateQueries({ queryKey: invalidateKey });
106
+ onSuccess?.(...args);
107
+ },
108
+ ...rest,
109
+ });
110
+ };
111
+ }
112
+ //# sourceMappingURL=service-hooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-hooks.js","sourceRoot":"","sources":["../src/service-hooks.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,8EAA8E;AAC9E,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,qEAAqE;AACrE,qEAAqE;AACrE,+EAA+E;AAC/E,4EAA4E;AAC5E,8EAA8E;AAC9E,sEAAsE;AACtE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,wEAAwE;AACxE,6EAA6E;AAC7E,4EAA4E;AAC5E,mCAAmC;AACnC,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,uDAAuD;AACvD,yEAAyE;AACzE,8EAA8E;AAC9E,8EAA8E;AAC9E,OAAO,EACL,MAAM,EACN,MAAM,GAGP,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,WAAW,EACX,QAAQ,EACR,cAAc,GAKf,MAAM,uBAAuB,CAAC;AAW/B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,UAAU,CACxB,MAAc,EACd,GAA6B;IAE7B,OAAO,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAoBD;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO;QACL,GAAG,EAAE,CAAC,OAAO,CAAU;QACvB,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,CAAU;QAClD,KAAK,EACH,CACE,MAAc,EACd,MAAc,EACd,WAAoB,EACpB,EAAE,CACJ,CAAC,GAA6B,EAAmB,EAAE,CACjD,WAAW,KAAK,SAAS;YACvB,CAAC,CAAE,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;YACvD,CAAC,CAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;KAC3E,CAAC;AACJ,CAAC;AAeD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAC7B,MAA0D,EAC1D,IAA0D;IAE1D,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,EAAE,CACtB,QAAQ,CAAC;QACP,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC;QACrB,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;QACxB,GAAG,OAAO;KACX,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,kBAAkB,CAChC,aAA8B,EAC9B,IAA0D;IAE1D,OAAO,CAAC,OAAO,EAAE,EAAE;QACjB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;QACrC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,IAAI,EAAE,CAAC;QAC7C,OAAO,WAAW,CAAC;YACjB,UAAU,EAAE,CAAC,GAA6B,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;YACxD,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE;gBACrB,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,aAAa,EAAE,CAAC,CAAC;gBAC3D,SAAS,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;YACvB,CAAC;YACD,GAAG,IAAI;SACR,CAAC,CAAC;IACL,CAAC,CAAC;AACJ,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The React Query wrapper machinery behind forge's generated per-service hook\n// files (`src/hooks/<svc>-service-hooks_gen.ts`).\n//\n// WHAT WAS HERE BEFORE. Each generated file re-emitted the same three things\n// per RPC: a query-key entry, a useQuery or useMutation wrapper, and — for\n// mutations — the entity-scoped invalidateQueries plus the onSuccess\n// composition, with the reasoning for each re-inlined as comments. A\n// five-service project rendered five copies of machinery that differed only in\n// RPC names and message types. This module IS that machinery, written once.\n// The generated file keeps exactly what varies per RPC: the name, the request\n// schema, the response type, query-vs-mutation, and the entity scope.\n//\n// WHY A SUBPATH AND NOT THE BARREL. This module imports @tanstack/react-query.\n// Every browser frontend forge scaffolds depends on it — but the barrel is\n// also what a consumer imports for interceptors and errors alone, and\n// anchoring React Query there would make a non-React consumer resolve a\n// package it never installed. Same reasoning as ./mock-transport and ./otel:\n// an engine with its own dependency footprint gets its own entry point, and\n// React Query is an OPTIONAL peer.\n//\n// WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts — the\n// app-wide MutationCache in src/lib/query-client.ts is the single error-toast\n// chokepoint, and pages that render errors inline pass\n// `meta: { silenceErrorToast: true }`. It never defaults success-side UX\n// either: \"CreateOrder succeeded\" leaks RPC names into the UI, so the toast /\n// redirect / inline confirmation stays the call site's via options.onSuccess.\nimport {\n create,\n toJson,\n type DescMessage,\n type MessageInitShape,\n} from \"@bufbuild/protobuf\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationOptions,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\n\nimport type { ConnectClientError } from \"./errors.js\";\n\n/**\n * A generated query key. The tuple is [service, entity?, method,\n * protojson(req)] for a method key and [service] / [service, entity] for the\n * broader scopes.\n */\nexport type ServiceQueryKey = readonly unknown[];\n\n/**\n * requestKey projects a request init into protojson for use inside a query\n * key. Two reasons this is not the raw request object:\n *\n * - React Query's default hasher is JSON.stringify, which THROWS on bigint\n * fields (any proto int64) — the raw message crashes the cache.\n * - protojson normalizes representations, so { id: 1 } and { id: \"1\" } land\n * in ONE cache entry instead of silently splitting the cache.\n *\n * Exported because a hand-written hook that wants to share a generated hook's\n * cache entry has to hash its request the identical way.\n */\nexport function requestKey<Schema extends DescMessage>(\n schema: Schema,\n req: MessageInitShape<Schema>,\n): unknown {\n return toJson(schema, create(schema, req));\n}\n\n/** The three key-scope builders a generated key factory is assembled from. */\nexport interface ServiceKeyBuilders {\n /** Every query on this service: [service]. */\n readonly all: readonly [string];\n /** Every query for one CRUD entity: [service, entity]. Mutations invalidate this. */\n entity(name: string): readonly [string, string];\n /**\n * One query: [service, entity?, method, protojson(req)]. Returns the\n * per-request key FUNCTION, which is what both the query hook and any\n * call-site prefetch/setQueryData use.\n */\n query<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): (req: MessageInitShape<Schema>) => ServiceQueryKey;\n}\n\n/**\n * serviceKeys returns the builders for one service's query-key factory.\n *\n * Cache-key semantics are a compatibility contract, not an implementation\n * detail: an existing app's\n * `invalidateQueries({ queryKey: orderServiceKeys.order })` has to keep\n * matching the keys its queries were registered under across a version bump.\n * So the tuple order and contents produced here are fixed, and identical to\n * what forge previously inlined into every generated hook file.\n */\nexport function serviceKeys(service: string): ServiceKeyBuilders {\n return {\n all: [service] as const,\n entity: (name: string) => [service, name] as const,\n query:\n <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ) =>\n (req: MessageInitShape<Schema>): ServiceQueryKey =>\n entityScope === undefined\n ? ([service, method, requestKey(schema, req)] as const)\n : ([service, entityScope, method, requestKey(schema, req)] as const),\n };\n}\n\n/** A generated query hook: `useGetOrder(req, options?)`. */\nexport type ServiceQueryHook<Schema extends DescMessage, Response> = (\n req: MessageInitShape<Schema>,\n options?: Partial<UseQueryOptions<Response, ConnectClientError>>,\n) => UseQueryResult<Response, ConnectClientError>;\n\n/** A generated mutation hook: `useCreateOrder(options?)`. */\nexport type ServiceMutationHook<Schema extends DescMessage, Response> = (\n options?: Partial<\n UseMutationOptions<Response, ConnectClientError, MessageInitShape<Schema>>\n >,\n) => UseMutationResult<Response, ConnectClientError, MessageInitShape<Schema>>;\n\n/**\n * createQueryHook builds one useQuery wrapper.\n *\n * The signature it returns is the one call sites already use — `(req,\n * options?)`, with the error typed as ConnectClientError rather than React\n * Query's default `Error`. That type is declared on the OPTIONS parameter\n * because React Query takes the result's error type from the same TError it\n * reads there, so `query.error` is a ConnectClientError at every call site\n * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`\n * and `.retryable`, and left `err.message.includes(...)` as the only\n * expression that compiled — the one thing the error contract forbids.\n */\nexport function createQueryHook<Schema extends DescMessage, Response>(\n keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceQueryHook<Schema, Response> {\n return (req, options) =>\n useQuery({\n queryKey: keyFor(req),\n queryFn: () => call(req),\n ...options,\n });\n}\n\n/**\n * createMutationHook builds one useMutation wrapper with scoped invalidation.\n *\n * invalidateKey is the entity scope when the RPC maps to a CRUD entity, and\n * the whole-service scope otherwise — a bespoke mutation may touch anything,\n * so over-invalidating is the safe default there.\n *\n * The invalidation is composed BEFORE the caller's onSuccess and is never\n * replaced by it: onSuccess is destructured out and re-invoked after the\n * invalidate, while the rest of the options object is spread separately. A\n * caller passing onSuccess therefore ADDS behaviour, rather than silently\n * disabling the cache refresh — which is exactly what a plain `...options`\n * spread over a default onSuccess would do.\n */\nexport function createMutationHook<Schema extends DescMessage, Response>(\n invalidateKey: ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceMutationHook<Schema, Response> {\n return (options) => {\n const queryClient = useQueryClient();\n const { onSuccess, ...rest } = options ?? {};\n return useMutation({\n mutationFn: (req: MessageInitShape<Schema>) => call(req),\n onSuccess: (...args) => {\n queryClient.invalidateQueries({ queryKey: invalidateKey });\n onSuccess?.(...args);\n },\n ...rest,\n });\n };\n}\n"]}
@@ -0,0 +1,45 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ /**
3
+ * The auth surface the session needs, and nothing more. Your app's auth
4
+ * context value satisfies it structurally — pass `useAuth()` straight in.
5
+ * Keeping this a plain prop (not an import) is what lets you swap, rename or
6
+ * delete src/lib/auth/* without breaking a forge-owned file.
7
+ */
8
+ export interface SessionAuth {
9
+ user: {
10
+ id: string;
11
+ email?: string;
12
+ name?: string;
13
+ } | null;
14
+ isAuthenticated: boolean;
15
+ isLoading: boolean;
16
+ getToken: () => Promise<string | null>;
17
+ }
18
+ /** Decoded JWT claims. Untyped beyond the fields the runtime reads. */
19
+ export interface JwtClaims {
20
+ sub?: string;
21
+ email?: string;
22
+ name?: string;
23
+ exp?: number;
24
+ [key: string]: unknown;
25
+ }
26
+ export interface Session {
27
+ userId: string | null;
28
+ email: string | null;
29
+ name: string | null;
30
+ claims: JwtClaims | null;
31
+ isAuthenticated: boolean;
32
+ isLoading: boolean;
33
+ }
34
+ /**
35
+ * decodeJwt extracts the claims from a JWT WITHOUT verifying the signature.
36
+ * This is for display/routing only — the backend is the sole authority.
37
+ * Returns null for malformed tokens.
38
+ */
39
+ export declare function decodeJwt(token: string | null | undefined): JwtClaims | null;
40
+ export declare function SessionProvider({ auth, children, }: {
41
+ auth: SessionAuth;
42
+ children: ReactNode;
43
+ }): ReactElement;
44
+ /** Current session — the signed-in user. Always defined (empty when logged out). */
45
+ export declare function useSession(): Session;
@@ -0,0 +1,79 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
4
+ //
5
+ // Session context: the current user, derived from the auth state the app
6
+ // hands in and the bearer token's JWT claims. The runtime never imports the
7
+ // app's auth module — the app owns which provider it uses and may rename or
8
+ // delete that file, so the dependency points app → runtime, never back.
9
+ // Mount <SessionProvider auth={useAuth()}> inside your auth provider; read it
10
+ // with useSession().
11
+ import { createContext, useContext, useEffect, useMemo, useState, } from "react";
12
+ const EMPTY_SESSION = {
13
+ userId: null,
14
+ email: null,
15
+ name: null,
16
+ claims: null,
17
+ isAuthenticated: false,
18
+ isLoading: true,
19
+ };
20
+ const SessionContext = createContext(EMPTY_SESSION);
21
+ /** base64url → string, tolerant of missing padding. Browser + jsdom safe. */
22
+ function base64UrlDecode(input) {
23
+ const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4));
24
+ const base64 = input.replace(/-/g, "+").replace(/_/g, "/") + pad;
25
+ // atob is a global in browsers and in Node 18+ (used during Next SSR).
26
+ return atob(base64);
27
+ }
28
+ /**
29
+ * decodeJwt extracts the claims from a JWT WITHOUT verifying the signature.
30
+ * This is for display/routing only — the backend is the sole authority.
31
+ * Returns null for malformed tokens.
32
+ */
33
+ export function decodeJwt(token) {
34
+ if (!token) {
35
+ return null;
36
+ }
37
+ const parts = token.split(".");
38
+ if (parts.length !== 3) {
39
+ return null;
40
+ }
41
+ try {
42
+ return JSON.parse(base64UrlDecode(parts[1] ?? ""));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ export function SessionProvider({ auth, children, }) {
49
+ const { user, isAuthenticated, isLoading, getToken } = auth;
50
+ const [claims, setClaims] = useState(null);
51
+ useEffect(() => {
52
+ let live = true;
53
+ void (async () => {
54
+ const token = await getToken();
55
+ if (live) {
56
+ setClaims(decodeJwt(token));
57
+ }
58
+ })();
59
+ return () => {
60
+ live = false;
61
+ };
62
+ }, [getToken, isAuthenticated]);
63
+ const value = useMemo(() => {
64
+ return {
65
+ userId: user?.id ?? claims?.sub ?? null,
66
+ email: user?.email ?? claims?.email ?? null,
67
+ name: user?.name ?? claims?.name ?? null,
68
+ claims,
69
+ isAuthenticated,
70
+ isLoading,
71
+ };
72
+ }, [user, claims, isAuthenticated, isLoading]);
73
+ return (_jsx(SessionContext.Provider, { value: value, children: children }));
74
+ }
75
+ /** Current session — the signed-in user. Always defined (empty when logged out). */
76
+ export function useSession() {
77
+ return useContext(SessionContext);
78
+ }
79
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.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// Session context: the current user, derived from the auth state the app\n// hands in and the bearer token's JWT claims. The runtime never imports the\n// app's auth module — the app owns which provider it uses and may rename or\n// delete that file, so the dependency points app → runtime, never back.\n// Mount <SessionProvider auth={useAuth()}> inside your auth provider; read it\n// with useSession().\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\n/**\n * The auth surface the session needs, and nothing more. Your app's auth\n * context value satisfies it structurally — pass `useAuth()` straight in.\n * Keeping this a plain prop (not an import) is what lets you swap, rename or\n * delete src/lib/auth/* without breaking a forge-owned file.\n */\nexport interface SessionAuth {\n user: { id: string; email?: string; name?: string } | null;\n isAuthenticated: boolean;\n isLoading: boolean;\n getToken: () => Promise<string | null>;\n}\n\n/** Decoded JWT claims. Untyped beyond the fields the runtime reads. */\nexport interface JwtClaims {\n sub?: string;\n email?: string;\n name?: string;\n exp?: number;\n [key: string]: unknown;\n}\n\nexport interface Session {\n userId: string | null;\n email: string | null;\n name: string | null;\n claims: JwtClaims | null;\n isAuthenticated: boolean;\n isLoading: boolean;\n}\n\nconst EMPTY_SESSION: Session = {\n userId: null,\n email: null,\n name: null,\n claims: null,\n isAuthenticated: false,\n isLoading: true,\n};\n\nconst SessionContext = createContext<Session>(EMPTY_SESSION);\n\n/** base64url → string, tolerant of missing padding. Browser + jsdom safe. */\nfunction base64UrlDecode(input: string): string {\n const pad = input.length % 4 === 0 ? \"\" : \"=\".repeat(4 - (input.length % 4));\n const base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\") + pad;\n // atob is a global in browsers and in Node 18+ (used during Next SSR).\n return atob(base64);\n}\n\n/**\n * decodeJwt extracts the claims from a JWT WITHOUT verifying the signature.\n * This is for display/routing only — the backend is the sole authority.\n * Returns null for malformed tokens.\n */\nexport function decodeJwt(token: string | null | undefined): JwtClaims | null {\n if (!token) {\n return null;\n }\n const parts = token.split(\".\");\n if (parts.length !== 3) {\n return null;\n }\n try {\n return JSON.parse(base64UrlDecode(parts[1] ?? \"\")) as JwtClaims;\n } catch {\n return null;\n }\n}\n\nexport function SessionProvider({\n auth,\n children,\n}: {\n auth: SessionAuth;\n children: ReactNode;\n}): ReactElement {\n const { user, isAuthenticated, isLoading, getToken } = auth;\n const [claims, setClaims] = useState<JwtClaims | null>(null);\n\n useEffect(() => {\n let live = true;\n void (async () => {\n const token = await getToken();\n if (live) {\n setClaims(decodeJwt(token));\n }\n })();\n return () => {\n live = false;\n };\n }, [getToken, isAuthenticated]);\n\n const value = useMemo<Session>(() => {\n return {\n userId: user?.id ?? claims?.sub ?? null,\n email: user?.email ?? claims?.email ?? null,\n name: user?.name ?? claims?.name ?? null,\n claims,\n isAuthenticated,\n isLoading,\n };\n }, [user, claims, isAuthenticated, isLoading]);\n\n return (\n <SessionContext.Provider value={value}>{children}</SessionContext.Provider>\n );\n}\n\n/** Current session — the signed-in user. Always defined (empty when logged out). */\nexport function useSession(): Session {\n return useContext(SessionContext);\n}\n"]}