@reliantlabs/forge-web-runtime 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/dist/benign-errors.d.ts +33 -0
- package/dist/benign-errors.js +115 -0
- package/dist/benign-errors.js.map +1 -0
- package/dist/client-context.d.ts +35 -0
- package/dist/client-context.js +204 -0
- package/dist/client-context.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interceptors.js +10 -3
- package/dist/interceptors.js.map +1 -1
- package/dist/node.js +10 -0
- package/dist/node.js.map +1 -1
- package/dist/resource.d.ts +62 -3
- package/dist/resource.js +96 -17
- package/dist/resource.js.map +1 -1
- package/dist/service-hooks.js +8 -6
- package/dist/service-hooks.js.map +1 -1
- package/dist/session-cookie-fallback.d.ts +23 -0
- package/dist/session-cookie-fallback.js +203 -0
- package/dist/session-cookie-fallback.js.map +1 -0
- package/dist/telemetry-gate.d.ts +4 -0
- package/dist/telemetry-gate.js +25 -0
- package/dist/telemetry-gate.js.map +1 -1
- package/dist/telemetry.d.ts +29 -2
- package/dist/telemetry.js +370 -17
- package/dist/telemetry.js.map +1 -1
- package/dist/trace.d.ts +7 -0
- package/dist/trace.js +21 -2
- package/dist/trace.js.map +1 -1
- package/package.json +4 -2
package/dist/resource.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,sEAAsE;AACtE,EAAE;AACF,wEAAwE;AACxE,4EAA4E;AAC5E,wEAAwE;AACxE,2DAA2D;AAC3D,EAAE;AACF,oEAAoE;AACpE,0DAA0D;AAC1D,6EAA6E;AAC7E,0DAA0D;AAC1D,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAqC,MAAM,OAAO,CAAC;AAE/E,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AA0C1C,MAAM,UAAU,QAAQ,CAAI,EAC1B,MAAM,EACN,IAAI,EACJ,OAAO,EACP,MAAM,EACN,KAAK,EACL,OAAO,EACP,KAAK,EACL,OAAO,EACP,UAAU,EACV,UAAU,GAAG,kBAAkB,EAC/B,YAAY,GAAG,mBAAmB,EAClC,MAAM,EACN,cAAc,EACd,iBAAiB,GAAG,SAAS,EAC7B,gBAAgB,GAAG,GAAG,EACtB,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,EACX,UAAU,GACO;IACjB,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;IAEjD,yEAAyE;IACzE,SAAS,CAAC,GAAG,EAAE;QACb,QAAQ,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;IACzB,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAEb,iEAAiE;IACjE,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,cAAc,IAAI,KAAK,KAAK,CAAC,MAAM,IAAI,EAAE,CAAC,EAAE,CAAC;YAChD,OAAO;QACT,CAAC;QACD,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC,CAAC;QACzE,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;IACpC,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,gBAAgB,CAAC,CAAC,CAAC;IAEtD,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,UAAU,CAAC,CAAC;IACrD,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC;IAChC,MAAM,WAAW,GAAG,KAAK,IAAI,SAAS,CAAC;IAEvC,OAAO,CACL,mBAAS,SAAS,EAAC,qBAAqB,aACrC,CAAC,KAAK,IAAI,OAAO,CAAC,IAAI,CACrB,kBAAQ,SAAS,EAAC,yCAAyC,aACxD,KAAK,CAAC,CAAC,CAAC,CACP,aAAI,SAAS,EAAC,gCAAgC,YAAE,KAAK,GAAM,CAC5D,CAAC,CAAC,CAAC,CACF,gBAAQ,CACT,EACA,OAAO,CAAC,CAAC,CAAC,CACT,cAAK,SAAS,EAAC,yBAAyB,YAAE,OAAO,GAAO,CACzD,CAAC,CAAC,CAAC,IAAI,IACD,CACV,EAEA,cAAc,CAAC,CAAC,CAAC,CAChB,gBACE,IAAI,EAAC,QAAQ,EACb,KAAK,EAAE,KAAK,EACZ,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EACzC,WAAW,EAAE,iBAAiB,gBAClB,KAAK,CAAC,CAAC,CAAC,UAAU,KAAK,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,EAC9D,SAAS,EAAC,uKAAuK,GACjL,CACH,CAAC,CAAC,CAAC,IAAI,EAER,cAAK,SAAS,EAAC,4DAA4D,YACzE,iBAAO,SAAS,EAAC,2CAA2C,aAC1D,kBAAS,SAAS,EAAC,SAAS,YAAE,WAAW,GAAW,EACpD,gBAAO,SAAS,EAAC,kBAAkB,YACjC,uBACG,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CACvB,aAEE,KAAK,EAAC,KAAK,EACX,SAAS,EAAE,oDACT,GAAG,CAAC,SAAS,IAAI,EACnB,EAAE,YAED,GAAG,CAAC,MAAM,IANN,CAAC,CAOH,CACN,CAAC,GACC,GACC,EACR,gBAAO,SAAS,EAAC,wBAAwB,YACtC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,CACtB,KAAC,YAAY,IAAC,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,QAAQ,GAAI,CAC1C,CAAC,CAAC,CAAC,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CACvB,uBACE,aAAI,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAC,YAAY,YAC3C,KAAC,UAAU,IAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAI,GAC3C,GACF,CACN,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAC/B,uBACE,aAAI,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAC,YAAY,YAC3C,KAAC,UAAU,IAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,YAAY,GAAI,GACrD,GACF,CACN,CAAC,CAAC,CAAC,CACF,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAChB,aAEE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;gCACvD,wDAAwD;gCACxD,gDAAgD;gCAChD,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,EACvC,QAAQ,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,EACpC,SAAS,EACP,UAAU;oCACR,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE;wCACJ,IAAI,CAAC,CAAC,GAAG,KAAK,OAAO,IAAI,CAAC,CAAC,GAAG,KAAK,GAAG,EAAE,CAAC;4CACvC,CAAC,CAAC,cAAc,EAAE,CAAC;4CACnB,UAAU,CAAC,GAAG,CAAC,CAAC;wCAClB,CAAC;oCACH,CAAC;oCACH,CAAC,CAAC,SAAS,EAEf,SAAS,EACP,UAAU;oCACR,CAAC,CAAC,2KAA2K;oCAC7K,CAAC,CAAC,SAAS,YAGd,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CACvB,aAEE,SAAS,EAAE,wBAAwB,GAAG,CAAC,SAAS,IAAI,EAAE,EAAE,YAEvD,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAHT,CAAC,CAIH,CACN,CAAC,IA7BG,MAAM,CAAC,GAAG,CAAC,CA8Bb,CACN,CAAC,CACH,GACK,IACF,GACJ,EAEL,UAAU,CAAC,CAAC,CAAC,CACZ,kBAAQ,SAAS,EAAC,qCAAqC,aACpD,UAAU,CAAC,CAAC,CAAC,CACZ,eAAM,SAAS,EAAC,iCAAiC,8BAAgB,CAClE,CAAC,CAAC,CAAC,IAAI,EACR,iBACE,IAAI,EAAC,QAAQ,EACb,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,CAAC,WAAW,EACtB,SAAS,EAAC,uJAAuJ,yBAG1J,EACT,iBACE,IAAI,EAAC,QAAQ,EACb,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,CAAC,WAAW,EACtB,SAAS,EAAC,uJAAuJ,qBAG1J,IACF,CACV,CAAC,CAAC,CAAC,IAAI,IACA,CACX,CAAC;AACJ,CAAC;AAED,yEAAyE;AACzE,wDAAwD;AACxD,EAAE;AACF,wEAAwE;AACxE,4EAA4E;AAC5E,kDAAkD;AAClD,EAAE;AACF,2EAA2E;AAC3E,0EAA0E;AAC1E,qEAAqE;AACrE,0EAA0E;AAC1E,4EAA4E;AAC5E,4EAA4E;AAC5E,+DAA+D;AAC/D,2EAA2E;AAC3E,4EAA4E;AAC5E,+DAA+D;AAC/D,EAAE;AACF,2EAA2E;AAC3E,uDAAuD;AACvD,4EAA4E;AAC5E,wCAAwC;AACxC,SAAS,YAAY,CAAC,EAAE,IAAI,EAAE,IAAI,EAAkC;IAClE,OAAO,CACL,uBACE,cAAI,OAAO,EAAE,IAAI,EAAE,SAAS,EAAC,KAAK,aAChC,eAAM,SAAS,EAAC,SAAS,8BAAgB,EACzC,6BAAiB,MAAM,EAAC,SAAS,EAAC,wBAAwB,YACvD,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAC1C,cAAa,SAAS,EAAC,mCAAmC,YACvD,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAC3C,cAEE,SAAS,EAAC,8EAA8E,IADnF,CAAC,CAEN,CACH,CAAC,IANM,CAAC,CAOL,CACP,CAAC,GACE,IACH,GACF,CACN,CAAC;AACJ,CAAC;AAED,6EAA6E;AAC7E,4EAA4E;AAC5E,0EAA0E;AAC1E,iBAAiB;AACjB,SAAS,UAAU,CAAC,EAClB,KAAK,EACL,OAAO,GAIR;IACC,OAAO,CACL,eAAK,IAAI,EAAC,OAAO,EAAC,SAAS,EAAC,8CAA8C,aACxE,YAAG,SAAS,EAAC,qCAAqC,mCAE9C,EACH,KAAK,CAAC,CAAC,CAAC,CACP,YAAG,SAAS,EAAC,iCAAiC,YAAE,WAAW,CAAC,KAAK,CAAC,GAAK,CACxE,CAAC,CAAC,CAAC,IAAI,EACP,OAAO,CAAC,CAAC,CAAC,CACT,iBACE,IAAI,EAAC,QAAQ,EACb,OAAO,EAAE,OAAO,EAChB,SAAS,EAAC,gGAAgG,0BAGnG,CACV,CAAC,CAAC,CAAC,IAAI,IACJ,CACP,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,EAAE,KAAK,EAAE,OAAO,EAAsC;IACxE,OAAO,CACL,eAAK,SAAS,EAAC,8CAA8C,aAC3D,YAAG,SAAS,EAAC,8BAA8B,YAAE,KAAK,GAAK,EACvD,YAAG,SAAS,EAAC,wBAAwB,YAAE,OAAO,GAAK,IAC/C,CACP,CAAC;AACJ,CAAC","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 { useEffect, useState, type ReactElement, type ReactNode } from \"react\";\n\nimport { userMessage } from \"./errors.js\";\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 ? (\n <div className=\"flex items-center gap-2\">{actions}</div>\n ) : 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 ${\n col.className ?? \"\"\n }`}\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 motion-reduce:animate-none rounded bg-surface-muted\"\n />\n ))}\n </div>\n ))}\n </div>\n </td>\n </tr>\n );\n}\n\n// ErrorState shows userMessage(error), never error.message: a ConnectError's\n// message carries the \"[code] \" transport framing, which is not copy a user\n// should read. It is body text in the empty rung's style, not a monospace\n// debug readout.\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\">\n Couldn't load data\n </p>\n {error ? (\n <p className=\"max-w-md text-sm text-ink-muted\">{userMessage(error)}</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"]}
|
|
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.\n//\n// Layout, top to bottom: a header (title + primary action), a toolbar row\n// (search + the page's `toolbar` facets) that WRAPS, the table, and a footer\n// (the record count + the pager). Filters used to share the header row with\n// the title and the create button in one non-wrapping flex row, which pushed\n// the button off-canvas on a phone; they now have a row of their own.\nimport {\n useEffect,\n useState,\n type KeyboardEvent,\n type MouseEvent,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\nimport { userMessage } from \"./errors.js\";\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 * Makes the column sortable: the server-side sort key (the List request's\n * `order_by` value, a column name) this header sorts by. The header renders\n * a button and `aria-sort`; the page owns the state through\n * `sort` / `onSortChange`.\n */\n sortKey?: string;\n}\n\n/** The active server-side sort: an `order_by` key and its direction. */\nexport interface ResourceSort {\n key: string;\n descending: boolean;\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: the page's primary action, e.g. a \"New\" button. */\n actions?: ReactNode;\n /**\n * Filter controls, rendered in their own wrapping row beside the search\n * box — never in the header, so a page with several facets still fits a\n * phone. Pass a filter bar, selects, pickers.\n */\n toolbar?: ReactNode;\n error?: Error | null;\n onRetry?: () => void;\n /**\n * Clicking anywhere on a row calls it; a click that lands on a link or\n * another control inside the row is left to that control. Without\n * `rowsHaveLinks` the row is also the keyboard path to the record: it takes\n * a tab stop, `role=\"button\"`, and Enter/Space call this.\n */\n onRowClick?: (row: T) => void;\n /**\n * Declares that every row renders a real link to its record (in the\n * primary cell), so `onRowClick` is a POINTER convenience only: the row\n * keeps table semantics (no button role, no tab stop) and keyboard and\n * screen-reader users reach the record through the link. Set it only when\n * the link is there — without it, a row with `onRowClick` stays keyboard\n * operable itself, so a page that predates the link is never stranded.\n */\n rowsHaveLinks?: boolean;\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 // Server-side sort, driven by columns that declare a sortKey. Each click\n // on a header cycles ascending → descending → unsorted.\n sort?: ResourceSort | null;\n onSortChange?: (sort: ResourceSort | null) => void;\n\n // Cursor pagination. Omit the handlers to hide the pager.\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 * The total number of matching records (the List response's total_count),\n * shown in the footer: \"1–25 of 112\". Omit when the API does not report it.\n */\n totalCount?: number;\n /** Zero-based index of this page's first row, for the footer's range. */\n pageStart?: number;\n}\n\n// The controls a row click must not hijack: a click on any of these inside a\n// row belongs to that control (and a link inside the row already navigates).\nconst INTERACTIVE =\n \"a, button, input, select, textarea, label, [role='button'], [role='combobox']\";\n\nfunction nextSort(\n current: ResourceSort | null | undefined,\n key: string,\n): ResourceSort | null {\n if (current?.key !== key) return { key, descending: false };\n if (!current.descending) return { key, descending: true };\n return null;\n}\n\nexport function Resource<T>({\n status,\n data,\n columns,\n rowKey,\n title,\n actions,\n toolbar,\n error,\n onRetry,\n onRowClick,\n rowsHaveLinks = false,\n emptyTitle = \"Nothing here yet\",\n emptyMessage = \"No records match.\",\n filter,\n onFilterChange,\n filterPlaceholder = \"Search…\",\n filterDebounceMs = 300,\n sort,\n onSortChange,\n onNextPage,\n onPrevPage,\n hasNextPage,\n hasPrevPage,\n isFetching,\n totalCount,\n pageStart,\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 colCount = columns.length;\n const captionText = title ?? \"Results\";\n const rows = status === \"success\" && data ? data.length : 0;\n const showPager =\n Boolean(onNextPage || onPrevPage) && Boolean(hasNextPage || hasPrevPage);\n const countText = describeCount(rows, totalCount, pageStart);\n const showFooter = showPager || countText !== \"\" || Boolean(isFetching);\n const showToolbar = Boolean(onFilterChange || toolbar);\n\n return (\n <section className=\"flex flex-col gap-4\">\n {(title || actions) && (\n <header className=\"flex flex-wrap items-center justify-between gap-3\">\n {title ? (\n <h1 className=\"text-xl font-semibold text-ink\">{title}</h1>\n ) : (\n <span />\n )}\n {actions ? (\n <div className=\"flex flex-wrap items-center gap-2\">{actions}</div>\n ) : null}\n </header>\n )}\n\n {showToolbar ? (\n <div className=\"flex flex-wrap items-start gap-2\">\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=\"h-9 w-full min-w-0 rounded-md border border-border-strong bg-surface px-3 text-sm text-ink placeholder:text-ink-subtle focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent sm:w-64\"\n />\n ) : null}\n {toolbar ? <div className=\"min-w-0 flex-1\">{toolbar}</div> : null}\n </div>\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 const sortable = Boolean(col.sortKey && onSortChange);\n const active = sortable && sort?.key === col.sortKey;\n return (\n <th\n key={i}\n scope=\"col\"\n aria-sort={\n active\n ? sort?.descending\n ? \"descending\"\n : \"ascending\"\n : undefined\n }\n className={`px-4 py-2.5 text-left font-medium text-ink-muted ${\n col.className ?? \"\"\n }`}\n >\n {sortable ? (\n <button\n type=\"button\"\n onClick={() =>\n onSortChange?.(nextSort(sort, col.sortKey ?? \"\"))\n }\n className=\"-mx-1 inline-flex items-center gap-1 rounded px-1 hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent\"\n >\n {col.header}\n <span\n aria-hidden=\"true\"\n className={active ? \"text-ink\" : \"text-ink-subtle\"}\n >\n {active ? (sort?.descending ? \"↓\" : \"↑\") : \"↕\"}\n </span>\n </button>\n ) : (\n col.header\n )}\n </th>\n );\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 {...rowInteraction(row, onRowClick, rowsHaveLinks)}\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 flex-wrap items-center justify-end gap-2\">\n <span\n aria-live=\"polite\"\n className=\"mr-auto text-sm text-ink-muted tabular-nums\"\n >\n {isFetching ? \"Loading…\" : countText}\n </span>\n {showPager ? (\n <>\n <button\n type=\"button\"\n onClick={onPrevPage}\n disabled={!hasPrevPage}\n className=\"h-9 rounded-md border border-border-strong px-3 text-sm font-medium text-ink hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent 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=\"h-9 rounded-md border border-border-strong px-3 text-sm font-medium text-ink hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40\"\n >\n Next\n </button>\n </>\n ) : null}\n </footer>\n ) : null}\n </section>\n );\n}\n\n/** The props rowInteraction puts on a body row. */\nexport interface RowInteraction {\n onClick?: (e: MouseEvent<HTMLTableRowElement>) => void;\n onKeyDown?: (e: KeyboardEvent<HTMLTableRowElement>) => void;\n role?: \"button\";\n tabIndex?: number;\n className?: string;\n}\n\n/**\n * rowInteraction is how a body row responds to its onRowClick. A row with\n * no onRowClick is inert data. A row whose cells link to the record\n * (`rowsHaveLinks`) gets a pointer-only click and keeps table semantics. A\n * row with onRowClick and no link is the only way to its record, so it is\n * keyboard operable itself: a tab stop, a button role, Enter/Space. Exported\n * for its tests; not part of the package's index.\n */\nexport function rowInteraction<T>(\n row: T,\n onRowClick: ((row: T) => void) | undefined,\n rowsHaveLinks: boolean,\n): RowInteraction {\n if (!onRowClick) return {};\n const onClick = (e: MouseEvent<HTMLTableRowElement>) => {\n // Leave clicks on the row's own controls (the primary-cell link, a\n // menu) to those controls, and never turn a text selection into a\n // navigation. Only a control INSIDE the row counts: a keyboard-operable\n // row is itself role=\"button\", and closest() matches the row too.\n const control = (e.target as Element).closest(INTERACTIVE);\n if (\n control &&\n control !== e.currentTarget &&\n e.currentTarget.contains(control)\n ) {\n return;\n }\n if (window.getSelection()?.toString()) return;\n onRowClick(row);\n };\n if (rowsHaveLinks) {\n return {\n onClick,\n className:\n \"cursor-pointer transition-colors hover:bg-surface-muted focus-within:bg-surface-muted\",\n };\n }\n return {\n onClick,\n role: \"button\",\n tabIndex: 0,\n onKeyDown: (e) => {\n // Only the row's own keys: Enter on a control inside the row is\n // that control's.\n if (e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onRowClick(row);\n }\n },\n className:\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 };\n}\n\n/**\n * describeCount is the footer's record count: \"1–25 of 112\" with a known page\n * offset, \"112 results\" without one, and nothing while no row is shown.\n */\nfunction describeCount(\n rows: number,\n total?: number,\n pageStart?: number,\n): string {\n if (total === undefined || rows === 0) return \"\";\n if (pageStart === undefined) {\n return `${total.toLocaleString()} ${total === 1 ? \"result\" : \"results\"}`;\n }\n const first = (pageStart + 1).toLocaleString();\n const last = (pageStart + rows).toLocaleString();\n return `${first}–${last} of ${total.toLocaleString()}`;\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 motion-reduce:animate-none rounded bg-surface-muted\"\n />\n ))}\n </div>\n ))}\n </div>\n </td>\n </tr>\n );\n}\n\n// ErrorState shows userMessage(error), never error.message: a ConnectError's\n// message carries the \"[code] \" transport framing, which is not copy a user\n// should read. It is body text in the empty rung's style, not a monospace\n// debug readout.\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\">\n Couldn't load data\n </p>\n {error ? (\n <p className=\"max-w-md text-sm text-ink-muted\">{userMessage(error)}</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"]}
|
package/dist/service-hooks.js
CHANGED
|
@@ -29,12 +29,14 @@
|
|
|
29
29
|
// an engine with its own dependency footprint gets its own entry point, and
|
|
30
30
|
// React Query is an OPTIONAL peer.
|
|
31
31
|
//
|
|
32
|
-
// WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts —
|
|
33
|
-
// app-wide MutationCache
|
|
34
|
-
//
|
|
35
|
-
// `meta: { silenceErrorToast: true }
|
|
36
|
-
//
|
|
37
|
-
//
|
|
32
|
+
// WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts — they
|
|
33
|
+
// belong to one app-wide chokepoint, a MutationCache onError on the app's
|
|
34
|
+
// QueryClient (a forge scaffold installs it in src/lib/query-client.ts, and
|
|
35
|
+
// pages that render errors inline pass `meta: { silenceErrorToast: true }`);
|
|
36
|
+
// an app without one handles errors at the call site. It never defaults
|
|
37
|
+
// success-side UX either: "CreateOrder succeeded" leaks RPC names into the
|
|
38
|
+
// UI, so the toast / redirect / inline confirmation stays the call site's via
|
|
39
|
+
// options.onSuccess.
|
|
38
40
|
import { create, toJson, } from "@bufbuild/protobuf";
|
|
39
41
|
import { infiniteQueryOptions, queryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
|
|
40
42
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-hooks.js","sourceRoot":"","sources":["../src/service-hooks.ts"],"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// Every export below is a React hook or builds one, so this module belongs in\n// the CLIENT graph. Without the directive the Next.js App Router compiles it\n// into the SERVER graph, where it binds a SECOND @tanstack/react-query\n// instance and every generated hook throws \"No QueryClient set\" at runtime —\n// invisible to tsc, eslint AND `next build`. published-surface.test.ts asserts\n// the directive survives into dist/. It is NOT applied package-wide: the\n// ./interceptors subpath exists to be importable without React.\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 infiniteQueryOptions,\n queryOptions,\n useInfiniteQuery,\n useMutation,\n useQuery,\n useQueryClient,\n type DataTag,\n type InfiniteData,\n type UseInfiniteQueryOptions,\n type UseInfiniteQueryResult,\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/**\n * One read's key entry in a generated factory: `orderServiceKeys.getOrder`.\n *\n * Called, it is that request's key, [service, entity?, method,\n * protojson(req)]. Its `.all` is the rpc's scope, [service, entity?,\n * method]: a prefix of every key the rpc caches under — every request, the\n * plain query and the `<method>Infinite` read alike — and of no other rpc's.\n *\n * queryClient.invalidateQueries({ queryKey: orderServiceKeys.listOrders.all })\n * queryClient.setQueriesData({ queryKey: orderServiceKeys.getOrder.all }, patch)\n *\n * It exists so code never names a key's tuple positions. A predicate like\n * `query.queryKey[1] === \"listOrders\"` breaks silently when a\n * `// forge:query-scope` marker inserts the entity at index 1; `.all` is\n * built by the same function that builds the keys, so it moves with them.\n *\n * `.all` is not data-tagged. It names a SET of entries, and for a paged\n * read that set holds two shapes (the response, and the infinite read's\n * `{ pages, pageParams }`), so a `setQueriesData` updater over it must\n * handle both.\n */\nexport type ServiceMethodKey<Schema extends DescMessage> = ((\n req: MessageInitShape<Schema>,\n) => ServiceQueryKey) & {\n /** Every cached request of this rpc: [service, entity?, method]. */\n readonly all: ServiceQueryKey;\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, with the rpc's scope\n * [service, entity?, method] on it as `.all` (ServiceMethodKey).\n */\n query<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): ServiceMethodKey<Schema>;\n /**\n * One paged read: [service, entity?, method, protojson(req), \"infinite\"],\n * where req is the request WITHOUT its page token — the hook owns the\n * token, so every page of one listing shares one cache entry.\n *\n * It nests under the same scopes as `query`, so an entity or service-wide\n * invalidation refetches it. The \"infinite\" suffix keeps it OFF the plain\n * query's entry for the same request: an infinite query caches\n * `{ pages, pageParams }`, not one response, and a plain hook reading that\n * entry would get the wrong shape.\n */\n infinite<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): (req: PageRequest<Schema>) => ServiceQueryKey;\n}\n\n/**\n * Omit distributed over a union. MessageInitShape is `Message | init\n * object`, and a plain Omit over a union keeps only the keys both members\n * share — which would silently drop every field from the init side.\n */\ntype DistributiveOmit<T, K extends PropertyKey> = T extends unknown\n ? Omit<T, K>\n : never;\n\n/**\n * The request a generated infinite hook takes: the rpc's request without\n * `pageToken`, which the hook supplies page by page.\n */\nexport type PageRequest<Schema extends DescMessage> = DistributiveOmit<\n MessageInitShape<Schema>,\n \"pageToken\"\n>;\n\n/**\n * The response shape forge's list-pagination convention (AIP-158) gives a\n * paged read: `string next_page_token`, empty on the last page. Optional so\n * an `optional string next_page_token` response satisfies it too.\n */\nexport interface PagedResponse {\n nextPageToken?: string;\n}\n\n/**\n * pageRequestKey is requestKey with the page token dropped, so every page of\n * one listing hashes to one key even when a caller (JS, or a cast) passes a\n * token anyway. It is dropped from the protojson rather than from the init\n * object: a spread Message keeps its $typeName, and create() would hand that\n * token-less copy back as if it were a complete message.\n */\nfunction pageRequestKey<Schema extends DescMessage>(\n schema: Schema,\n req: PageRequest<Schema>,\n): unknown {\n const json = requestKey(schema, req as unknown as MessageInitShape<Schema>);\n if (json === null || typeof json !== \"object\" || Array.isArray(json)) {\n return json;\n }\n const { pageToken: _pageToken, ...rest } = json as Record<string, unknown>;\n return rest;\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 // The rpc scope every key below starts with. Both builders derive their\n // keys from it, so `.all` is a prefix of them by construction, not by a\n // second copy of the tuple layout.\n const rpcScope = (method: string, entityScope?: string): ServiceQueryKey =>\n entityScope === undefined\n ? ([service, method] as const)\n : ([service, entityScope, method] as const);\n return {\n all: [service] as const,\n entity: (name: string) => [service, name] as const,\n query: <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): ServiceMethodKey<Schema> => {\n const all = rpcScope(method, entityScope);\n return Object.assign(\n (req: MessageInitShape<Schema>): ServiceQueryKey => [\n ...all,\n requestKey(schema, req),\n ],\n { all },\n );\n },\n infinite:\n <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ) =>\n (req: PageRequest<Schema>): ServiceQueryKey => [\n ...rpcScope(method, entityScope),\n pageRequestKey(schema, req),\n \"infinite\",\n ],\n };\n}\n\n/**\n * How a generated file calls one rpc. `signal` is React Query's: a query\n * that is unmounted or superseded (a new key while typing) aborts its\n * request instead of letting it land on a cache entry nobody reads.\n */\nexport type ServiceCall<Schema extends DescMessage, Response> = (\n req: MessageInitShape<Schema>,\n signal?: AbortSignal,\n) => Promise<Response>;\n\n/**\n * The options a generated read takes — its hook (`useGetOrder(req,\n * options?)`) and its query-options builder\n * (`orderServiceQueries.getOrder(req, options?)`) alike: React Query's own,\n * minus the two the read owns, the key and the fetch. Everything else\n * (`select`, `enabled`, `staleTime`, `placeholderData`, …) passes through.\n *\n * TData is what `data` is, exactly as in React Query's own useQuery: the\n * response by default, or whatever `select` returns. A call site mapping the\n * proto response to a view model passes `select: (r) => toView(r)` and gets\n * `data: View` — no useMemo over `data`, and React Query re-runs the mapping\n * only when the response changes (or when `select` itself does, so hoist it\n * or wrap it in useCallback when it is expensive).\n */\nexport type ServiceQueryInput<Response, TData = Response> = Partial<\n Omit<\n UseQueryOptions<Response, ConnectClientError, TData, ServiceQueryKey>,\n \"queryKey\" | \"queryFn\"\n >\n>;\n\n/**\n * What a query-options builder returns: TanStack's `queryOptions(...)`\n * result, so it goes anywhere React Query takes one —\n * `queryClient.fetchQuery`, `ensureQueryData`, `prefetchQuery`,\n * `useQueries({ queries })`, `useQuery`, `useSuspenseQuery`. Its `queryKey`\n * is data-tagged, so `queryClient.getQueryData(opts.queryKey)` and\n * `setQueryData` are typed as the response with no annotation.\n *\n * fetchQuery / ensureQueryData resolve to the RESPONSE, not to `select`'s\n * TData: they return what the cache holds, and `select` is an observer\n * option that only a hook or useQueries applies.\n */\nexport type ServiceQueryOptions<Response, TData = Response> = UseQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey\n> & {\n queryKey: DataTag<ServiceQueryKey, Response, ConnectClientError>;\n};\n\n/**\n * A generated query-options builder: `orderServiceQueries.getOrder(req,\n * options?)`. It is the read's single definition — the generated hook\n * `useGetOrder` is `useQuery` over exactly this — so code outside React (an\n * imperative action, a stream handler, a router loader, a prefetch) shares\n * the hook's cache entry and fetch by construction rather than by copying\n * its key and its client call.\n */\nexport type ServiceQueryOptionsBuilder<Schema extends DescMessage, Response> = <\n TData = Response,\n>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n) => ServiceQueryOptions<Response, TData>;\n\n/**\n * A generated query hook: `useGetOrder(req, options?)` — `useQuery` over the\n * service's query-options builder for the same rpc. TData is inferred from\n * `select` and is a parameter of the HOOK, not of the factory, so every call\n * site picks its own.\n */\nexport type ServiceQueryHook<Schema extends DescMessage, Response> = <\n TData = Response,\n>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n) => UseQueryResult<TData, ConnectClientError>;\n\n/**\n * The options a generated infinite read takes: React Query's own, minus the\n * three the read owns — the key, the fetch, and the first page param.\n * `getNextPageParam` defaults to the response's `nextPageToken` (empty means\n * last page) and may be overridden; everything else (`select`,\n * `placeholderData`, `staleTime`, `enabled`, `maxPages`, …) passes through.\n */\nexport type ServiceInfiniteQueryInput<Response, TData> = Partial<\n Omit<\n UseInfiniteQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey,\n string\n >,\n \"queryKey\" | \"queryFn\" | \"initialPageParam\"\n >\n>;\n\n/**\n * What an infinite query-options builder returns: TanStack's\n * `infiniteQueryOptions(...)` result, for `queryClient.fetchInfiniteQuery`,\n * `prefetchInfiniteQuery`, `ensureInfiniteQueryData` and\n * `useInfiniteQuery`. Its key is data-tagged as `InfiniteData<Response,\n * string>`, the `{ pages, pageParams }` the cache holds.\n */\nexport type ServiceInfiniteQueryOptions<\n Response,\n TData = InfiniteData<Response, string>,\n> = UseInfiniteQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey,\n string\n> & {\n queryKey: DataTag<\n ServiceQueryKey,\n InfiniteData<Response, string>,\n ConnectClientError\n >;\n};\n\n/**\n * A generated infinite query-options builder:\n * `orderServiceQueries.listOrdersInfinite(req, options?)`, for a read that\n * follows forge's list-pagination shape. `useListOrdersInfinite` is\n * `useInfiniteQuery` over exactly this.\n */\nexport type ServiceInfiniteQueryOptionsBuilder<\n Schema extends DescMessage,\n Response extends PagedResponse,\n> = <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n) => ServiceInfiniteQueryOptions<Response, TData>;\n\n/**\n * A generated infinite-query hook: `useListOrdersInfinite(req, options?)`,\n * for a read that follows forge's list-pagination shape (`string page_token`\n * on the request, `string next_page_token` on the response).\n *\n * `req` is the first page's request without its token. `data` is React\n * Query's `{ pages, pageParams }` by default; `select` reshapes it, so\n * flattening the pages into one list is\n * `select: (d) => d.pages.flatMap((p) => p.orders)`.\n */\nexport type ServiceInfiniteQueryHook<\n Schema extends DescMessage,\n Response extends PagedResponse,\n> = <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n) => UseInfiniteQueryResult<TData, ConnectClientError>;\n\n/**\n * A generated mutation hook: `useCreateOrder(options?)`.\n *\n * TContext is React Query's fourth type parameter — what `onMutate` returns\n * and `onError` / `onSettled` receive back. It is a parameter of the HOOK,\n * not of the factory, so each call site gets its own: TypeScript infers it\n * from the `onMutate` the caller passes, and an optimistic update reads its\n * rollback off `context` with no cast. Fixing it at the factory (or leaving\n * it out, which pinned it to `unknown`) forced every such call site to\n * narrow `unknown` by hand. It defaults to `unknown`, so a call without\n * `onMutate` types exactly as before.\n */\nexport type ServiceMutationHook<Schema extends DescMessage, Response> = <\n TContext = unknown,\n>(\n options?: Partial<\n UseMutationOptions<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n >\n >,\n) => UseMutationResult<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n>;\n\n/**\n * createQueryOptions builds one read's query-options builder — the single\n * definition of its cache key and its fetch that the generated hook, and\n * every caller outside React, share.\n *\n * The error is typed as ConnectClientError rather than React Query's default\n * `Error`, on the options themselves, because React Query takes a result's\n * error type from the TError it reads there: `query.error` is a\n * ConnectClientError at every call site with nothing to pass. Typing it as\n * `Error` hid `.reason`, `.codeName`, `.status` and `.retryable`, and left\n * `err.message.includes(...)` as the only expression that compiled — the one\n * thing the error contract forbids.\n *\n * The caller's options are spread BEFORE the key and the fetch: a caller may\n * set anything else, never the two that make this read this read.\n */\nexport function createQueryOptions<Schema extends DescMessage, Response>(\n keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey,\n call: ServiceCall<Schema, Response>,\n): ServiceQueryOptionsBuilder<Schema, Response> {\n return <TData = Response>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n ): ServiceQueryOptions<Response, TData> =>\n queryOptions<Response, ConnectClientError, TData, ServiceQueryKey>({\n ...options,\n queryKey: keyFor(req),\n queryFn: ({ signal }) => call(req, signal),\n }) as ServiceQueryOptions<Response, TData>;\n}\n\n/**\n * createQueryHook builds one useQuery wrapper over a read's query-options\n * builder. It adds nothing to the options: a hook and a `fetchQuery` of the\n * same request therefore read and write one cache entry with one fetch.\n */\nexport function createQueryHook<Schema extends DescMessage, Response>(\n optionsFor: ServiceQueryOptionsBuilder<Schema, Response>,\n): ServiceQueryHook<Schema, Response> {\n return <TData = Response>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n ) => useQuery(optionsFor(req, options));\n}\n\n/**\n * createInfiniteQueryOptions builds one paged read's infinite query-options\n * builder.\n *\n * The page token is the builder's: the first page is fetched with the\n * caller's request as given (no token, so an `optional string page_token`\n * stays unset), each later one with the previous response's\n * `nextPageToken`, and an empty token ends the listing. The key is\n * `keys.infinite(...)`, which strips the token and sits under the service and\n * entity scopes, so the mutations that invalidate a plain list refetch this\n * one too.\n *\n * The caller's options are spread BETWEEN the default getNextPageParam and\n * the three fields the read owns: a caller may replace the default, never the\n * key, the fetch or the first page.\n */\nexport function createInfiniteQueryOptions<\n Schema extends DescMessage,\n Response extends PagedResponse,\n>(\n keyFor: (req: PageRequest<Schema>) => ServiceQueryKey,\n call: ServiceCall<Schema, Response>,\n): ServiceInfiniteQueryOptionsBuilder<Schema, Response> {\n return <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n ): ServiceInfiniteQueryOptions<Response, TData> =>\n infiniteQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey,\n string\n >({\n getNextPageParam: (last) => last.nextPageToken || undefined,\n ...options,\n queryKey: keyFor(req),\n queryFn: ({ pageParam, signal }) =>\n call(\n (pageParam === \"\"\n ? req\n : {\n ...req,\n pageToken: pageParam,\n }) as unknown as MessageInitShape<Schema>,\n signal,\n ),\n initialPageParam: \"\",\n }) as ServiceInfiniteQueryOptions<Response, TData>;\n}\n\n/**\n * createInfiniteQueryHook builds one useInfiniteQuery wrapper over a paged\n * read's infinite query-options builder, adding nothing to it — so a hook\n * and a `fetchInfiniteQuery` of the same request share one cache entry.\n */\nexport function createInfiniteQueryHook<\n Schema extends DescMessage,\n Response extends PagedResponse,\n>(\n optionsFor: ServiceInfiniteQueryOptionsBuilder<Schema, Response>,\n): ServiceInfiniteQueryHook<Schema, Response> {\n return <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n ) => useInfiniteQuery(optionsFor(req, options));\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 <TContext = unknown>(\n options?: Partial<\n UseMutationOptions<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n >\n >,\n ) => {\n const queryClient = useQueryClient();\n const { onSuccess, ...rest } = options ?? {};\n return useMutation<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n >({\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"]}
|
|
1
|
+
{"version":3,"file":"service-hooks.js","sourceRoot":"","sources":["../src/service-hooks.ts"],"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// Every export below is a React hook or builds one, so this module belongs in\n// the CLIENT graph. Without the directive the Next.js App Router compiles it\n// into the SERVER graph, where it binds a SECOND @tanstack/react-query\n// instance and every generated hook throws \"No QueryClient set\" at runtime —\n// invisible to tsc, eslint AND `next build`. published-surface.test.ts asserts\n// the directive survives into dist/. It is NOT applied package-wide: the\n// ./interceptors subpath exists to be importable without React.\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 — they\n// belong to one app-wide chokepoint, a MutationCache onError on the app's\n// QueryClient (a forge scaffold installs it in src/lib/query-client.ts, and\n// pages that render errors inline pass `meta: { silenceErrorToast: true }`);\n// an app without one handles errors at the call site. It never defaults\n// success-side UX either: \"CreateOrder succeeded\" leaks RPC names into the\n// UI, so the toast / redirect / inline confirmation stays the call site's via\n// options.onSuccess.\nimport {\n create,\n toJson,\n type DescMessage,\n type MessageInitShape,\n} from \"@bufbuild/protobuf\";\nimport {\n infiniteQueryOptions,\n queryOptions,\n useInfiniteQuery,\n useMutation,\n useQuery,\n useQueryClient,\n type DataTag,\n type InfiniteData,\n type UseInfiniteQueryOptions,\n type UseInfiniteQueryResult,\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/**\n * One read's key entry in a generated factory: `orderServiceKeys.getOrder`.\n *\n * Called, it is that request's key, [service, entity?, method,\n * protojson(req)]. Its `.all` is the rpc's scope, [service, entity?,\n * method]: a prefix of every key the rpc caches under — every request, the\n * plain query and the `<method>Infinite` read alike — and of no other rpc's.\n *\n * queryClient.invalidateQueries({ queryKey: orderServiceKeys.listOrders.all })\n * queryClient.setQueriesData({ queryKey: orderServiceKeys.getOrder.all }, patch)\n *\n * It exists so code never names a key's tuple positions. A predicate like\n * `query.queryKey[1] === \"listOrders\"` breaks silently when a\n * `// forge:query-scope` marker inserts the entity at index 1; `.all` is\n * built by the same function that builds the keys, so it moves with them.\n *\n * `.all` is not data-tagged. It names a SET of entries, and for a paged\n * read that set holds two shapes (the response, and the infinite read's\n * `{ pages, pageParams }`), so a `setQueriesData` updater over it must\n * handle both.\n */\nexport type ServiceMethodKey<Schema extends DescMessage> = ((\n req: MessageInitShape<Schema>,\n) => ServiceQueryKey) & {\n /** Every cached request of this rpc: [service, entity?, method]. */\n readonly all: ServiceQueryKey;\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, with the rpc's scope\n * [service, entity?, method] on it as `.all` (ServiceMethodKey).\n */\n query<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): ServiceMethodKey<Schema>;\n /**\n * One paged read: [service, entity?, method, protojson(req), \"infinite\"],\n * where req is the request WITHOUT its page token — the hook owns the\n * token, so every page of one listing shares one cache entry.\n *\n * It nests under the same scopes as `query`, so an entity or service-wide\n * invalidation refetches it. The \"infinite\" suffix keeps it OFF the plain\n * query's entry for the same request: an infinite query caches\n * `{ pages, pageParams }`, not one response, and a plain hook reading that\n * entry would get the wrong shape.\n */\n infinite<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): (req: PageRequest<Schema>) => ServiceQueryKey;\n}\n\n/**\n * Omit distributed over a union. MessageInitShape is `Message | init\n * object`, and a plain Omit over a union keeps only the keys both members\n * share — which would silently drop every field from the init side.\n */\ntype DistributiveOmit<T, K extends PropertyKey> = T extends unknown\n ? Omit<T, K>\n : never;\n\n/**\n * The request a generated infinite hook takes: the rpc's request without\n * `pageToken`, which the hook supplies page by page.\n */\nexport type PageRequest<Schema extends DescMessage> = DistributiveOmit<\n MessageInitShape<Schema>,\n \"pageToken\"\n>;\n\n/**\n * The response shape forge's list-pagination convention (AIP-158) gives a\n * paged read: `string next_page_token`, empty on the last page. Optional so\n * an `optional string next_page_token` response satisfies it too.\n */\nexport interface PagedResponse {\n nextPageToken?: string;\n}\n\n/**\n * pageRequestKey is requestKey with the page token dropped, so every page of\n * one listing hashes to one key even when a caller (JS, or a cast) passes a\n * token anyway. It is dropped from the protojson rather than from the init\n * object: a spread Message keeps its $typeName, and create() would hand that\n * token-less copy back as if it were a complete message.\n */\nfunction pageRequestKey<Schema extends DescMessage>(\n schema: Schema,\n req: PageRequest<Schema>,\n): unknown {\n const json = requestKey(schema, req as unknown as MessageInitShape<Schema>);\n if (json === null || typeof json !== \"object\" || Array.isArray(json)) {\n return json;\n }\n const { pageToken: _pageToken, ...rest } = json as Record<string, unknown>;\n return rest;\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 // The rpc scope every key below starts with. Both builders derive their\n // keys from it, so `.all` is a prefix of them by construction, not by a\n // second copy of the tuple layout.\n const rpcScope = (method: string, entityScope?: string): ServiceQueryKey =>\n entityScope === undefined\n ? ([service, method] as const)\n : ([service, entityScope, method] as const);\n return {\n all: [service] as const,\n entity: (name: string) => [service, name] as const,\n query: <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): ServiceMethodKey<Schema> => {\n const all = rpcScope(method, entityScope);\n return Object.assign(\n (req: MessageInitShape<Schema>): ServiceQueryKey => [\n ...all,\n requestKey(schema, req),\n ],\n { all },\n );\n },\n infinite:\n <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ) =>\n (req: PageRequest<Schema>): ServiceQueryKey => [\n ...rpcScope(method, entityScope),\n pageRequestKey(schema, req),\n \"infinite\",\n ],\n };\n}\n\n/**\n * How a generated file calls one rpc. `signal` is React Query's: a query\n * that is unmounted or superseded (a new key while typing) aborts its\n * request instead of letting it land on a cache entry nobody reads.\n */\nexport type ServiceCall<Schema extends DescMessage, Response> = (\n req: MessageInitShape<Schema>,\n signal?: AbortSignal,\n) => Promise<Response>;\n\n/**\n * The options a generated read takes — its hook (`useGetOrder(req,\n * options?)`) and its query-options builder\n * (`orderServiceQueries.getOrder(req, options?)`) alike: React Query's own,\n * minus the two the read owns, the key and the fetch. Everything else\n * (`select`, `enabled`, `staleTime`, `placeholderData`, …) passes through.\n *\n * TData is what `data` is, exactly as in React Query's own useQuery: the\n * response by default, or whatever `select` returns. A call site mapping the\n * proto response to a view model passes `select: (r) => toView(r)` and gets\n * `data: View` — no useMemo over `data`, and React Query re-runs the mapping\n * only when the response changes (or when `select` itself does, so hoist it\n * or wrap it in useCallback when it is expensive).\n */\nexport type ServiceQueryInput<Response, TData = Response> = Partial<\n Omit<\n UseQueryOptions<Response, ConnectClientError, TData, ServiceQueryKey>,\n \"queryKey\" | \"queryFn\"\n >\n>;\n\n/**\n * What a query-options builder returns: TanStack's `queryOptions(...)`\n * result, so it goes anywhere React Query takes one —\n * `queryClient.fetchQuery`, `ensureQueryData`, `prefetchQuery`,\n * `useQueries({ queries })`, `useQuery`, `useSuspenseQuery`. Its `queryKey`\n * is data-tagged, so `queryClient.getQueryData(opts.queryKey)` and\n * `setQueryData` are typed as the response with no annotation.\n *\n * fetchQuery / ensureQueryData resolve to the RESPONSE, not to `select`'s\n * TData: they return what the cache holds, and `select` is an observer\n * option that only a hook or useQueries applies.\n */\nexport type ServiceQueryOptions<Response, TData = Response> = UseQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey\n> & {\n queryKey: DataTag<ServiceQueryKey, Response, ConnectClientError>;\n};\n\n/**\n * A generated query-options builder: `orderServiceQueries.getOrder(req,\n * options?)`. It is the read's single definition — the generated hook\n * `useGetOrder` is `useQuery` over exactly this — so code outside React (an\n * imperative action, a stream handler, a router loader, a prefetch) shares\n * the hook's cache entry and fetch by construction rather than by copying\n * its key and its client call.\n */\nexport type ServiceQueryOptionsBuilder<Schema extends DescMessage, Response> = <\n TData = Response,\n>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n) => ServiceQueryOptions<Response, TData>;\n\n/**\n * A generated query hook: `useGetOrder(req, options?)` — `useQuery` over the\n * service's query-options builder for the same rpc. TData is inferred from\n * `select` and is a parameter of the HOOK, not of the factory, so every call\n * site picks its own.\n */\nexport type ServiceQueryHook<Schema extends DescMessage, Response> = <\n TData = Response,\n>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n) => UseQueryResult<TData, ConnectClientError>;\n\n/**\n * The options a generated infinite read takes: React Query's own, minus the\n * three the read owns — the key, the fetch, and the first page param.\n * `getNextPageParam` defaults to the response's `nextPageToken` (empty means\n * last page) and may be overridden; everything else (`select`,\n * `placeholderData`, `staleTime`, `enabled`, `maxPages`, …) passes through.\n */\nexport type ServiceInfiniteQueryInput<Response, TData> = Partial<\n Omit<\n UseInfiniteQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey,\n string\n >,\n \"queryKey\" | \"queryFn\" | \"initialPageParam\"\n >\n>;\n\n/**\n * What an infinite query-options builder returns: TanStack's\n * `infiniteQueryOptions(...)` result, for `queryClient.fetchInfiniteQuery`,\n * `prefetchInfiniteQuery`, `ensureInfiniteQueryData` and\n * `useInfiniteQuery`. Its key is data-tagged as `InfiniteData<Response,\n * string>`, the `{ pages, pageParams }` the cache holds.\n */\nexport type ServiceInfiniteQueryOptions<\n Response,\n TData = InfiniteData<Response, string>,\n> = UseInfiniteQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey,\n string\n> & {\n queryKey: DataTag<\n ServiceQueryKey,\n InfiniteData<Response, string>,\n ConnectClientError\n >;\n};\n\n/**\n * A generated infinite query-options builder:\n * `orderServiceQueries.listOrdersInfinite(req, options?)`, for a read that\n * follows forge's list-pagination shape. `useListOrdersInfinite` is\n * `useInfiniteQuery` over exactly this.\n */\nexport type ServiceInfiniteQueryOptionsBuilder<\n Schema extends DescMessage,\n Response extends PagedResponse,\n> = <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n) => ServiceInfiniteQueryOptions<Response, TData>;\n\n/**\n * A generated infinite-query hook: `useListOrdersInfinite(req, options?)`,\n * for a read that follows forge's list-pagination shape (`string page_token`\n * on the request, `string next_page_token` on the response).\n *\n * `req` is the first page's request without its token. `data` is React\n * Query's `{ pages, pageParams }` by default; `select` reshapes it, so\n * flattening the pages into one list is\n * `select: (d) => d.pages.flatMap((p) => p.orders)`.\n */\nexport type ServiceInfiniteQueryHook<\n Schema extends DescMessage,\n Response extends PagedResponse,\n> = <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n) => UseInfiniteQueryResult<TData, ConnectClientError>;\n\n/**\n * A generated mutation hook: `useCreateOrder(options?)`.\n *\n * TContext is React Query's fourth type parameter — what `onMutate` returns\n * and `onError` / `onSettled` receive back. It is a parameter of the HOOK,\n * not of the factory, so each call site gets its own: TypeScript infers it\n * from the `onMutate` the caller passes, and an optimistic update reads its\n * rollback off `context` with no cast. Fixing it at the factory (or leaving\n * it out, which pinned it to `unknown`) forced every such call site to\n * narrow `unknown` by hand. It defaults to `unknown`, so a call without\n * `onMutate` types exactly as before.\n */\nexport type ServiceMutationHook<Schema extends DescMessage, Response> = <\n TContext = unknown,\n>(\n options?: Partial<\n UseMutationOptions<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n >\n >,\n) => UseMutationResult<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n>;\n\n/**\n * createQueryOptions builds one read's query-options builder — the single\n * definition of its cache key and its fetch that the generated hook, and\n * every caller outside React, share.\n *\n * The error is typed as ConnectClientError rather than React Query's default\n * `Error`, on the options themselves, because React Query takes a result's\n * error type from the TError it reads there: `query.error` is a\n * ConnectClientError at every call site with nothing to pass. Typing it as\n * `Error` hid `.reason`, `.codeName`, `.status` and `.retryable`, and left\n * `err.message.includes(...)` as the only expression that compiled — the one\n * thing the error contract forbids.\n *\n * The caller's options are spread BEFORE the key and the fetch: a caller may\n * set anything else, never the two that make this read this read.\n */\nexport function createQueryOptions<Schema extends DescMessage, Response>(\n keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey,\n call: ServiceCall<Schema, Response>,\n): ServiceQueryOptionsBuilder<Schema, Response> {\n return <TData = Response>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n ): ServiceQueryOptions<Response, TData> =>\n queryOptions<Response, ConnectClientError, TData, ServiceQueryKey>({\n ...options,\n queryKey: keyFor(req),\n queryFn: ({ signal }) => call(req, signal),\n }) as ServiceQueryOptions<Response, TData>;\n}\n\n/**\n * createQueryHook builds one useQuery wrapper over a read's query-options\n * builder. It adds nothing to the options: a hook and a `fetchQuery` of the\n * same request therefore read and write one cache entry with one fetch.\n */\nexport function createQueryHook<Schema extends DescMessage, Response>(\n optionsFor: ServiceQueryOptionsBuilder<Schema, Response>,\n): ServiceQueryHook<Schema, Response> {\n return <TData = Response>(\n req: MessageInitShape<Schema>,\n options?: ServiceQueryInput<Response, TData>,\n ) => useQuery(optionsFor(req, options));\n}\n\n/**\n * createInfiniteQueryOptions builds one paged read's infinite query-options\n * builder.\n *\n * The page token is the builder's: the first page is fetched with the\n * caller's request as given (no token, so an `optional string page_token`\n * stays unset), each later one with the previous response's\n * `nextPageToken`, and an empty token ends the listing. The key is\n * `keys.infinite(...)`, which strips the token and sits under the service and\n * entity scopes, so the mutations that invalidate a plain list refetch this\n * one too.\n *\n * The caller's options are spread BETWEEN the default getNextPageParam and\n * the three fields the read owns: a caller may replace the default, never the\n * key, the fetch or the first page.\n */\nexport function createInfiniteQueryOptions<\n Schema extends DescMessage,\n Response extends PagedResponse,\n>(\n keyFor: (req: PageRequest<Schema>) => ServiceQueryKey,\n call: ServiceCall<Schema, Response>,\n): ServiceInfiniteQueryOptionsBuilder<Schema, Response> {\n return <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n ): ServiceInfiniteQueryOptions<Response, TData> =>\n infiniteQueryOptions<\n Response,\n ConnectClientError,\n TData,\n ServiceQueryKey,\n string\n >({\n getNextPageParam: (last) => last.nextPageToken || undefined,\n ...options,\n queryKey: keyFor(req),\n queryFn: ({ pageParam, signal }) =>\n call(\n (pageParam === \"\"\n ? req\n : {\n ...req,\n pageToken: pageParam,\n }) as unknown as MessageInitShape<Schema>,\n signal,\n ),\n initialPageParam: \"\",\n }) as ServiceInfiniteQueryOptions<Response, TData>;\n}\n\n/**\n * createInfiniteQueryHook builds one useInfiniteQuery wrapper over a paged\n * read's infinite query-options builder, adding nothing to it — so a hook\n * and a `fetchInfiniteQuery` of the same request share one cache entry.\n */\nexport function createInfiniteQueryHook<\n Schema extends DescMessage,\n Response extends PagedResponse,\n>(\n optionsFor: ServiceInfiniteQueryOptionsBuilder<Schema, Response>,\n): ServiceInfiniteQueryHook<Schema, Response> {\n return <TData = InfiniteData<Response, string>>(\n req: PageRequest<Schema>,\n options?: ServiceInfiniteQueryInput<Response, TData>,\n ) => useInfiniteQuery(optionsFor(req, options));\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 <TContext = unknown>(\n options?: Partial<\n UseMutationOptions<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n >\n >,\n ) => {\n const queryClient = useQueryClient();\n const { onSuccess, ...rest } = options ?? {};\n return useMutation<\n Response,\n ConnectClientError,\n MessageInitShape<Schema>,\n TContext\n >({\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,23 @@
|
|
|
1
|
+
/** The SDK's session cookie (otel-web COOKIE_NAME). */
|
|
2
|
+
export declare const RUM_SESSION_COOKIE = "__rum_sid";
|
|
3
|
+
/** Where the session cookie is kept when cookies do not persist. */
|
|
4
|
+
export declare const RUM_SESSION_STORAGE_KEY = "forge.rum.session";
|
|
5
|
+
/** The slice of Storage the fallback needs. */
|
|
6
|
+
export interface SessionStore {
|
|
7
|
+
getItem(key: string): string | null;
|
|
8
|
+
setItem(key: string, value: string): void;
|
|
9
|
+
removeItem(key: string): void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Whether `document.cookie` keeps what is written to it. Writes a short-lived
|
|
13
|
+
* probe cookie, reads it back and removes it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function cookiesPersist(doc: Pick<Document, "cookie">): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Keep the SDK's session cookie in `storage` when `doc` cannot keep cookies.
|
|
18
|
+
* Returns whether the fallback is now in place (it was installed by this call,
|
|
19
|
+
* or already was). Idempotent; never throws.
|
|
20
|
+
*
|
|
21
|
+
* Call it before the SDK initialises: the SDK reads the session at init.
|
|
22
|
+
*/
|
|
23
|
+
export declare function installSessionCookieFallback(doc: Document, storage: SessionStore | null, now?: () => number): boolean;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
|
|
2
|
+
//
|
|
3
|
+
// A stable RUM session on an origin that cannot keep cookies.
|
|
4
|
+
//
|
|
5
|
+
// ── Why ──────────────────────────────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// The HyperDX SDK (its otel-web layer) keeps the session ONLY in a cookie,
|
|
8
|
+
// `__rum_sid`: `{ id, startTime }`, written with `max-age=900` and renewed
|
|
9
|
+
// while the user is active. Every 60 s it reads the cookie back, and when it
|
|
10
|
+
// finds none it mints a new session id. There is no other store and no seam
|
|
11
|
+
// to supply one: the session module is bundled inside the HyperDX browser package.
|
|
12
|
+
//
|
|
13
|
+
// Some origins accept a `document.cookie` write and silently keep nothing.
|
|
14
|
+
// The one that matters is an Electron renderer served from a custom scheme
|
|
15
|
+
// (`app://bundle/`): Chromium treats a non-http(s) scheme as non-cookieable
|
|
16
|
+
// even when it is registered `standard` + `secure`, so the write is dropped
|
|
17
|
+
// and `session.cookies.set` refuses with EXCLUDE_NONCOOKIEABLE_SCHEME.
|
|
18
|
+
// Verified against Electron 41. On such an origin every open window starts a
|
|
19
|
+
// new "session" every minute; each one re-uploads a full replay snapshot and
|
|
20
|
+
// none of them is a session anyone can watch.
|
|
21
|
+
//
|
|
22
|
+
// ── What ─────────────────────────────────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// When, and only when, a probe cookie does not round-trip, the session cookie
|
|
25
|
+
// is kept in localStorage instead. An own `cookie` accessor on `document`
|
|
26
|
+
// answers reads and writes of `__rum_sid` from localStorage, honouring the
|
|
27
|
+
// max-age the SDK writes, and passes every other cookie to the browser
|
|
28
|
+
// untouched. The SDK's own rules then hold unchanged: the session expires 15
|
|
29
|
+
// minutes after the last activity (max-age=900, renewed on activity) and never
|
|
30
|
+
// outlives 4 hours (the startTime inside the value). localStorage is shared by
|
|
31
|
+
// every window of the origin, as a cookie jar is, so the windows of one app
|
|
32
|
+
// share one session the way the tabs of one browser do.
|
|
33
|
+
//
|
|
34
|
+
// An origin whose cookies work is left exactly as it was.
|
|
35
|
+
/** The SDK's session cookie (otel-web COOKIE_NAME). */
|
|
36
|
+
export const RUM_SESSION_COOKIE = "__rum_sid";
|
|
37
|
+
/** Where the session cookie is kept when cookies do not persist. */
|
|
38
|
+
export const RUM_SESSION_STORAGE_KEY = "forge.rum.session";
|
|
39
|
+
const PROBE_COOKIE = "forge_cookie_probe";
|
|
40
|
+
/**
|
|
41
|
+
* Whether `document.cookie` keeps what is written to it. Writes a short-lived
|
|
42
|
+
* probe cookie, reads it back and removes it.
|
|
43
|
+
*/
|
|
44
|
+
export function cookiesPersist(doc) {
|
|
45
|
+
try {
|
|
46
|
+
const token = Math.random().toString(36).slice(2);
|
|
47
|
+
doc.cookie = `${PROBE_COOKIE}=${token}; path=/; max-age=60; SameSite=Strict`;
|
|
48
|
+
const kept = doc.cookie
|
|
49
|
+
.split(";")
|
|
50
|
+
.some((c) => c.trim() === `${PROBE_COOKIE}=${token}`);
|
|
51
|
+
doc.cookie = `${PROBE_COOKIE}=; path=/; max-age=0; SameSite=Strict`;
|
|
52
|
+
return kept;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A sandboxed document throws on cookie access: nothing persists.
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Keep the SDK's session cookie in `storage` when `doc` cannot keep cookies.
|
|
61
|
+
* Returns whether the fallback is now in place (it was installed by this call,
|
|
62
|
+
* or already was). Idempotent; never throws.
|
|
63
|
+
*
|
|
64
|
+
* Call it before the SDK initialises: the SDK reads the session at init.
|
|
65
|
+
*/
|
|
66
|
+
export function installSessionCookieFallback(doc, storage, now = Date.now) {
|
|
67
|
+
try {
|
|
68
|
+
if (installed.has(doc)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (!storage || !storageWorks(storage) || cookiesPersist(doc)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const native = findCookieDescriptor(doc);
|
|
75
|
+
if (!native?.get || !native.set) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const nativeGet = native.get;
|
|
79
|
+
const nativeSet = native.set;
|
|
80
|
+
Object.defineProperty(doc, "cookie", {
|
|
81
|
+
configurable: true,
|
|
82
|
+
enumerable: true,
|
|
83
|
+
get() {
|
|
84
|
+
const jar = String(nativeGet.call(this) ?? "");
|
|
85
|
+
const session = readStored(storage, now());
|
|
86
|
+
if (session === null) {
|
|
87
|
+
return jar;
|
|
88
|
+
}
|
|
89
|
+
const entry = `${RUM_SESSION_COOKIE}=${session}`;
|
|
90
|
+
return jar ? `${jar}; ${entry}` : entry;
|
|
91
|
+
},
|
|
92
|
+
set(value) {
|
|
93
|
+
const text = String(value);
|
|
94
|
+
if (!text.startsWith(`${RUM_SESSION_COOKIE}=`)) {
|
|
95
|
+
nativeSet.call(this, text);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
writeStored(storage, text, now());
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
installed.add(doc);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const installed = new WeakSet();
|
|
109
|
+
/** The accessor `doc.cookie` resolves to: its own, or its prototype chain's. */
|
|
110
|
+
function findCookieDescriptor(doc) {
|
|
111
|
+
for (let o = doc; o; o = Object.getPrototypeOf(o)) {
|
|
112
|
+
const d = Object.getOwnPropertyDescriptor(o, "cookie");
|
|
113
|
+
if (d) {
|
|
114
|
+
return d;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
function storageWorks(storage) {
|
|
120
|
+
const key = `${RUM_SESSION_STORAGE_KEY}.probe`;
|
|
121
|
+
try {
|
|
122
|
+
storage.setItem(key, "1");
|
|
123
|
+
const ok = storage.getItem(key) === "1";
|
|
124
|
+
storage.removeItem(key);
|
|
125
|
+
return ok;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function readStored(storage, at) {
|
|
132
|
+
try {
|
|
133
|
+
const raw = storage.getItem(RUM_SESSION_STORAGE_KEY);
|
|
134
|
+
if (!raw) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const stored = JSON.parse(raw);
|
|
138
|
+
if (typeof stored.v !== "string" || typeof stored.exp !== "number") {
|
|
139
|
+
storage.removeItem(RUM_SESSION_STORAGE_KEY);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (stored.exp <= at) {
|
|
143
|
+
// Expired, as the browser would have expired the cookie.
|
|
144
|
+
storage.removeItem(RUM_SESSION_STORAGE_KEY);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return stored.v;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Store a `__rum_sid=<value>; …attributes` write. `max-age` (seconds) or
|
|
155
|
+
* `expires` set the expiry; `max-age=0` or a past expiry deletes. A write with
|
|
156
|
+
* neither is a session cookie, which here lasts the SDK's inactivity window:
|
|
157
|
+
* the SDK always sends max-age, so this is only a defined answer for a write
|
|
158
|
+
* it does not make.
|
|
159
|
+
*/
|
|
160
|
+
function writeStored(storage, text, at) {
|
|
161
|
+
const [pair = "", ...attributes] = text.split(";");
|
|
162
|
+
const value = pair.slice(RUM_SESSION_COOKIE.length + 1).trim();
|
|
163
|
+
let exp = null;
|
|
164
|
+
for (const attribute of attributes) {
|
|
165
|
+
const eq = attribute.indexOf("=");
|
|
166
|
+
const name = (eq < 0 ? attribute : attribute.slice(0, eq))
|
|
167
|
+
.trim()
|
|
168
|
+
.toLowerCase();
|
|
169
|
+
const raw = eq < 0 ? "" : attribute.slice(eq + 1).trim();
|
|
170
|
+
if (name === "max-age") {
|
|
171
|
+
const seconds = Number(raw);
|
|
172
|
+
if (Number.isFinite(seconds)) {
|
|
173
|
+
// max-age wins over expires, as in a browser.
|
|
174
|
+
exp = at + seconds * 1000;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (name === "expires" && exp === null) {
|
|
179
|
+
const when = Date.parse(raw);
|
|
180
|
+
if (!Number.isNaN(when)) {
|
|
181
|
+
exp = when;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
if (value === "" || (exp !== null && exp <= at)) {
|
|
187
|
+
storage.removeItem(RUM_SESSION_STORAGE_KEY);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const stored = {
|
|
191
|
+
v: value,
|
|
192
|
+
exp: exp ?? at + SESSION_COOKIE_FALLBACK_MS,
|
|
193
|
+
};
|
|
194
|
+
storage.setItem(RUM_SESSION_STORAGE_KEY, JSON.stringify(stored));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Storage full or revoked: the SDK sees no cookie and starts a session,
|
|
198
|
+
// as it would have without the fallback.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** The SDK's inactivity window (otel-web InactivityTimeoutSeconds). */
|
|
202
|
+
const SESSION_COOKIE_FALLBACK_MS = 15 * 60 * 1000;
|
|
203
|
+
//# sourceMappingURL=session-cookie-fallback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-cookie-fallback.js","sourceRoot":"","sources":["../src/session-cookie-fallback.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// A stable RUM session on an origin that cannot keep cookies.\n//\n// ── Why ──────────────────────────────────────────────────────────────────\n//\n// The HyperDX SDK (its otel-web layer) keeps the session ONLY in a cookie,\n// `__rum_sid`: `{ id, startTime }`, written with `max-age=900` and renewed\n// while the user is active. Every 60 s it reads the cookie back, and when it\n// finds none it mints a new session id. There is no other store and no seam\n// to supply one: the session module is bundled inside the HyperDX browser package.\n//\n// Some origins accept a `document.cookie` write and silently keep nothing.\n// The one that matters is an Electron renderer served from a custom scheme\n// (`app://bundle/`): Chromium treats a non-http(s) scheme as non-cookieable\n// even when it is registered `standard` + `secure`, so the write is dropped\n// and `session.cookies.set` refuses with EXCLUDE_NONCOOKIEABLE_SCHEME.\n// Verified against Electron 41. On such an origin every open window starts a\n// new \"session\" every minute; each one re-uploads a full replay snapshot and\n// none of them is a session anyone can watch.\n//\n// ── What ─────────────────────────────────────────────────────────────────\n//\n// When, and only when, a probe cookie does not round-trip, the session cookie\n// is kept in localStorage instead. An own `cookie` accessor on `document`\n// answers reads and writes of `__rum_sid` from localStorage, honouring the\n// max-age the SDK writes, and passes every other cookie to the browser\n// untouched. The SDK's own rules then hold unchanged: the session expires 15\n// minutes after the last activity (max-age=900, renewed on activity) and never\n// outlives 4 hours (the startTime inside the value). localStorage is shared by\n// every window of the origin, as a cookie jar is, so the windows of one app\n// share one session the way the tabs of one browser do.\n//\n// An origin whose cookies work is left exactly as it was.\n\n/** The SDK's session cookie (otel-web COOKIE_NAME). */\nexport const RUM_SESSION_COOKIE = \"__rum_sid\";\n\n/** Where the session cookie is kept when cookies do not persist. */\nexport const RUM_SESSION_STORAGE_KEY = \"forge.rum.session\";\n\nconst PROBE_COOKIE = \"forge_cookie_probe\";\n\n/** The slice of Storage the fallback needs. */\nexport interface SessionStore {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n removeItem(key: string): void;\n}\n\ninterface StoredCookie {\n /** The cookie value exactly as the SDK wrote it (URI-encoded JSON). */\n v: string;\n /** Epoch ms at which the cookie expires. */\n exp: number;\n}\n\n/**\n * Whether `document.cookie` keeps what is written to it. Writes a short-lived\n * probe cookie, reads it back and removes it.\n */\nexport function cookiesPersist(doc: Pick<Document, \"cookie\">): boolean {\n try {\n const token = Math.random().toString(36).slice(2);\n doc.cookie = `${PROBE_COOKIE}=${token}; path=/; max-age=60; SameSite=Strict`;\n const kept = doc.cookie\n .split(\";\")\n .some((c) => c.trim() === `${PROBE_COOKIE}=${token}`);\n doc.cookie = `${PROBE_COOKIE}=; path=/; max-age=0; SameSite=Strict`;\n return kept;\n } catch {\n // A sandboxed document throws on cookie access: nothing persists.\n return false;\n }\n}\n\n/**\n * Keep the SDK's session cookie in `storage` when `doc` cannot keep cookies.\n * Returns whether the fallback is now in place (it was installed by this call,\n * or already was). Idempotent; never throws.\n *\n * Call it before the SDK initialises: the SDK reads the session at init.\n */\nexport function installSessionCookieFallback(\n doc: Document,\n storage: SessionStore | null,\n now: () => number = Date.now,\n): boolean {\n try {\n if (installed.has(doc)) {\n return true;\n }\n if (!storage || !storageWorks(storage) || cookiesPersist(doc)) {\n return false;\n }\n const native = findCookieDescriptor(doc);\n if (!native?.get || !native.set) {\n return false;\n }\n const nativeGet = native.get;\n const nativeSet = native.set;\n Object.defineProperty(doc, \"cookie\", {\n configurable: true,\n enumerable: true,\n get(this: Document): string {\n const jar = String(nativeGet.call(this) ?? \"\");\n const session = readStored(storage, now());\n if (session === null) {\n return jar;\n }\n const entry = `${RUM_SESSION_COOKIE}=${session}`;\n return jar ? `${jar}; ${entry}` : entry;\n },\n set(this: Document, value: string) {\n const text = String(value);\n if (!text.startsWith(`${RUM_SESSION_COOKIE}=`)) {\n nativeSet.call(this, text);\n return;\n }\n writeStored(storage, text, now());\n },\n });\n installed.add(doc);\n return true;\n } catch {\n return false;\n }\n}\n\nconst installed = new WeakSet<object>();\n\n/** The accessor `doc.cookie` resolves to: its own, or its prototype chain's. */\nfunction findCookieDescriptor(doc: object): PropertyDescriptor | undefined {\n for (\n let o: object | null = doc;\n o;\n o = Object.getPrototypeOf(o) as object | null\n ) {\n const d = Object.getOwnPropertyDescriptor(o, \"cookie\");\n if (d) {\n return d;\n }\n }\n return undefined;\n}\n\nfunction storageWorks(storage: SessionStore): boolean {\n const key = `${RUM_SESSION_STORAGE_KEY}.probe`;\n try {\n storage.setItem(key, \"1\");\n const ok = storage.getItem(key) === \"1\";\n storage.removeItem(key);\n return ok;\n } catch {\n return false;\n }\n}\n\nfunction readStored(storage: SessionStore, at: number): string | null {\n try {\n const raw = storage.getItem(RUM_SESSION_STORAGE_KEY);\n if (!raw) {\n return null;\n }\n const stored = JSON.parse(raw) as Partial<StoredCookie>;\n if (typeof stored.v !== \"string\" || typeof stored.exp !== \"number\") {\n storage.removeItem(RUM_SESSION_STORAGE_KEY);\n return null;\n }\n if (stored.exp <= at) {\n // Expired, as the browser would have expired the cookie.\n storage.removeItem(RUM_SESSION_STORAGE_KEY);\n return null;\n }\n return stored.v;\n } catch {\n return null;\n }\n}\n\n/**\n * Store a `__rum_sid=<value>; …attributes` write. `max-age` (seconds) or\n * `expires` set the expiry; `max-age=0` or a past expiry deletes. A write with\n * neither is a session cookie, which here lasts the SDK's inactivity window:\n * the SDK always sends max-age, so this is only a defined answer for a write\n * it does not make.\n */\nfunction writeStored(storage: SessionStore, text: string, at: number): void {\n const [pair = \"\", ...attributes] = text.split(\";\");\n const value = pair.slice(RUM_SESSION_COOKIE.length + 1).trim();\n let exp: number | null = null;\n for (const attribute of attributes) {\n const eq = attribute.indexOf(\"=\");\n const name = (eq < 0 ? attribute : attribute.slice(0, eq))\n .trim()\n .toLowerCase();\n const raw = eq < 0 ? \"\" : attribute.slice(eq + 1).trim();\n if (name === \"max-age\") {\n const seconds = Number(raw);\n if (Number.isFinite(seconds)) {\n // max-age wins over expires, as in a browser.\n exp = at + seconds * 1000;\n break;\n }\n } else if (name === \"expires\" && exp === null) {\n const when = Date.parse(raw);\n if (!Number.isNaN(when)) {\n exp = when;\n }\n }\n }\n try {\n if (value === \"\" || (exp !== null && exp <= at)) {\n storage.removeItem(RUM_SESSION_STORAGE_KEY);\n return;\n }\n const stored: StoredCookie = {\n v: value,\n exp: exp ?? at + SESSION_COOKIE_FALLBACK_MS,\n };\n storage.setItem(RUM_SESSION_STORAGE_KEY, JSON.stringify(stored));\n } catch {\n // Storage full or revoked: the SDK sees no cookie and starts a session,\n // as it would have without the fallback.\n }\n}\n\n/** The SDK's inactivity window (otel-web InactivityTimeoutSeconds). */\nconst SESSION_COOKIE_FALLBACK_MS = 15 * 60 * 1000;\n"]}
|
package/dist/telemetry-gate.d.ts
CHANGED
|
@@ -8,5 +8,9 @@ export declare function registerTelemetryStartup(p: Promise<unknown>): void;
|
|
|
8
8
|
* has already settled.
|
|
9
9
|
*/
|
|
10
10
|
export declare function telemetryReady(maxMs?: number): Promise<void>;
|
|
11
|
+
/** Called by initBrowserTelemetry with the SDK's session getter, or null. */
|
|
12
|
+
export declare function registerRumSession(source: (() => string | undefined) | null): void;
|
|
13
|
+
/** The current RUM session id, or undefined with telemetry off. Never throws. */
|
|
14
|
+
export declare function rumSessionId(): string | undefined;
|
|
11
15
|
/** Test seam. */
|
|
12
16
|
export declare function resetTelemetryGate(): void;
|
package/dist/telemetry-gate.js
CHANGED
|
@@ -44,8 +44,33 @@ export function telemetryReady(maxMs = TELEMETRY_WAIT_MS) {
|
|
|
44
44
|
p.then(done, done);
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
// ── The RUM session the requests belong to ─────────────────────────────────
|
|
48
|
+
//
|
|
49
|
+
// initBrowserTelemetry registers the SDK's session id here once it runs, and
|
|
50
|
+
// clears it when telemetry is disabled. injectTraceContext (trace.ts) sends it
|
|
51
|
+
// as the W3C baggage member `session.id`, which forge servers copy onto their
|
|
52
|
+
// server span (forge/pkg/observe, span_identity.go), so a backend trace links
|
|
53
|
+
// back to the browser session that caused it.
|
|
54
|
+
/** The shape of a RUM session id; forge servers drop anything else. */
|
|
55
|
+
const RUM_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
56
|
+
let sessionSource = null;
|
|
57
|
+
/** Called by initBrowserTelemetry with the SDK's session getter, or null. */
|
|
58
|
+
export function registerRumSession(source) {
|
|
59
|
+
sessionSource = source;
|
|
60
|
+
}
|
|
61
|
+
/** The current RUM session id, or undefined with telemetry off. Never throws. */
|
|
62
|
+
export function rumSessionId() {
|
|
63
|
+
try {
|
|
64
|
+
const id = sessionSource?.();
|
|
65
|
+
return id && RUM_SESSION_ID_RE.test(id) ? id : undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
47
71
|
/** Test seam. */
|
|
48
72
|
export function resetTelemetryGate() {
|
|
49
73
|
startup = null;
|
|
74
|
+
sessionSource = null;
|
|
50
75
|
}
|
|
51
76
|
//# sourceMappingURL=telemetry-gate.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetry-gate.js","sourceRoot":"","sources":["../src/telemetry-gate.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,qEAAqE;AACrE,EAAE;AACF,+EAA+E;AAC/E,4EAA4E;AAC5E,+EAA+E;AAC/E,uEAAuE;AACvE,gFAAgF;AAChF,0EAA0E;AAC1E,SAAS;AACT,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAChF,gFAAgF;AAChF,2EAA2E;AAC3E,EAAE;AACF,iEAAiE;AACjE,2EAA2E;AAC3E,iCAAiC;AAEjC,yEAAyE;AACzE,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAEtC,IAAI,OAAO,GAA4B,IAAI,CAAC;AAE5C,2EAA2E;AAC3E,MAAM,UAAU,wBAAwB,CAAC,CAAmB;IAC1D,OAAO,GAAG,CAAC,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAgB,iBAAiB;IAEjC,MAAM,CAAC,GAAG,OAAO,CAAC;IAClB,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IACD,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QACnC,MAAM,KAAK,GAAG,UAAU,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACzC,MAAM,IAAI,GAAG,GAAG,EAAE;YAChB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,EAAE,CAAC;QACZ,CAAC,CAAC;QACF,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACrB,CAAC,CAAC,CAAC;AACL,CAAC;AAED,iBAAiB;AACjB,MAAM,UAAU,kBAAkB;IAChC,OAAO,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"telemetry-gate.js","sourceRoot":"","sources":["../src/telemetry-gate.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,qEAAqE;AACrE,EAAE;AACF,+EAA+E;AAC/E,4EAA4E;AAC5E,+EAA+E;AAC/E,uEAAuE;AACvE,gFAAgF;AAChF,0EAA0E;AAC1E,SAAS;AACT,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAChF,gFAAgF;AAChF,2EAA2E;AAC3E,EAAE;AACF,iEAAiE;AACjE,2EAA2E;AAC3E,iCAAiC;AAEjC,yEAAyE;AACzE,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAEtC,IAAI,OAAO,GAA4B,IAAI,CAAC;AAE5C,2EAA2E;AAC3E,MAAM,UAAU,wBAAwB,CAAC,CAAmB;IAC1D,OAAO,GAAG,CAAC,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAgB,iBAAiB;IAEjC,MAAM,CAAC,GAAG,OAAO,CAAC;IAClB,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IACD,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QACnC,MAAM,KAAK,GAAG,UAAU,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACzC,MAAM,IAAI,GAAG,GAAG,EAAE;YAChB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,EAAE,CAAC;QACZ,CAAC,CAAC;QACF,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACrB,CAAC,CAAC,CAAC;AACL,CAAC;AAED,8EAA8E;AAC9E,EAAE;AACF,6EAA6E;AAC7E,+EAA+E;AAC/E,8EAA8E;AAC9E,8EAA8E;AAC9E,8CAA8C;AAE9C,uEAAuE;AACvE,MAAM,iBAAiB,GAAG,wBAAwB,CAAC;AAEnD,IAAI,aAAa,GAAsC,IAAI,CAAC;AAE5D,6EAA6E;AAC7E,MAAM,UAAU,kBAAkB,CAChC,MAAyC;IAEzC,aAAa,GAAG,MAAM,CAAC;AACzB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,aAAa,EAAE,EAAE,CAAC;QAC7B,OAAO,EAAE,IAAI,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,iBAAiB;AACjB,MAAM,UAAU,kBAAkB;IAChC,OAAO,GAAG,IAAI,CAAC;IACf,aAAa,GAAG,IAAI,CAAC;AACvB,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The hand-off between browser telemetry startup and the first RPCs.\n//\n// The HyperDX SDK is a ~600 KB lazy chunk, so it patches `fetch` some hundreds\n// of milliseconds after the app is interactive — and a page fires its first\n// queries on mount, inside that window. A call made before the patch carries a\n// SYNTHESIZED traceparent (trace.ts) that names a span no browser ever\n// exported: the API's span has a parent id that matches nothing, and \"this page\n// load, end to end\" has no answer exactly for the requests the page makes\n// first.\n//\n// So initBrowserTelemetry registers its startup here, and the trace interceptor\n// waits for it — at most TELEMETRY_WAIT_MS, so a blocked chunk or an ad blocker\n// delays a request by that much and no more, then falls back to the synthesized\n// traceparent. With telemetry off nothing is registered and nothing waits.\n//\n// DOM-free and import-free on purpose: the transport entry point\n// (@reliantlabs/forge-web-runtime/interceptors) imports this and must stay\n// light enough for React Native.\n\n/** The longest an RPC waits for browser telemetry to finish starting. */\nexport const TELEMETRY_WAIT_MS = 2000;\n\nlet startup: Promise<unknown> | null = null;\n\n/** Called once by initBrowserTelemetry with the promise of its startup. */\nexport function registerTelemetryStartup(p: Promise<unknown>): void {\n startup = p;\n}\n\n/**\n * Resolves when telemetry has finished starting (or failed to), or after\n * `maxMs`, whichever is first. Resolves immediately when none is starting or it\n * has already settled.\n */\nexport function telemetryReady(\n maxMs: number = TELEMETRY_WAIT_MS,\n): Promise<void> {\n const p = startup;\n if (!p) {\n return Promise.resolve();\n }\n return new Promise<void>((resolve) => {\n const timer = setTimeout(resolve, maxMs);\n const done = () => {\n clearTimeout(timer);\n resolve();\n };\n p.then(done, done);\n });\n}\n\n// ── The RUM session the requests belong to ─────────────────────────────────\n//\n// initBrowserTelemetry registers the SDK's session id here once it runs, and\n// clears it when telemetry is disabled. injectTraceContext (trace.ts) sends it\n// as the W3C baggage member `session.id`, which forge servers copy onto their\n// server span (forge/pkg/observe, span_identity.go), so a backend trace links\n// back to the browser session that caused it.\n\n/** The shape of a RUM session id; forge servers drop anything else. */\nconst RUM_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;\n\nlet sessionSource: (() => string | undefined) | null = null;\n\n/** Called by initBrowserTelemetry with the SDK's session getter, or null. */\nexport function registerRumSession(\n source: (() => string | undefined) | null,\n): void {\n sessionSource = source;\n}\n\n/** The current RUM session id, or undefined with telemetry off. Never throws. */\nexport function rumSessionId(): string | undefined {\n try {\n const id = sessionSource?.();\n return id && RUM_SESSION_ID_RE.test(id) ? id : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Test seam. */\nexport function resetTelemetryGate(): void {\n startup = null;\n sessionSource = null;\n}\n"]}
|