@xemahq/ui-kernel 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/errors/http-error-status.d.ts +17 -0
- package/dist/errors/http-error-status.d.ts.map +1 -0
- package/dist/errors/http-error-status.js +59 -0
- package/dist/errors/http-error-status.js.map +1 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +2 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/biome-host/errors.d.ts.map +1 -1
- package/dist/lib/biome-host/errors.js +1 -19
- package/dist/lib/biome-host/errors.js.map +1 -1
- package/dist/offline/index.d.ts.map +1 -1
- package/dist/offline/index.js.map +1 -1
- package/dist/offline/online-controller.d.ts.map +1 -1
- package/dist/offline/online-controller.js +6 -1
- package/dist/offline/online-controller.js.map +1 -1
- package/dist/offline/outbox.d.ts.map +1 -1
- package/dist/offline/outbox.js +2 -24
- package/dist/offline/outbox.js.map +1 -1
- package/package.json +1 -2
- package/src/index.ts +0 -17
- package/src/lib/biome-host/agent-validation.ts +0 -239
- package/src/lib/biome-host/biome-builders.ts +0 -112
- package/src/lib/biome-host/biome-mode.ts +0 -6
- package/src/lib/biome-host/biome-navigation.ts +0 -37
- package/src/lib/biome-host/biome-registry.ts +0 -299
- package/src/lib/biome-host/biome-scope-hooks.ts +0 -41
- package/src/lib/biome-host/biome-scope.tsx +0 -52
- package/src/lib/biome-host/biome-scoped-query.ts +0 -134
- package/src/lib/biome-host/create-biome-orval-config.ts +0 -82
- package/src/lib/biome-host/define-web-biome.spec.ts +0 -221
- package/src/lib/biome-host/define-web-biome.ts +0 -222
- package/src/lib/biome-host/errors.ts +0 -220
- package/src/lib/biome-host/frontend-biome.ts +0 -256
- package/src/lib/biome-host/host-bridge.ts +0 -281
- package/src/lib/biome-host/host-sources.ts +0 -54
- package/src/lib/biome-host/i18n-hooks.ts +0 -51
- package/src/lib/biome-host/i18n-port.test.ts +0 -50
- package/src/lib/biome-host/i18n-port.ts +0 -96
- package/src/lib/biome-host/index.ts +0 -35
- package/src/lib/biome-host/realtime-hooks.ts +0 -75
- package/src/lib/biome-host/realtime-port.ts +0 -115
- package/src/lib/biome-host/session-contributions.ts +0 -296
- package/src/lib/biome-host/use-mutation-with-error-toast.ts +0 -90
- package/src/lib/biome-host/use-page-state.ts +0 -248
- package/src/lib/capabilities/capability-context.ts +0 -25
- package/src/lib/capabilities/capability-provider.tsx +0 -73
- package/src/lib/capabilities/index.ts +0 -17
- package/src/lib/capabilities/types.ts +0 -69
- package/src/lib/capabilities/use-capability.ts +0 -79
- package/src/lib/contribution-registry/contribution-registry.spec.ts +0 -141
- package/src/lib/contribution-registry/contribution-registry.ts +0 -204
- package/src/lib/contribution-registry/index.ts +0 -9
- package/src/lib/contribution-registry/renderable-registry.ts +0 -142
- package/src/lib/system-bus/capability-invoke.ts +0 -92
- package/src/lib/system-bus/deeplink.ts +0 -210
- package/src/lib/system-bus/enums.ts +0 -86
- package/src/lib/system-bus/host-ports.ts +0 -93
- package/src/lib/system-bus/index.ts +0 -16
- package/src/lib/system-bus/intent-registry.ts +0 -109
- package/src/lib/system-bus/intents.ts +0 -113
- package/src/lib/system-bus/palette.ts +0 -78
- package/src/lib/system-bus/system-bus-builder.ts +0 -159
- package/src/lib/system-bus/system-bus.ts +0 -37
- package/src/lib/system-bus/windows.ts +0 -51
- package/src/offline/id.ts +0 -16
- package/src/offline/index.ts +0 -54
- package/src/offline/mutation.ts +0 -75
- package/src/offline/offline-status-badge.tsx +0 -51
- package/src/offline/online-controller.ts +0 -105
- package/src/offline/outbox.test.ts +0 -172
- package/src/offline/outbox.ts +0 -348
- package/src/offline/react-hooks.ts +0 -40
- package/src/offline/react.tsx +0 -32
- package/src/offline/storage.ts +0 -112
- package/src/offline/types.ts +0 -77
- package/src/org-db/components/DbResultTable.tsx +0 -176
- package/src/org-db/index.ts +0 -1
- package/src/pdf/index.ts +0 -11
- package/src/registry/index.ts +0 -9
- package/src/registry/lib/agent-validation-host.ts +0 -36
- package/src/registry/lib/biome-slot.tsx +0 -50
- package/src/registry/lib/biomes-enabled-context.ts +0 -20
- package/src/registry/lib/extension-points.ts +0 -134
- package/src/registry/lib/primitives/SessionEventCard.tsx +0 -164
- package/src/registry/lib/primitives/SessionEventTimeline.tsx +0 -95
- package/src/registry/lib/primitives/SessionMutationBar.tsx +0 -76
- package/src/registry/lib/primitives/SessionTabbedDrawer.tsx +0 -165
- package/src/registry/lib/primitives/index.ts +0 -18
- package/src/registry/lib/session-context-builder.ts +0 -84
- package/src/registry/lib/session-context-provider.tsx +0 -33
- package/src/registry/lib/session-context.ts +0 -21
- package/src/registry/lib/session-selectors.ts +0 -272
- package/src/session/comments/CommentRail.tsx +0 -164
- package/src/session/index.ts +0 -3
- package/src/session/lib/cn.ts +0 -11
- package/src/session/shell/SessionWorkspaceShell.tsx +0 -147
- package/src/session-kit/approvals/ApprovalButton.tsx +0 -89
- package/src/session-kit/approvals/ApprovalCard.tsx +0 -341
- package/src/session-kit/approvals/ApprovalsCenter.tsx +0 -336
- package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +0 -118
- package/src/session-kit/approvals/Obligation.tsx +0 -111
- package/src/session-kit/approvals/ScopedBody.tsx +0 -392
- package/src/session-kit/approvals/approval-icons.ts +0 -31
- package/src/session-kit/approvals/approval-model.ts +0 -204
- package/src/session-kit/approvals/index.ts +0 -22
- package/src/session-kit/approvals/obligation-display.ts +0 -100
- package/src/session-kit/approvals/risk-accent.ts +0 -47
- package/src/session-kit/approvals/scope-icons.ts +0 -19
- package/src/session-kit/combobox/Combobox.tsx +0 -358
- package/src/session-kit/combobox/use-click-outside.ts +0 -21
- package/src/session-kit/display/ContextHeader.tsx +0 -148
- package/src/session-kit/display/FileDiffCard.tsx +0 -140
- package/src/session-kit/display/MD.tsx +0 -162
- package/src/session-kit/display/MessageTurn.tsx +0 -157
- package/src/session-kit/display/ThinkingPanel.tsx +0 -81
- package/src/session-kit/display/TodoChecklist.tsx +0 -120
- package/src/session-kit/display/TokenMeter.tsx +0 -89
- package/src/session-kit/display/ToolStrip.tsx +0 -278
- package/src/session-kit/display/TypingDots.tsx +0 -24
- package/src/session-kit/index.ts +0 -44
- package/src/session-kit/lib/enums.ts +0 -66
- package/src/session-kit/lib/portal-accent.ts +0 -30
- package/src/session-kit/lib/status-dot.ts +0 -68
- package/src/session-kit/primitives/Avatar.tsx +0 -44
- package/src/session-kit/primitives/PortalGlyph.tsx +0 -51
- package/src/session-kit/primitives/RiskPill.tsx +0 -95
- package/src/session-kit/primitives/ScopeDot.tsx +0 -47
- package/src/session-kit/primitives/Segmented.tsx +0 -71
- package/src/ui/branding/app-branding-context.ts +0 -20
- package/src/ui/branding/app-branding-provider.tsx +0 -123
- package/src/ui/branding/sanitize.ts +0 -89
- package/src/ui/breakpoints.ts +0 -14
- package/src/ui/capture/document-preview.tsx +0 -166
- package/src/ui/capture/field-spec-to-zod.test.ts +0 -81
- package/src/ui/capture/field-spec-to-zod.ts +0 -174
- package/src/ui/capture/field-spec-validate.ts +0 -236
- package/src/ui/capture/field-spec.test.ts +0 -49
- package/src/ui/capture/field-spec.ts +0 -274
- package/src/ui/capture/index.ts +0 -51
- package/src/ui/capture/schema-field-input.tsx +0 -620
- package/src/ui/capture/schema-form.tsx +0 -149
- package/src/ui/capture/submit-values.test.ts +0 -70
- package/src/ui/capture/submit-values.ts +0 -63
- package/src/ui/capture/use-prefill.ts +0 -186
- package/src/ui/capture/use-schema-validation.ts +0 -145
- package/src/ui/capture/use-wizard.ts +0 -180
- package/src/ui/capture/value-format.ts +0 -32
- package/src/ui/capture/wizard.tsx +0 -105
- package/src/ui/charts/bar-chart.tsx +0 -139
- package/src/ui/charts/chart-frame.tsx +0 -125
- package/src/ui/charts/chart-types.ts +0 -52
- package/src/ui/charts/chart-utils.ts +0 -190
- package/src/ui/charts/charts.test.tsx +0 -204
- package/src/ui/charts/donut-chart.tsx +0 -129
- package/src/ui/charts/index.ts +0 -27
- package/src/ui/charts/line-chart.tsx +0 -202
- package/src/ui/charts/sparkline.tsx +0 -109
- package/src/ui/chrome/AsyncBoundary.tsx +0 -74
- package/src/ui/chrome/EmptyState.tsx +0 -257
- package/src/ui/chrome/ErrorCard.tsx +0 -63
- package/src/ui/chrome/LoadingState.tsx +0 -61
- package/src/ui/chrome/PageHeader.tsx +0 -137
- package/src/ui/chrome/RecordCardList.tsx +0 -98
- package/src/ui/chrome/StateCard.tsx +0 -150
- package/src/ui/chrome/confirm-delete-dialog.tsx +0 -59
- package/src/ui/chrome/page-shell.spec.tsx +0 -117
- package/src/ui/chrome/page-shell.tsx +0 -221
- package/src/ui/chrome/scope-badge.tsx +0 -40
- package/src/ui/chrome/status-badge.tsx +0 -79
- package/src/ui/cn.ts +0 -32
- package/src/ui/design-tokens.ts +0 -372
- package/src/ui/hooks/use-debounced-value.ts +0 -16
- package/src/ui/hooks/use-is-mobile.ts +0 -28
- package/src/ui/index.ts +0 -110
- package/src/ui/pdf/pdf-preview.tsx +0 -122
- package/src/ui/primitives/alert-dialog.tsx +0 -104
- package/src/ui/primitives/async-combobox.tsx +0 -182
- package/src/ui/primitives/badge-variants.ts +0 -21
- package/src/ui/primitives/badge.tsx +0 -25
- package/src/ui/primitives/button-variants.ts +0 -31
- package/src/ui/primitives/button.tsx +0 -29
- package/src/ui/primitives/card.tsx +0 -43
- package/src/ui/primitives/checkbox.tsx +0 -26
- package/src/ui/primitives/collapsible.tsx +0 -9
- package/src/ui/primitives/dialog.tsx +0 -103
- package/src/ui/primitives/dropdown-menu.tsx +0 -179
- package/src/ui/primitives/form-stepper.tsx +0 -94
- package/src/ui/primitives/form-stepper.types.ts +0 -15
- package/src/ui/primitives/input.tsx +0 -22
- package/src/ui/primitives/label.tsx +0 -17
- package/src/ui/primitives/overflow-tabs.tsx +0 -331
- package/src/ui/primitives/popover.tsx +0 -33
- package/src/ui/primitives/preview-device-frame.tsx +0 -213
- package/src/ui/primitives/radio-group.tsx +0 -36
- package/src/ui/primitives/resizable.tsx +0 -67
- package/src/ui/primitives/scroll-area.tsx +0 -38
- package/src/ui/primitives/select.tsx +0 -143
- package/src/ui/primitives/separator.tsx +0 -20
- package/src/ui/primitives/sheet.tsx +0 -107
- package/src/ui/primitives/skeleton.tsx +0 -99
- package/src/ui/primitives/switch.tsx +0 -27
- package/src/ui/primitives/table.tsx +0 -72
- package/src/ui/primitives/tabs.tsx +0 -53
- package/src/ui/primitives/tag-multi-select.tsx +0 -261
- package/src/ui/primitives/textarea.tsx +0 -21
- package/src/ui/primitives/tooltip.tsx +0 -30
- package/src/ui/primitives/url-preview-chrome.tsx +0 -103
- package/src/vista/GenericCatalogueSurface.tsx +0 -179
- package/src/vista/SurfaceFallbackCard.tsx +0 -109
- package/src/vista/VistaPanel.tsx +0 -163
- package/src/vista/form-payload.spec.ts +0 -139
- package/src/vista/html/CustomUiFrame.spec.tsx +0 -127
- package/src/vista/html/CustomUiFrame.tsx +0 -206
- package/src/vista/html/custom-ui-frame-bridge.spec.ts +0 -191
- package/src/vista/html/custom-ui-frame-bridge.ts +0 -98
- package/src/vista/html/custom-ui-frame-contract.ts +0 -88
- package/src/vista/html/custom-ui-intents.spec.ts +0 -135
- package/src/vista/html/custom-ui-intents.ts +0 -155
- package/src/vista/html/custom-ui-srcdoc.spec.ts +0 -151
- package/src/vista/html/custom-ui-srcdoc.ts +0 -193
- package/src/vista/html/index.ts +0 -13
- package/src/vista/index.ts +0 -52
- package/src/vista/surface-catalogue.spec.ts +0 -140
- package/src/vista/surface-catalogue.ts +0 -90
- package/src/vista/surface-icons.ts +0 -57
- package/src/vista/surface-registry.ts +0 -56
- package/src/vista/surfaces/ChartSurface.tsx +0 -34
- package/src/vista/surfaces/FilesSurface.tsx +0 -202
- package/src/vista/surfaces/FormSurface.tsx +0 -380
- package/src/vista/surfaces/HtmlSurface.tsx +0 -43
- package/src/vista/surfaces/TableSurface.tsx +0 -100
- package/src/vista/surfaces/UrlSurface.tsx +0 -40
- package/src/vista/surfaces/blob-surfaces.tsx +0 -139
- package/src/vista/surfaces/elicitation-transport.spec.ts +0 -369
- package/src/vista/surfaces/elicitation-transport.ts +0 -367
- package/src/vista/surfaces/file-surface-utils.ts +0 -69
- package/src/vista/surfaces/index.ts +0 -24
- package/src/vista/surfaces/register-surfaces.tsx +0 -139
- package/src/vista/surfaces/text-surfaces.tsx +0 -132
- package/src/vista/use-vista-tabs.ts +0 -196
- package/src/vista/vista-code-renderer-context.ts +0 -16
- package/src/vista/vista-code-renderer.tsx +0 -35
- package/src/vista/vista-file-context.ts +0 -14
- package/src/vista/vista-file-source.tsx +0 -141
- package/src/vista/vista-form-context.ts +0 -20
- package/src/vista/vista-form-host.tsx +0 -48
- package/src/vista/vista-model.ts +0 -137
- package/src/vista/vista-open-context.tsx +0 -27
- package/src/vista/vista-open-state.ts +0 -11
- package/src/vista/vista-payloads.ts +0 -635
- package/src/vista/vista-tabs-manifest.ts +0 -138
- package/src/vista/vista-tools.ts +0 -232
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { CapabilityBus } from './capability-invoke';
|
|
2
|
-
import type { DeeplinkBus } from './deeplink';
|
|
3
|
-
import type { IntentBus } from './intents';
|
|
4
|
-
import type { PaletteBus } from './palette';
|
|
5
|
-
import type { WindowBus } from './windows';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* `SystemBus` — the framework-agnostic OS-orchestration plane every
|
|
9
|
-
* frontend biome reaches through its {@link HostBridge}.
|
|
10
|
-
*
|
|
11
|
-
* **Pure orchestration. Zero policy.** The SystemBus routes intent,
|
|
12
|
-
* navigation, capability invocation, palette, and windowing. It NEVER
|
|
13
|
-
* authorizes, denies, or duplicates policy. Every `capability.invoke`
|
|
14
|
-
* routes through the backend capability-router, which is the single
|
|
15
|
-
* authority on auth / tenancy / policy / audit. The SystemBus's job is to
|
|
16
|
-
* carry orchestration metadata faithfully and relay the router's outcome —
|
|
17
|
-
* a denial surfaces as an observable result (`output === undefined` +
|
|
18
|
-
* `auditId`), never a silent swallow.
|
|
19
|
-
*
|
|
20
|
-
* The contract is framework-agnostic on purpose: it imports no Vite, no
|
|
21
|
-
* Next.js, no React-Router, and no generated transport client. A concrete
|
|
22
|
-
* host (Vite SPA today, Next.js tomorrow) implements each sub-bus against
|
|
23
|
-
* its router, its capability-router client, and its window manager. Biome
|
|
24
|
-
* code is identical across hosts.
|
|
25
|
-
*/
|
|
26
|
-
export interface SystemBus {
|
|
27
|
-
/** Capability invocation → backend router (backend enforces; bus adds none). */
|
|
28
|
-
readonly capability: CapabilityBus;
|
|
29
|
-
/** Cross-biome "Open with… / Send to…" intent registry. */
|
|
30
|
-
readonly intents: IntentBus;
|
|
31
|
-
/** Command palette = capabilities ∪ registered intents. */
|
|
32
|
-
readonly palette: PaletteBus;
|
|
33
|
-
/** Typed `xema://` deeplink parse + resolve. */
|
|
34
|
-
readonly deeplink: DeeplinkBus;
|
|
35
|
-
/** Host window manager (open / focus / close / arrange). */
|
|
36
|
-
readonly windows: WindowBus;
|
|
37
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { type WindowArrangement, WindowOp } from './enums';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Window-management plane of the SystemBus.
|
|
5
|
-
*
|
|
6
|
-
* A "window" is a host-managed surface hosting a biome route or object.
|
|
7
|
-
* The contract is framework-agnostic: the host (desktop shell, tiling WM,
|
|
8
|
-
* tabbed SPA) decides how a window is physically realised, but every host
|
|
9
|
-
* exposes the same closed {@link WindowOp} verb set. The SystemBus carries
|
|
10
|
-
* no authorization here — windowing is pure shell orchestration.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Identifier of an open window, minted by the host on `open`. */
|
|
14
|
-
export type WindowId = string;
|
|
15
|
-
|
|
16
|
-
/** A request to open a new window onto a deeplink target. */
|
|
17
|
-
export interface OpenWindowRequest {
|
|
18
|
-
/** The `xema://` deeplink the new window should display. */
|
|
19
|
-
readonly target: string;
|
|
20
|
-
/** Optional human title; the host falls back to the target's own title. */
|
|
21
|
-
readonly title?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Snapshot of an open window. */
|
|
25
|
-
export interface WindowState {
|
|
26
|
-
readonly id: WindowId;
|
|
27
|
-
readonly target: string;
|
|
28
|
-
readonly title: string;
|
|
29
|
-
readonly focused: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The `windows` surface of the SystemBus. Each method maps to a closed
|
|
34
|
-
* {@link WindowOp}. Operations a host cannot honour reject fail-fast
|
|
35
|
-
* rather than silently no-op, so callers never assume a window exists
|
|
36
|
-
* when it does not.
|
|
37
|
-
*/
|
|
38
|
-
export interface WindowBus {
|
|
39
|
-
/** {@link WindowOp.Open} — open a new window; resolves with its id. */
|
|
40
|
-
open(request: OpenWindowRequest): Promise<WindowId>;
|
|
41
|
-
/** {@link WindowOp.Focus} — bring a window to the foreground. Rejects on unknown id. */
|
|
42
|
-
focus(id: WindowId): Promise<void>;
|
|
43
|
-
/** {@link WindowOp.Close} — close a window. Rejects on unknown id. */
|
|
44
|
-
close(id: WindowId): Promise<void>;
|
|
45
|
-
/** {@link WindowOp.Arrange} — apply a layout to all open windows. */
|
|
46
|
-
arrange(arrangement: WindowArrangement): Promise<void>;
|
|
47
|
-
/** Current snapshot of open windows (for a window-list UI). */
|
|
48
|
-
list(): readonly WindowState[];
|
|
49
|
-
/** Subscribe to window-set changes; returns an unsubscribe fn. */
|
|
50
|
-
subscribe(listener: () => void): () => void;
|
|
51
|
-
}
|
package/src/offline/id.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/** RFC-4122 id, using the platform crypto when available (browser + Node 18+),
|
|
2
|
-
* with a non-cryptographic fallback for exotic runtimes. */
|
|
3
|
-
export function newId(): string {
|
|
4
|
-
const c = (globalThis as { crypto?: Crypto }).crypto;
|
|
5
|
-
if (c && typeof c.randomUUID === 'function') {
|
|
6
|
-
return c.randomUUID();
|
|
7
|
-
}
|
|
8
|
-
// Fallback — good enough for an outbox entry id (not a security token).
|
|
9
|
-
return (
|
|
10
|
-
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
|
11
|
-
const r = (Math.floor(Math.random() * 16) + Date.now()) % 16;
|
|
12
|
-
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
|
13
|
-
return v.toString(16);
|
|
14
|
-
})
|
|
15
|
-
);
|
|
16
|
-
}
|
package/src/offline/index.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// @xemahq/ui-kernel/offline — generic offline field-capture kit
|
|
3
|
-
//
|
|
4
|
-
// A durable, provider-agnostic mutation outbox: queue mutations while
|
|
5
|
-
// disconnected (IndexedDB) and drain them on reconnect with retry, backoff, and
|
|
6
|
-
// idempotency. Pairs with the existing guided-capture kit (`../ui/capture`) and
|
|
7
|
-
// the existing mobile off-canvas shell so a form captured in the field (trade
|
|
8
|
-
// fair, no signal) is never lost. The immediate-online path is unchanged; only
|
|
9
|
-
// a failed/offline send is queued.
|
|
10
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
-
|
|
12
|
-
export { newId } from './id';
|
|
13
|
-
export {
|
|
14
|
-
OutboxEntryStatus,
|
|
15
|
-
type OutboxEntry,
|
|
16
|
-
type OutboxStorage,
|
|
17
|
-
type OutboxExecutor,
|
|
18
|
-
type OutboxExecuteContext,
|
|
19
|
-
type OutboxSnapshot,
|
|
20
|
-
} from './types';
|
|
21
|
-
export {
|
|
22
|
-
InMemoryOutboxStorage,
|
|
23
|
-
IndexedDbOutboxStorage,
|
|
24
|
-
createDefaultOutboxStorage,
|
|
25
|
-
} from './storage';
|
|
26
|
-
export {
|
|
27
|
-
OnlineController,
|
|
28
|
-
isNetworkUnavailableError,
|
|
29
|
-
type OnlineListener,
|
|
30
|
-
} from './online-controller';
|
|
31
|
-
export {
|
|
32
|
-
Outbox,
|
|
33
|
-
type OutboxOptions,
|
|
34
|
-
type OutboxListener,
|
|
35
|
-
type EnqueueOptions,
|
|
36
|
-
} from './outbox';
|
|
37
|
-
export {
|
|
38
|
-
createOfflineMutationFn,
|
|
39
|
-
type OfflineMutationResult,
|
|
40
|
-
type OfflineMutationOptions,
|
|
41
|
-
} from './mutation';
|
|
42
|
-
export {
|
|
43
|
-
OutboxProvider,
|
|
44
|
-
type OutboxProviderProps,
|
|
45
|
-
} from './react';
|
|
46
|
-
export {
|
|
47
|
-
useOutbox,
|
|
48
|
-
useOutboxSnapshot,
|
|
49
|
-
useOnlineStatus,
|
|
50
|
-
} from './react-hooks';
|
|
51
|
-
export {
|
|
52
|
-
OfflineStatusBadge,
|
|
53
|
-
type OfflineStatusBadgeProps,
|
|
54
|
-
} from './offline-status-badge';
|
package/src/offline/mutation.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { newId } from './id';
|
|
2
|
-
import { isNetworkUnavailableError } from './online-controller';
|
|
3
|
-
|
|
4
|
-
import type { Outbox } from './outbox';
|
|
5
|
-
import type { OutboxExecuteContext } from './types';
|
|
6
|
-
|
|
7
|
-
/** Outcome of an offline-aware mutation. */
|
|
8
|
-
export interface OfflineMutationResult<TResult> {
|
|
9
|
-
/** True when the mutation was queued for later delivery (offline / network
|
|
10
|
-
* failure) instead of applied immediately. */
|
|
11
|
-
queued: boolean;
|
|
12
|
-
/** The outbox entry id when queued (for optimistic UI correlation). */
|
|
13
|
-
entryId?: string;
|
|
14
|
-
/** The server result when applied immediately (`queued === false`). */
|
|
15
|
-
result?: TResult;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface OfflineMutationOptions<TPayload, TResult> {
|
|
19
|
-
outbox: Outbox;
|
|
20
|
-
/** Stable executor key for this mutation type (e.g. "app-forge:create-record"). */
|
|
21
|
-
kind: string;
|
|
22
|
-
/** Sends the mutation to the backend. Used BOTH for the immediate online path
|
|
23
|
-
* and for draining the queue. Pass `ctx.idempotencyKey` to the backend so a
|
|
24
|
-
* redelivered mutation is not double-applied. */
|
|
25
|
-
send: (payload: TPayload, ctx: OutboxExecuteContext) => Promise<TResult>;
|
|
26
|
-
/** Optional label for the pending-items UI. */
|
|
27
|
-
label?: (payload: TPayload) => string | undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Build an offline-aware `mutationFn` (drop-in for `@tanstack/react-query`'s
|
|
32
|
-
* `useMutation`). When online it sends immediately; if the send fails because
|
|
33
|
-
* the network is unavailable — or the app is already offline — the payload is
|
|
34
|
-
* durably queued and the promise RESOLVES (queued: true) so the UI can update
|
|
35
|
-
* optimistically. The outbox drains on reconnect. A real server rejection (4xx)
|
|
36
|
-
* still throws so the form shows the validation error.
|
|
37
|
-
*
|
|
38
|
-
* Registers the outbox executor for `kind` once (idempotent).
|
|
39
|
-
*/
|
|
40
|
-
export function createOfflineMutationFn<TPayload, TResult>(
|
|
41
|
-
opts: OfflineMutationOptions<TPayload, TResult>,
|
|
42
|
-
): (payload: TPayload) => Promise<OfflineMutationResult<TResult>> {
|
|
43
|
-
const { outbox, kind, send, label } = opts;
|
|
44
|
-
|
|
45
|
-
if (!outbox.hasExecutor(kind)) {
|
|
46
|
-
outbox.registerExecutor<TPayload>(kind, async (payload, ctx) => {
|
|
47
|
-
await send(payload, ctx);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return async (payload: TPayload): Promise<OfflineMutationResult<TResult>> => {
|
|
52
|
-
const idempotencyKey = newId();
|
|
53
|
-
|
|
54
|
-
const queue = async (): Promise<OfflineMutationResult<TResult>> => {
|
|
55
|
-
const entry = await outbox.enqueue(kind, payload, {
|
|
56
|
-
idempotencyKey,
|
|
57
|
-
...(label ? { label: label(payload) } : {}),
|
|
58
|
-
});
|
|
59
|
-
return { queued: true, entryId: entry.id };
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
if (!outbox.isOnline()) {
|
|
63
|
-
return queue();
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
const result = await send(payload, { idempotencyKey, attempt: 1, entryId: 'direct' });
|
|
67
|
-
return { queued: false, result };
|
|
68
|
-
} catch (err) {
|
|
69
|
-
if (isNetworkUnavailableError(err)) {
|
|
70
|
-
return queue();
|
|
71
|
-
}
|
|
72
|
-
throw err; // genuine server rejection — surface it to the caller
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { useOutboxSnapshot } from './react-hooks';
|
|
2
|
-
|
|
3
|
-
import type { Outbox } from './outbox';
|
|
4
|
-
|
|
5
|
-
export interface OfflineStatusBadgeProps {
|
|
6
|
-
/** Explicit outbox; falls back to the nearest OutboxProvider. */
|
|
7
|
-
outbox?: Outbox;
|
|
8
|
-
className?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const BASE =
|
|
12
|
-
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-body-2 font-medium';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* A compact connectivity + queue indicator for the offline field-capture kit.
|
|
16
|
-
* Renders nothing when online with an empty queue; otherwise surfaces the
|
|
17
|
-
* offline / syncing / dead-letter state so the user knows captures are safe and
|
|
18
|
-
* will sync. Self-contained (no UI-primitive deps) so it can live in the
|
|
19
|
-
* framework-level offline module.
|
|
20
|
-
*/
|
|
21
|
-
export function OfflineStatusBadge({ outbox, className }: OfflineStatusBadgeProps) {
|
|
22
|
-
const { online, pendingCount, failedCount, draining } = useOutboxSnapshot(outbox);
|
|
23
|
-
|
|
24
|
-
if (online && pendingCount === 0 && failedCount === 0) {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const cls = (tone: string): string =>
|
|
29
|
-
`${BASE} ${tone}${className ? ` ${className}` : ''}`;
|
|
30
|
-
|
|
31
|
-
if (failedCount > 0) {
|
|
32
|
-
return (
|
|
33
|
-
<span className={cls('bg-destructive/10 text-destructive')} role="status">
|
|
34
|
-
⚠ {failedCount} failed to sync
|
|
35
|
-
</span>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
if (!online) {
|
|
39
|
-
return (
|
|
40
|
-
<span className={cls('bg-amber-500/10 text-amber-600')} role="status">
|
|
41
|
-
● Offline{pendingCount > 0 ? ` — ${pendingCount} queued` : ''}
|
|
42
|
-
</span>
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
// Online with a non-empty queue → actively draining.
|
|
46
|
-
return (
|
|
47
|
-
<span className={cls('bg-secondary text-secondary-foreground')} role="status">
|
|
48
|
-
{draining ? '⟳' : '●'} Syncing {pendingCount}…
|
|
49
|
-
</span>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
export type OnlineListener = (online: boolean) => void;
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Tracks connectivity via `navigator.onLine` + the window `online`/`offline`
|
|
5
|
-
* events. SSR-safe: when there is no `window`/`navigator` it reports online (so
|
|
6
|
-
* server rendering never queues) and is inert. `navigator.onLine` only knows
|
|
7
|
-
* about the local link, so callers may also mark the controller offline when a
|
|
8
|
-
* request fails as network-unavailable (`reportOffline`) and online again after
|
|
9
|
-
* a successful send (`reportOnline`).
|
|
10
|
-
*/
|
|
11
|
-
export class OnlineController {
|
|
12
|
-
private online: boolean;
|
|
13
|
-
private readonly listeners = new Set<OnlineListener>();
|
|
14
|
-
private started = false;
|
|
15
|
-
|
|
16
|
-
private readonly onOnline = () => this.set(true);
|
|
17
|
-
private readonly onOffline = () => this.set(false);
|
|
18
|
-
|
|
19
|
-
constructor() {
|
|
20
|
-
this.online =
|
|
21
|
-
typeof navigator === 'undefined' || typeof navigator.onLine !== 'boolean'
|
|
22
|
-
? true
|
|
23
|
-
: navigator.onLine;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
isOnline(): boolean {
|
|
27
|
-
return this.online;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
start(): void {
|
|
31
|
-
if (this.started || typeof window === 'undefined') {
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
this.started = true;
|
|
35
|
-
window.addEventListener('online', this.onOnline);
|
|
36
|
-
window.addEventListener('offline', this.onOffline);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
stop(): void {
|
|
40
|
-
if (!this.started || typeof window === 'undefined') {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
this.started = false;
|
|
44
|
-
window.removeEventListener('online', this.onOnline);
|
|
45
|
-
window.removeEventListener('offline', this.onOffline);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Force offline after a network-unavailable request failure. */
|
|
49
|
-
reportOffline(): void {
|
|
50
|
-
this.set(false);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Force online after a successful send (recovers from a stale offline flag). */
|
|
54
|
-
reportOnline(): void {
|
|
55
|
-
this.set(true);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
subscribe(listener: OnlineListener): () => void {
|
|
59
|
-
this.listeners.add(listener);
|
|
60
|
-
return () => this.listeners.delete(listener);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
private set(online: boolean): void {
|
|
64
|
-
if (this.online === online) {
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
this.online = online;
|
|
68
|
-
for (const l of this.listeners) {
|
|
69
|
-
l(online);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Best-effort classifier for "the request failed because the network was
|
|
76
|
-
* unavailable" (offline, DNS, connection refused) vs a real server rejection.
|
|
77
|
-
* A fetch that never reaches the server rejects with a TypeError ("Failed to
|
|
78
|
-
* fetch"); an aborted request has name AbortError. Anything with an HTTP status
|
|
79
|
-
* is a server response, NOT a network failure.
|
|
80
|
-
*/
|
|
81
|
-
export function isNetworkUnavailableError(err: unknown): boolean {
|
|
82
|
-
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
if (!err || typeof err !== 'object') {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
const e = err as { name?: string; message?: string; status?: number; response?: unknown };
|
|
89
|
-
// A response with a status is a server answer, not a transport failure.
|
|
90
|
-
if (typeof e.status === 'number' || e.response !== undefined) {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
if (e.name === 'AbortError') {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
const msg = (e.message ?? '').toLowerCase();
|
|
97
|
-
return (
|
|
98
|
-
e.name === 'TypeError' ||
|
|
99
|
-
msg.includes('failed to fetch') ||
|
|
100
|
-
msg.includes('network') ||
|
|
101
|
-
msg.includes('load failed') ||
|
|
102
|
-
msg.includes('econnrefused') ||
|
|
103
|
-
msg.includes('enotfound')
|
|
104
|
-
);
|
|
105
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { OnlineController } from './online-controller';
|
|
4
|
-
import { Outbox, type OutboxOptions } from './outbox';
|
|
5
|
-
import { InMemoryOutboxStorage } from './storage';
|
|
6
|
-
import { OutboxEntryStatus, type OutboxExecuteContext } from './types';
|
|
7
|
-
|
|
8
|
-
const KIND = 'test:mutation';
|
|
9
|
-
|
|
10
|
-
function makeOutbox(overrides: Partial<OutboxOptions> = {}): {
|
|
11
|
-
outbox: Outbox;
|
|
12
|
-
online: OnlineController;
|
|
13
|
-
} {
|
|
14
|
-
const online = new OnlineController(); // node: navigator undefined ⇒ online
|
|
15
|
-
const outbox = new Outbox({
|
|
16
|
-
storage: new InMemoryOutboxStorage(),
|
|
17
|
-
online,
|
|
18
|
-
// Immediate scheduler: retries are driven synchronously within `drain()`.
|
|
19
|
-
scheduler: (fn) => fn(),
|
|
20
|
-
...overrides,
|
|
21
|
-
});
|
|
22
|
-
return { outbox, online };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('Outbox', () => {
|
|
26
|
-
it('queues while offline and delivers on reconnect', async () => {
|
|
27
|
-
const { outbox, online } = makeOutbox();
|
|
28
|
-
const send = vi.fn(() => Promise.resolve());
|
|
29
|
-
outbox.registerExecutor(KIND, send);
|
|
30
|
-
|
|
31
|
-
online.reportOffline();
|
|
32
|
-
await outbox.enqueue(KIND, { a: 1 });
|
|
33
|
-
expect(send).not.toHaveBeenCalled();
|
|
34
|
-
expect(outbox.getSnapshot().pendingCount).toBe(1);
|
|
35
|
-
|
|
36
|
-
online.reportOnline();
|
|
37
|
-
await outbox.drain();
|
|
38
|
-
expect(send).toHaveBeenCalledTimes(1);
|
|
39
|
-
expect(outbox.getSnapshot().entries).toHaveLength(0);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('delivers immediately when online and removes the entry; idempotency key is stable', async () => {
|
|
43
|
-
const { outbox } = makeOutbox();
|
|
44
|
-
const keys: string[] = [];
|
|
45
|
-
outbox.registerExecutor(KIND, (_p, ctx: OutboxExecuteContext) => {
|
|
46
|
-
keys.push(ctx.idempotencyKey);
|
|
47
|
-
return Promise.resolve();
|
|
48
|
-
});
|
|
49
|
-
await outbox.enqueue(KIND, { a: 1 });
|
|
50
|
-
await outbox.drain();
|
|
51
|
-
expect(keys).toHaveLength(1);
|
|
52
|
-
expect(keys[0]).toMatch(/[0-9a-f-]{36}/);
|
|
53
|
-
expect(outbox.getSnapshot().entries).toHaveLength(0);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('retries a 5xx and eventually succeeds', async () => {
|
|
57
|
-
const { outbox } = makeOutbox();
|
|
58
|
-
let calls = 0;
|
|
59
|
-
outbox.registerExecutor(KIND, () => {
|
|
60
|
-
calls += 1;
|
|
61
|
-
if (calls < 3) {
|
|
62
|
-
return Promise.reject({ status: 503, message: 'unavailable' });
|
|
63
|
-
}
|
|
64
|
-
return Promise.resolve();
|
|
65
|
-
});
|
|
66
|
-
await outbox.enqueue(KIND, { a: 1 });
|
|
67
|
-
await outbox.drain();
|
|
68
|
-
expect(calls).toBe(3);
|
|
69
|
-
expect(outbox.getSnapshot().entries).toHaveLength(0);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('dead-letters a 4xx immediately (no pointless retries)', async () => {
|
|
73
|
-
const { outbox } = makeOutbox();
|
|
74
|
-
const send = vi.fn(() =>
|
|
75
|
-
Promise.reject({ status: 400, message: 'bad request' }),
|
|
76
|
-
);
|
|
77
|
-
outbox.registerExecutor(KIND, send);
|
|
78
|
-
await outbox.enqueue(KIND, { a: 1 });
|
|
79
|
-
await outbox.drain();
|
|
80
|
-
expect(send).toHaveBeenCalledTimes(1);
|
|
81
|
-
const snap = outbox.getSnapshot();
|
|
82
|
-
expect(snap.failedCount).toBe(1);
|
|
83
|
-
expect(snap.entries[0]?.status).toBe(OutboxEntryStatus.Failed);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('dead-letters after exhausting retries on persistent 5xx', async () => {
|
|
87
|
-
const { outbox } = makeOutbox({ maxAttempts: 3 });
|
|
88
|
-
let calls = 0;
|
|
89
|
-
outbox.registerExecutor(KIND, () => {
|
|
90
|
-
calls += 1;
|
|
91
|
-
return Promise.reject({ status: 503 });
|
|
92
|
-
});
|
|
93
|
-
await outbox.enqueue(KIND, { a: 1 });
|
|
94
|
-
await outbox.drain();
|
|
95
|
-
expect(calls).toBe(3);
|
|
96
|
-
expect(outbox.getSnapshot().entries[0]?.status).toBe(
|
|
97
|
-
OutboxEntryStatus.Failed,
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('a network failure never advances the attempt counter or dead-letters', async () => {
|
|
102
|
-
// Captured scheduler so the offline-retry loop does not run forever.
|
|
103
|
-
const timers: Array<() => void> = [];
|
|
104
|
-
const { outbox } = makeOutbox({ scheduler: (fn) => void timers.push(fn) });
|
|
105
|
-
let succeed = false;
|
|
106
|
-
outbox.registerExecutor(KIND, () => {
|
|
107
|
-
if (!succeed) {
|
|
108
|
-
return Promise.reject({
|
|
109
|
-
name: 'TypeError',
|
|
110
|
-
message: 'Failed to fetch',
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
return Promise.resolve();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
await outbox.enqueue(KIND, { a: 1 });
|
|
117
|
-
await outbox.drain();
|
|
118
|
-
const parked = outbox.getSnapshot().entries[0];
|
|
119
|
-
if (!parked) {
|
|
120
|
-
throw new Error(
|
|
121
|
-
'the offline entry was dropped from the outbox instead of parked',
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
expect(parked.status).toBe(OutboxEntryStatus.Pending);
|
|
125
|
-
expect(parked.attempts).toBe(0); // offline is not the payload's fault
|
|
126
|
-
expect(outbox.getSnapshot().failedCount).toBe(0);
|
|
127
|
-
|
|
128
|
-
// Reconnect + retry drains it.
|
|
129
|
-
succeed = true;
|
|
130
|
-
await outbox.drain();
|
|
131
|
-
expect(outbox.getSnapshot().entries).toHaveLength(0);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('delivers queued entries in FIFO order', async () => {
|
|
135
|
-
const { outbox, online } = makeOutbox();
|
|
136
|
-
const order: number[] = [];
|
|
137
|
-
outbox.registerExecutor(KIND, (p: { n: number }) => {
|
|
138
|
-
order.push(p.n);
|
|
139
|
-
return Promise.resolve();
|
|
140
|
-
});
|
|
141
|
-
online.reportOffline();
|
|
142
|
-
await outbox.enqueue(KIND, { n: 1 });
|
|
143
|
-
await outbox.enqueue(KIND, { n: 2 });
|
|
144
|
-
await outbox.enqueue(KIND, { n: 3 });
|
|
145
|
-
online.reportOnline();
|
|
146
|
-
await outbox.drain();
|
|
147
|
-
expect(order).toEqual([1, 2, 3]);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('recovers crash-orphaned inflight entries to pending on start', async () => {
|
|
151
|
-
const storage = new InMemoryOutboxStorage();
|
|
152
|
-
const ts = 1;
|
|
153
|
-
await storage.put({
|
|
154
|
-
id: 'orphan',
|
|
155
|
-
kind: KIND,
|
|
156
|
-
payload: { a: 1 },
|
|
157
|
-
idempotencyKey: 'k',
|
|
158
|
-
status: OutboxEntryStatus.Inflight, // died mid-send
|
|
159
|
-
attempts: 1,
|
|
160
|
-
createdAt: ts,
|
|
161
|
-
updatedAt: ts,
|
|
162
|
-
});
|
|
163
|
-
const online = new OnlineController();
|
|
164
|
-
online.reportOffline(); // keep it queued so we can observe the recovery
|
|
165
|
-
const outbox = new Outbox({ storage, online, scheduler: (fn) => fn() });
|
|
166
|
-
await outbox.start();
|
|
167
|
-
expect(outbox.getSnapshot().entries[0]?.status).toBe(
|
|
168
|
-
OutboxEntryStatus.Pending,
|
|
169
|
-
);
|
|
170
|
-
outbox.stop();
|
|
171
|
-
});
|
|
172
|
-
});
|