@xemahq/ui-kernel 0.6.1 → 0.8.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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/biome-host/frontend-biome.d.ts +6 -3
- package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
- package/dist/lib/biome-host/frontend-biome.js +9 -0
- package/dist/lib/biome-host/frontend-biome.js.map +1 -1
- package/dist/offline/id.d.ts +2 -0
- package/dist/offline/id.d.ts.map +1 -0
- package/dist/offline/id.js +15 -0
- package/dist/offline/id.js.map +1 -0
- package/dist/offline/index.d.ts +9 -0
- package/dist/offline/index.d.ts.map +1 -0
- package/dist/offline/index.js +26 -0
- package/dist/offline/index.js.map +1 -0
- package/dist/offline/mutation.d.ts +15 -0
- package/dist/offline/mutation.d.ts.map +1 -0
- package/dist/offline/mutation.js +37 -0
- package/dist/offline/mutation.js.map +1 -0
- package/dist/offline/offline-status-badge.d.ts +7 -0
- package/dist/offline/offline-status-badge.d.ts.map +1 -0
- package/dist/offline/offline-status-badge.js +21 -0
- package/dist/offline/offline-status-badge.js.map +1 -0
- package/dist/offline/online-controller.d.ts +18 -0
- package/dist/offline/online-controller.d.ts.map +1 -0
- package/dist/offline/online-controller.js +79 -0
- package/dist/offline/online-controller.js.map +1 -0
- package/dist/offline/outbox.d.ts +57 -0
- package/dist/offline/outbox.d.ts.map +1 -0
- package/dist/offline/outbox.js +257 -0
- package/dist/offline/outbox.js.map +1 -0
- package/dist/offline/react.d.ts +13 -0
- package/dist/offline/react.d.ts.map +1 -0
- package/dist/offline/react.js +42 -0
- package/dist/offline/react.js.map +1 -0
- package/dist/offline/storage.d.ts +22 -0
- package/dist/offline/storage.d.ts.map +1 -0
- package/dist/offline/storage.js +90 -0
- package/dist/offline/storage.js.map +1 -0
- package/dist/offline/types.d.ts +38 -0
- package/dist/offline/types.d.ts.map +1 -0
- package/dist/offline/types.js +11 -0
- package/dist/offline/types.js.map +1 -0
- package/dist/ui/branding/app-branding-provider.d.ts +1 -1
- package/dist/ui/branding/app-branding-provider.d.ts.map +1 -1
- package/dist/ui/capture/document-preview.d.ts +12 -0
- package/dist/ui/capture/document-preview.d.ts.map +1 -0
- package/dist/ui/capture/document-preview.js +31 -0
- package/dist/ui/capture/document-preview.js.map +1 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts +5 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -0
- package/dist/ui/capture/field-spec-to-zod.js +79 -0
- package/dist/ui/capture/field-spec-to-zod.js.map +1 -0
- package/dist/ui/capture/field-spec.d.ts +54 -0
- package/dist/ui/capture/field-spec.d.ts.map +1 -0
- package/dist/ui/capture/field-spec.js +62 -0
- package/dist/ui/capture/field-spec.js.map +1 -0
- package/dist/ui/capture/index.d.ts +11 -0
- package/dist/ui/capture/index.d.ts.map +1 -0
- package/dist/ui/capture/index.js +31 -0
- package/dist/ui/capture/index.js.map +1 -0
- package/dist/ui/capture/schema-field-input.d.ts +20 -0
- package/dist/ui/capture/schema-field-input.d.ts.map +1 -0
- package/dist/ui/capture/schema-field-input.js +127 -0
- package/dist/ui/capture/schema-field-input.js.map +1 -0
- package/dist/ui/capture/schema-form.d.ts +18 -0
- package/dist/ui/capture/schema-form.d.ts.map +1 -0
- package/dist/ui/capture/schema-form.js +26 -0
- package/dist/ui/capture/schema-form.js.map +1 -0
- package/dist/ui/capture/use-prefill.d.ts +18 -0
- package/dist/ui/capture/use-prefill.d.ts.map +1 -0
- package/dist/ui/capture/use-prefill.js +94 -0
- package/dist/ui/capture/use-prefill.js.map +1 -0
- package/dist/ui/capture/use-schema-validation.d.ts +11 -0
- package/dist/ui/capture/use-schema-validation.d.ts.map +1 -0
- package/dist/ui/capture/use-schema-validation.js +69 -0
- package/dist/ui/capture/use-schema-validation.js.map +1 -0
- package/dist/ui/capture/use-wizard.d.ts +28 -0
- package/dist/ui/capture/use-wizard.d.ts.map +1 -0
- package/dist/ui/capture/use-wizard.js +63 -0
- package/dist/ui/capture/use-wizard.js.map +1 -0
- package/dist/ui/capture/value-format.d.ts +4 -0
- package/dist/ui/capture/value-format.d.ts.map +1 -0
- package/dist/ui/capture/value-format.js +23 -0
- package/dist/ui/capture/value-format.js.map +1 -0
- package/dist/ui/capture/wizard.d.ts +19 -0
- package/dist/ui/capture/wizard.d.ts.map +1 -0
- package/dist/ui/capture/wizard.js +34 -0
- package/dist/ui/capture/wizard.js.map +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/primitives/badge.d.ts +1 -1
- package/dist/ui/primitives/button.d.ts +1 -1
- package/dist/ui/primitives/sheet.d.ts +1 -1
- package/package.json +4 -3
- package/src/index.ts +9 -0
- package/src/lib/biome-host/define-web-biome.spec.ts +221 -0
- package/src/lib/biome-host/frontend-biome.ts +32 -5
- package/src/offline/id.ts +16 -0
- package/src/offline/index.ts +52 -0
- package/src/offline/mutation.ts +75 -0
- package/src/offline/offline-status-badge.tsx +51 -0
- package/src/offline/online-controller.ts +105 -0
- package/src/offline/outbox.test.ts +156 -0
- package/src/offline/outbox.ts +344 -0
- package/src/offline/react.tsx +71 -0
- package/src/offline/storage.ts +107 -0
- package/src/offline/types.ts +77 -0
- package/src/ui/branding/app-branding-provider.tsx +1 -1
- package/src/ui/capture/document-preview.tsx +101 -0
- package/src/ui/capture/field-spec-to-zod.test.ts +81 -0
- package/src/ui/capture/field-spec-to-zod.ts +104 -0
- package/src/ui/capture/field-spec.test.ts +49 -0
- package/src/ui/capture/field-spec.ts +181 -0
- package/src/ui/capture/index.ts +43 -0
- package/src/ui/capture/schema-field-input.tsx +314 -0
- package/src/ui/capture/schema-form.tsx +108 -0
- package/src/ui/capture/use-prefill.ts +150 -0
- package/src/ui/capture/use-schema-validation.ts +113 -0
- package/src/ui/capture/use-wizard.ts +134 -0
- package/src/ui/capture/value-format.ts +22 -0
- package/src/ui/capture/wizard.tsx +100 -0
- package/src/ui/index.ts +3 -0
- package/src/lib/biome-host/define-web-biome.test.js +0 -93
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineWebBiome` — define-time scope/access locking, the fail-closed
|
|
3
|
+
* defaults, and closed-set completeness.
|
|
4
|
+
*
|
|
5
|
+
* This suite replaces an earlier `define-web-biome.test.js` written for
|
|
6
|
+
* Node's test runner against `dist/`. That file ran NOWHERE: the package's
|
|
7
|
+
* `test` script is a notice (the kernel carries no test runner by design) and
|
|
8
|
+
* the host-web vitest `include` is `*.{test,spec}.{ts,tsx}` — so a `.js` file
|
|
9
|
+
* was invisible to both. A security test that never executes reads as
|
|
10
|
+
* coverage while proving nothing, so it is gone; this vitest spec is synced
|
|
11
|
+
* into the host mirror by the frontend bridge and runs there with everything
|
|
12
|
+
* else.
|
|
13
|
+
*
|
|
14
|
+
* The invariant under test is the security one: a route is public ONLY when
|
|
15
|
+
* its author said so twice, explicitly. Nothing defaulted, inferred, or
|
|
16
|
+
* coerced can produce a public route.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
import { defineWebBiome } from './define-web-biome';
|
|
21
|
+
import {
|
|
22
|
+
PAGE_CHROME_VALUES,
|
|
23
|
+
ROUTE_ACCESS_VALUES,
|
|
24
|
+
ROUTE_SCOPE_VALUES,
|
|
25
|
+
type PageChrome,
|
|
26
|
+
type RouteAccess,
|
|
27
|
+
type RouteScope,
|
|
28
|
+
} from './frontend-biome';
|
|
29
|
+
|
|
30
|
+
import type { HostBridge } from './host-bridge';
|
|
31
|
+
|
|
32
|
+
const load = () => Promise.resolve({ default: () => null });
|
|
33
|
+
const bridge = {} as HostBridge;
|
|
34
|
+
|
|
35
|
+
describe('defineWebBiome — public/chromeless scope', () => {
|
|
36
|
+
it('rejects a public page whose access defaults to member', () => {
|
|
37
|
+
expect(() =>
|
|
38
|
+
defineWebBiome({
|
|
39
|
+
id: 'test-biome',
|
|
40
|
+
displayName: 'Test',
|
|
41
|
+
pages: [{ slug: 'ext/:id', label: 'Ext', scope: 'public', load }],
|
|
42
|
+
}),
|
|
43
|
+
).toThrow(/a 'public' scope page MUST declare access: 'public'/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects a public page with an explicit non-public access tier', () => {
|
|
47
|
+
expect(() =>
|
|
48
|
+
defineWebBiome({
|
|
49
|
+
id: 'test-biome',
|
|
50
|
+
displayName: 'Test',
|
|
51
|
+
pages: [
|
|
52
|
+
{
|
|
53
|
+
slug: 'ext/:id',
|
|
54
|
+
label: 'Ext',
|
|
55
|
+
scope: 'public',
|
|
56
|
+
access: 'org-admin',
|
|
57
|
+
load,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
).toThrow(/a 'public' scope page MUST declare access: 'public'/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects a non-public page that declares access public', () => {
|
|
65
|
+
expect(() =>
|
|
66
|
+
defineWebBiome({
|
|
67
|
+
id: 'test-biome',
|
|
68
|
+
displayName: 'Test',
|
|
69
|
+
pages: [{ slug: 'system/x', label: 'X', access: 'public', load }],
|
|
70
|
+
}),
|
|
71
|
+
).toThrow(/access: 'public' is only valid on a 'public' scope page/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('accepts a public + access:public page and emits no nav item', () => {
|
|
75
|
+
const biome = defineWebBiome({
|
|
76
|
+
id: 'test-biome',
|
|
77
|
+
displayName: 'Test',
|
|
78
|
+
pages: [
|
|
79
|
+
{
|
|
80
|
+
slug: 'ext/:id',
|
|
81
|
+
label: 'Ext',
|
|
82
|
+
scope: 'public',
|
|
83
|
+
access: 'public',
|
|
84
|
+
load,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
})(bridge);
|
|
88
|
+
|
|
89
|
+
expect(biome.routes).toHaveLength(1);
|
|
90
|
+
const route = biome.routes?.[0];
|
|
91
|
+
expect(route?.path).toBe('ext/:id');
|
|
92
|
+
expect(route?.access).toBe('public');
|
|
93
|
+
expect(route?.projectScoped).toBe(false);
|
|
94
|
+
// A public route is un-authenticated; it never belongs in the authed rail.
|
|
95
|
+
expect(biome.navItems).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('chrome defaults to host and none passes through', () => {
|
|
99
|
+
const biome = defineWebBiome({
|
|
100
|
+
id: 'test-biome',
|
|
101
|
+
displayName: 'Test',
|
|
102
|
+
pages: [
|
|
103
|
+
{ slug: 'system/a', label: 'A', load },
|
|
104
|
+
{ slug: 'system/b', label: 'B', chrome: 'none', load },
|
|
105
|
+
],
|
|
106
|
+
})(bridge);
|
|
107
|
+
|
|
108
|
+
expect(biome.routes?.find((r) => r.path === 'system/a')?.chrome).toBe(
|
|
109
|
+
'host',
|
|
110
|
+
);
|
|
111
|
+
expect(biome.routes?.find((r) => r.path === 'system/b')?.chrome).toBe(
|
|
112
|
+
'none',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('defineWebBiome — public is opt-in, never a default', () => {
|
|
118
|
+
/**
|
|
119
|
+
* THE fail-closed invariant. An ordinarily-declared page (no `scope`, no
|
|
120
|
+
* `access`) must emit a route that carries NO `access` marker at all — the
|
|
121
|
+
* host then applies its own `member` default. If the kernel ever started
|
|
122
|
+
* defaulting `access` (or `scope`) to `'public'`, every existing biome route
|
|
123
|
+
* in the fleet would silently become reachable without a session.
|
|
124
|
+
*
|
|
125
|
+
* Mutation-proof (recorded 2026-07-22): flipping the emit in
|
|
126
|
+
* `defineWebBiome` to `access: page.access ?? 'public'` turns this test RED
|
|
127
|
+
* ("expected 'public' to be undefined"), and flipping the validator default
|
|
128
|
+
* `page.access ?? 'member'` to `?? 'public'` makes the ordinary page throw
|
|
129
|
+
* at define time — RED in this test and in the two below. There is no
|
|
130
|
+
* mutation of a default that makes a route public and keeps the suite green.
|
|
131
|
+
*/
|
|
132
|
+
it('an ordinary page emits NO access marker (host defaults to member)', () => {
|
|
133
|
+
const biome = defineWebBiome({
|
|
134
|
+
id: 'test-biome',
|
|
135
|
+
displayName: 'Test',
|
|
136
|
+
pages: [{ slug: 'system/a', label: 'A', load }],
|
|
137
|
+
})(bridge);
|
|
138
|
+
|
|
139
|
+
const route = biome.routes?.[0];
|
|
140
|
+
expect(route?.access).toBeUndefined();
|
|
141
|
+
expect(route?.projectScoped).toBe(false);
|
|
142
|
+
// …and it DOES contribute a nav item, unlike a public route.
|
|
143
|
+
expect(biome.navItems).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('an authenticated page can be chromeless without becoming public', () => {
|
|
147
|
+
// Chrome and access are orthogonal axes: the embedded-app case.
|
|
148
|
+
const biome = defineWebBiome({
|
|
149
|
+
id: 'test-biome',
|
|
150
|
+
displayName: 'Test',
|
|
151
|
+
pages: [
|
|
152
|
+
{
|
|
153
|
+
slug: 'apps/:appId/embed',
|
|
154
|
+
label: 'Embedded app',
|
|
155
|
+
chrome: 'none',
|
|
156
|
+
navHidden: true,
|
|
157
|
+
load,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
})(bridge);
|
|
161
|
+
|
|
162
|
+
const route = biome.routes?.[0];
|
|
163
|
+
expect(route?.chrome).toBe('none');
|
|
164
|
+
expect(route?.access).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('every non-public access tier is rejected on a public page', () => {
|
|
168
|
+
for (const access of ROUTE_ACCESS_VALUES.filter((a) => a !== 'public')) {
|
|
169
|
+
expect(() =>
|
|
170
|
+
defineWebBiome({
|
|
171
|
+
id: 'test-biome',
|
|
172
|
+
displayName: 'Test',
|
|
173
|
+
pages: [{ slug: 'ext', label: 'Ext', scope: 'public', access, load }],
|
|
174
|
+
}),
|
|
175
|
+
).toThrow(/MUST declare access: 'public'/);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('every non-public scope rejects access public', () => {
|
|
180
|
+
for (const scope of ROUTE_SCOPE_VALUES.filter((s) => s !== 'public')) {
|
|
181
|
+
expect(() =>
|
|
182
|
+
defineWebBiome({
|
|
183
|
+
id: 'test-biome',
|
|
184
|
+
displayName: 'Test',
|
|
185
|
+
pages: [{ slug: 'x', label: 'X', scope, access: 'public', load }],
|
|
186
|
+
}),
|
|
187
|
+
).toThrow(/only valid on a 'public' scope page/);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('closed-set completeness', () => {
|
|
193
|
+
/**
|
|
194
|
+
* The runtime tuples ARE the types (`RouteAccess = typeof
|
|
195
|
+
* ROUTE_ACCESS_VALUES[number]`), so a new member cannot be added to the type
|
|
196
|
+
* without appearing here — which is what makes the exhaustive iteration in
|
|
197
|
+
* this file, and the host resolver's `default`-less switch, complete by
|
|
198
|
+
* construction. These assertions are the compile-time proof made executable:
|
|
199
|
+
* the `satisfies` widening below fails to compile if a tuple ever drifts
|
|
200
|
+
* from its type.
|
|
201
|
+
*/
|
|
202
|
+
it('the access tuple enumerates exactly the RouteAccess union', () => {
|
|
203
|
+
const all: readonly RouteAccess[] =
|
|
204
|
+
ROUTE_ACCESS_VALUES satisfies readonly RouteAccess[];
|
|
205
|
+
expect([...all].sort()).toEqual(
|
|
206
|
+
['member', 'org-admin', 'platform-admin', 'public'].sort(),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('the scope tuple enumerates exactly the RouteScope union', () => {
|
|
211
|
+
const all: readonly RouteScope[] =
|
|
212
|
+
ROUTE_SCOPE_VALUES satisfies readonly RouteScope[];
|
|
213
|
+
expect([...all].sort()).toEqual(['org', 'project', 'public'].sort());
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('the chrome tuple enumerates exactly the PageChrome union', () => {
|
|
217
|
+
const all: readonly PageChrome[] =
|
|
218
|
+
PAGE_CHROME_VALUES satisfies readonly PageChrome[];
|
|
219
|
+
expect([...all].sort()).toEqual(['host', 'none'].sort());
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -50,10 +50,25 @@ export interface NavItemContribution {
|
|
|
50
50
|
* authenticated route group. It is value-locked to `RouteScope` `'public'`
|
|
51
51
|
* (`defineWebBiome` rejects any other pairing at define time), so a route is
|
|
52
52
|
* public in BOTH senses or in neither. Closed set — a new member is a
|
|
53
|
-
* one-line change
|
|
54
|
-
* `hasRouteAccess` switch
|
|
53
|
+
* one-line change to {@link ROUTE_ACCESS_VALUES} and one `case` in the host
|
|
54
|
+
* resolver's exhaustive `hasRouteAccess` switch (the switch has no `default`,
|
|
55
|
+
* so an unhandled member is a compile error, never a silent allow).
|
|
56
|
+
*
|
|
57
|
+
* The tuple below is the SINGLE source of truth: the type is derived from it,
|
|
58
|
+
* so a member cannot exist in the type without also being enumerable at
|
|
59
|
+
* runtime — which is what lets a host prove exhaustiveness in a test rather
|
|
60
|
+
* than by inspection. Order is least → most privileged; `public` is the
|
|
61
|
+
* no-auth marker and is deliberately last.
|
|
55
62
|
*/
|
|
56
|
-
export
|
|
63
|
+
export const ROUTE_ACCESS_VALUES = [
|
|
64
|
+
'member',
|
|
65
|
+
'org-admin',
|
|
66
|
+
'platform-admin',
|
|
67
|
+
'public',
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
/** Access tier required to render a route. See {@link ROUTE_ACCESS_VALUES}. */
|
|
71
|
+
export type RouteAccess = (typeof ROUTE_ACCESS_VALUES)[number];
|
|
57
72
|
|
|
58
73
|
/**
|
|
59
74
|
* Where a route mounts in the host's URL/auth topology. Closed set — adding a
|
|
@@ -65,8 +80,13 @@ export type RouteAccess = 'member' | 'org-admin' | 'platform-admin' | 'public';
|
|
|
65
80
|
* authenticated group.
|
|
66
81
|
* - `public` — mounts OUTSIDE the authenticated group; no Keycloak session,
|
|
67
82
|
* no login redirect, no org switcher. Locked to `RouteAccess` `'public'`.
|
|
83
|
+
*
|
|
84
|
+
* Single source of truth for the closed set; the type is derived from it.
|
|
68
85
|
*/
|
|
69
|
-
export
|
|
86
|
+
export const ROUTE_SCOPE_VALUES = ['org', 'project', 'public'] as const;
|
|
87
|
+
|
|
88
|
+
/** Where a route mounts. See {@link ROUTE_SCOPE_VALUES}. */
|
|
89
|
+
export type RouteScope = (typeof ROUTE_SCOPE_VALUES)[number];
|
|
70
90
|
|
|
71
91
|
/**
|
|
72
92
|
* Whether a page renders wrapped in the host application shell (app rail,
|
|
@@ -78,8 +98,15 @@ export type RouteScope = 'org' | 'project' | 'public';
|
|
|
78
98
|
* switcher (a full-bleed surface). `public` routes are structurally
|
|
79
99
|
* chromeless (their route group ships no shell); `none` also lets an
|
|
80
100
|
* authenticated route opt out of the shell.
|
|
101
|
+
*
|
|
102
|
+
* Single source of truth for the closed set; the type is derived from it.
|
|
103
|
+
* Chrome is ORTHOGONAL to access: `chrome: 'none'` on an authenticated route
|
|
104
|
+
* is the embedded-app case and grants no privilege change whatsoever.
|
|
81
105
|
*/
|
|
82
|
-
export
|
|
106
|
+
export const PAGE_CHROME_VALUES = ['host', 'none'] as const;
|
|
107
|
+
|
|
108
|
+
/** Host-shell mode a page renders in. See {@link PAGE_CHROME_VALUES}. */
|
|
109
|
+
export type PageChrome = (typeof PAGE_CHROME_VALUES)[number];
|
|
83
110
|
|
|
84
111
|
export interface RouteContribution {
|
|
85
112
|
/** React-router path. May include params. */
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
useOutbox,
|
|
45
|
+
useOutboxSnapshot,
|
|
46
|
+
useOnlineStatus,
|
|
47
|
+
type OutboxProviderProps,
|
|
48
|
+
} from './react';
|
|
49
|
+
export {
|
|
50
|
+
OfflineStatusBadge,
|
|
51
|
+
type OfflineStatusBadgeProps,
|
|
52
|
+
} from './offline-status-badge';
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useOutboxSnapshot } from './react';
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
}
|