@effindomv2/fui-as 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE.md +7 -0
  2. package/browser/src/common-harness/host-imports.ts +430 -0
  3. package/browser/src/common-harness/interop.ts +39 -0
  4. package/browser/src/common-harness/managed-harness-bitmap-host.ts +92 -0
  5. package/browser/src/common-harness/managed-harness-fetch-host.ts +201 -0
  6. package/browser/src/common-harness/managed-harness-file-host.ts +1101 -0
  7. package/browser/src/common-harness/managed-harness-file-payloads.ts +143 -0
  8. package/browser/src/common-harness/managed-harness-file-types.ts +106 -0
  9. package/browser/src/common-harness/managed-harness-session.ts +15 -0
  10. package/browser/src/common-harness/managed-harness.ts +1323 -0
  11. package/browser/src/common-harness/managed-history.ts +168 -0
  12. package/browser/src/common-harness/persisted-restore-policy.ts +50 -0
  13. package/browser/src/common-harness/persisted-ui-state-controller.ts +309 -0
  14. package/browser/src/common-harness/text-session-bridge.ts +452 -0
  15. package/browser/src/common-harness/types.ts +205 -0
  16. package/browser/src/common-harness/ui-chrome.ts +191 -0
  17. package/browser/src/common-harness/ui-imports.ts +529 -0
  18. package/browser/src/common-harness/wasm-module-cache.ts +47 -0
  19. package/browser/src/common-harness.ts +27 -0
  20. package/browser/src/file-processing-worker.ts +89 -0
  21. package/browser/src/host-events.ts +97 -0
  22. package/browser/src/host-services.ts +203 -0
  23. package/browser/src/index.ts +62 -0
  24. package/browser/src/persisted-ui-state.ts +206 -0
  25. package/browser/src/routed-harness.ts +198 -0
  26. package/browser/src/worker-bootstrap.ts +483 -0
  27. package/browser/src/worker-manager.ts +230 -0
  28. package/browser/src/worker-types.ts +50 -0
  29. package/package.json +89 -0
  30. package/scripts/build-demo-as.sh +91 -0
  31. package/scripts/build.sh +325 -0
  32. package/scripts/generate-host-events.ts +175 -0
  33. package/scripts/generate-host-services.ts +157 -0
  34. package/src/Fui.ts +205 -0
  35. package/src/FuiExports.ts +55 -0
  36. package/src/FuiPrimitives.ts +15 -0
  37. package/src/FuiWorker.ts +3 -0
  38. package/src/FuiWorkerExports.ts +6 -0
  39. package/src/bindings/ui.ts +531 -0
  40. package/src/color.ts +86 -0
  41. package/src/controls/AntiSelectionArea.ts +23 -0
  42. package/src/controls/Button.ts +750 -0
  43. package/src/controls/Checkbox.ts +181 -0
  44. package/src/controls/ContextMenu.ts +885 -0
  45. package/src/controls/ControlTemplateSet.ts +37 -0
  46. package/src/controls/Dialog.ts +355 -0
  47. package/src/controls/Dropdown.ts +856 -0
  48. package/src/controls/Form.ts +110 -0
  49. package/src/controls/NavLink.ts +211 -0
  50. package/src/controls/Popup.ts +129 -0
  51. package/src/controls/ProgressBar.ts +180 -0
  52. package/src/controls/RadioButton.ts +135 -0
  53. package/src/controls/RadioGroup.ts +244 -0
  54. package/src/controls/SelectionArea.ts +75 -0
  55. package/src/controls/Slider.ts +471 -0
  56. package/src/controls/Switch.ts +132 -0
  57. package/src/controls/TextArea.ts +20 -0
  58. package/src/controls/TextInput.ts +7 -0
  59. package/src/controls/index.ts +18 -0
  60. package/src/controls/internal/ButtonPresenter.ts +95 -0
  61. package/src/controls/internal/CheckboxIndicatorPresenter.ts +93 -0
  62. package/src/controls/internal/DropdownChevronPresenter.ts +67 -0
  63. package/src/controls/internal/DropdownFieldPresenter.ts +110 -0
  64. package/src/controls/internal/DropdownOptionRowPresenter.ts +82 -0
  65. package/src/controls/internal/PopupPresenter.ts +198 -0
  66. package/src/controls/internal/PressableIndicatorPresenter.ts +32 -0
  67. package/src/controls/internal/PressableLabeledControl.ts +221 -0
  68. package/src/controls/internal/RadioIndicatorPresenter.ts +73 -0
  69. package/src/controls/internal/SliderPresenter.ts +157 -0
  70. package/src/controls/internal/SwitchIndicatorPresenter.ts +72 -0
  71. package/src/controls/internal/TextInputCore.ts +695 -0
  72. package/src/controls/internal/TextInputPresenter.ts +72 -0
  73. package/src/controls/templating.ts +54 -0
  74. package/src/core/Action.ts +94 -0
  75. package/src/core/Actions.ts +37 -0
  76. package/src/core/Animation.ts +412 -0
  77. package/src/core/Application.ts +328 -0
  78. package/src/core/Assets.ts +264 -0
  79. package/src/core/AttachedProperties.ts +32 -0
  80. package/src/core/Bitmap.ts +70 -0
  81. package/src/core/BoundCallback.ts +104 -0
  82. package/src/core/Callbacks.ts +17 -0
  83. package/src/core/ContextMenuManager.ts +466 -0
  84. package/src/core/DebugApi.ts +30 -0
  85. package/src/core/Disposable.ts +10 -0
  86. package/src/core/DragDropManager.ts +179 -0
  87. package/src/core/DragGesture.ts +184 -0
  88. package/src/core/DynamicAssetIds.ts +24 -0
  89. package/src/core/Errors.ts +48 -0
  90. package/src/core/EventRouter.ts +408 -0
  91. package/src/core/ExternalDropManager.ts +122 -0
  92. package/src/core/Fetch.ts +264 -0
  93. package/src/core/FetchFfi.ts +15 -0
  94. package/src/core/File.ts +1002 -0
  95. package/src/core/FocusAdornerManager.ts +263 -0
  96. package/src/core/FocusVisibility.ts +36 -0
  97. package/src/core/FrameScheduler.ts +28 -0
  98. package/src/core/KeyboardScroll.ts +161 -0
  99. package/src/core/KeyboardScrollTracker.ts +386 -0
  100. package/src/core/Logger.ts +80 -0
  101. package/src/core/Navigation.ts +13 -0
  102. package/src/core/Node.ts +1708 -0
  103. package/src/core/PersistedState.ts +102 -0
  104. package/src/core/PersistedUiState.ts +142 -0
  105. package/src/core/Platform.ts +219 -0
  106. package/src/core/Signal.ts +89 -0
  107. package/src/core/Theme.ts +365 -0
  108. package/src/core/Timers.ts +129 -0
  109. package/src/core/ToolTip.ts +122 -0
  110. package/src/core/ToolTipManager.ts +459 -0
  111. package/src/core/Transitions.ts +34 -0
  112. package/src/core/Typography.ts +204 -0
  113. package/src/core/Worker.ts +196 -0
  114. package/src/core/bind.ts +37 -0
  115. package/src/core/event_exports.ts +596 -0
  116. package/src/core/ffi.ts +728 -0
  117. package/src/host-services/runtime.ts +25 -0
  118. package/src/nodes/FlexBox.ts +789 -0
  119. package/src/nodes/GradientStop.ts +9 -0
  120. package/src/nodes/Grid.ts +183 -0
  121. package/src/nodes/Image.ts +189 -0
  122. package/src/nodes/Portal.ts +14 -0
  123. package/src/nodes/RichText.ts +312 -0
  124. package/src/nodes/ScrollBar.ts +570 -0
  125. package/src/nodes/ScrollBox.ts +415 -0
  126. package/src/nodes/ScrollState.ts +10 -0
  127. package/src/nodes/ScrollView.ts +511 -0
  128. package/src/nodes/Svg.ts +142 -0
  129. package/src/nodes/Text.ts +145 -0
  130. package/src/nodes/TextCore.ts +558 -0
  131. package/src/nodes/VirtualList.ts +431 -0
  132. package/src/nodes/helpers.ts +25 -0
  133. package/src/nodes/index.ts +14 -0
  134. package/src/tsconfig.json +7 -0
  135. package/src/worker/Worker.ts +169 -0
  136. package/src/worker/WorkerJob.ts +65 -0
  137. package/src/worker/ffi.ts +23 -0
@@ -0,0 +1,97 @@
1
+ import type { HostServiceTypeName } from "./host-services";
2
+
3
+ type HostEventTypeValue<T extends HostServiceTypeName> =
4
+ T extends "string" ? string :
5
+ T extends "bool" ? boolean :
6
+ T extends "void" ? void :
7
+ number;
8
+
9
+ type HostEventArgsValues<TArgs extends readonly HostServiceTypeName[]> = {
10
+ readonly [K in keyof TArgs]: HostEventTypeValue<TArgs[K] & HostServiceTypeName>;
11
+ };
12
+
13
+ export interface HostEventMethodDefinition<
14
+ TArgs extends readonly HostServiceTypeName[] = readonly HostServiceTypeName[],
15
+ > {
16
+ readonly args: TArgs;
17
+ readonly subscribe: (emit: (...args: HostEventArgsValues<TArgs>) => void) => (() => void) | void;
18
+ }
19
+
20
+ export type HostEventsDefinition = Record<string, Record<string, HostEventMethodDefinition>>;
21
+
22
+ export interface NormalizedHostEventMethod {
23
+ readonly serviceName: string;
24
+ readonly methodName: string;
25
+ readonly eventName: string;
26
+ readonly exportName: string;
27
+ readonly args: readonly HostServiceTypeName[];
28
+ readonly subscribe: (emit: (...args: readonly unknown[]) => void) => (() => void) | void;
29
+ }
30
+
31
+ const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ export function hostEvent<
34
+ TArgs extends readonly HostServiceTypeName[],
35
+ >(definition: HostEventMethodDefinition<TArgs>): HostEventMethodDefinition<TArgs> {
36
+ return definition;
37
+ }
38
+
39
+ export function defineHostEvents<TEvents extends HostEventsDefinition>(events: TEvents): TEvents {
40
+ return events;
41
+ }
42
+
43
+ function assertIdentifier(value: string, context: string): void {
44
+ if (!IDENTIFIER_RE.test(value)) {
45
+ throw new Error(`${context} "${value}" must be a valid identifier.`);
46
+ }
47
+ }
48
+
49
+ function capitalize(value: string): string {
50
+ return value.length == 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
51
+ }
52
+
53
+ function buildEventName(serviceName: string, methodName: string): string {
54
+ return `${serviceName}${capitalize(methodName)}`;
55
+ }
56
+
57
+ function buildExportName(eventName: string): string {
58
+ return `__fui_host_event_${eventName}`;
59
+ }
60
+
61
+ function validateEventType(type: string, context: string): asserts type is HostServiceTypeName {
62
+ if (type === "string" || type === "bool" || type === "i32" || type === "f64") {
63
+ return;
64
+ }
65
+ throw new Error(`${context} uses unsupported host-event type "${type}".`);
66
+ }
67
+
68
+ export function listHostEventMethods(events: HostEventsDefinition | undefined): ReadonlyArray<NormalizedHostEventMethod> {
69
+ if (events === undefined) {
70
+ return [];
71
+ }
72
+ const methods: Array<NormalizedHostEventMethod> = [];
73
+ const seenEvents = new Set<string>();
74
+ for (const [serviceName, serviceMethods] of Object.entries(events)) {
75
+ assertIdentifier(serviceName, "Host event service");
76
+ for (const [methodName, definition] of Object.entries(serviceMethods)) {
77
+ assertIdentifier(methodName, `Host event ${serviceName} method`);
78
+ const eventName = buildEventName(serviceName, methodName);
79
+ if (seenEvents.has(eventName)) {
80
+ throw new Error(`Duplicate host-event name "${eventName}".`);
81
+ }
82
+ seenEvents.add(eventName);
83
+ const args = [...definition.args];
84
+ args.forEach((type, index) => validateEventType(type, `Host event ${serviceName}.${methodName} arg ${String(index)}`));
85
+ methods.push({
86
+ serviceName,
87
+ methodName,
88
+ eventName,
89
+ exportName: buildExportName(eventName),
90
+ args,
91
+ subscribe: definition.subscribe as (emit: (...args: readonly unknown[]) => void) => (() => void) | void,
92
+ });
93
+ }
94
+ }
95
+ methods.sort((left, right) => left.eventName.localeCompare(right.eventName));
96
+ return methods;
97
+ }
@@ -0,0 +1,203 @@
1
+ export type HostServiceTypeName = "string" | "bool" | "i32" | "f64" | "void";
2
+
3
+ type HostServiceTypeValue<T extends HostServiceTypeName> =
4
+ T extends "string" ? string :
5
+ T extends "bool" ? boolean :
6
+ T extends "void" ? void :
7
+ number;
8
+
9
+ type HostServiceArgsValues<TArgs extends readonly HostServiceTypeName[]> = {
10
+ readonly [K in keyof TArgs]: HostServiceTypeValue<TArgs[K] & HostServiceTypeName>;
11
+ };
12
+
13
+ export interface HostServiceMethodDefinition<
14
+ TArgs extends readonly HostServiceTypeName[] = readonly HostServiceTypeName[],
15
+ TResult extends HostServiceTypeName = HostServiceTypeName,
16
+ > {
17
+ readonly args: TArgs;
18
+ readonly returns: TResult;
19
+ readonly implementation: (...args: HostServiceArgsValues<TArgs>) => HostServiceTypeValue<TResult>;
20
+ }
21
+
22
+ export type HostServicesDefinition = Record<string, Record<string, HostServiceMethodDefinition>>;
23
+
24
+ export interface NormalizedHostServiceMethod {
25
+ readonly serviceName: string;
26
+ readonly methodName: string;
27
+ readonly importName: string;
28
+ readonly args: readonly HostServiceTypeName[];
29
+ readonly returns: HostServiceTypeName;
30
+ readonly implementation: (...args: readonly unknown[]) => unknown;
31
+ }
32
+
33
+ export interface HostServiceImportIo {
34
+ readString(ptr: number, len: number): string;
35
+ writeString(ptr: number, capacity: number, text: string, context: string): number;
36
+ }
37
+
38
+ const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
39
+
40
+ export function hostService<
41
+ TArgs extends readonly HostServiceTypeName[],
42
+ TResult extends HostServiceTypeName,
43
+ >(definition: HostServiceMethodDefinition<TArgs, TResult>): HostServiceMethodDefinition<TArgs, TResult> {
44
+ return definition;
45
+ }
46
+
47
+ export function defineHostServices<TServices extends HostServicesDefinition>(services: TServices): TServices {
48
+ return services;
49
+ }
50
+
51
+ function assertIdentifier(value: string, context: string): void {
52
+ if (!IDENTIFIER_RE.test(value)) {
53
+ throw new Error(`${context} "${value}" must be a valid identifier.`);
54
+ }
55
+ }
56
+
57
+ function capitalize(value: string): string {
58
+ return value.length == 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
59
+ }
60
+
61
+ function buildImportName(serviceName: string, methodName: string): string {
62
+ return `${serviceName}${capitalize(methodName)}`;
63
+ }
64
+
65
+ function validateServiceType(type: string, context: string): asserts type is HostServiceTypeName {
66
+ if (type === "string" || type === "bool" || type === "i32" || type === "f64" || type === "void") {
67
+ return;
68
+ }
69
+ throw new Error(`${context} uses unsupported host-service type "${type}".`);
70
+ }
71
+
72
+ export function listHostServiceMethods(services: HostServicesDefinition | undefined): ReadonlyArray<NormalizedHostServiceMethod> {
73
+ if (services === undefined) {
74
+ return [];
75
+ }
76
+ const methods: Array<NormalizedHostServiceMethod> = [];
77
+ const seenImports = new Set<string>();
78
+ for (const [serviceName, serviceMethods] of Object.entries(services)) {
79
+ assertIdentifier(serviceName, "Host service");
80
+ for (const [methodName, definition] of Object.entries(serviceMethods)) {
81
+ assertIdentifier(methodName, `Host service ${serviceName} method`);
82
+ const importName = buildImportName(serviceName, methodName);
83
+ if (seenImports.has(importName)) {
84
+ throw new Error(`Duplicate host-service import name "${importName}".`);
85
+ }
86
+ seenImports.add(importName);
87
+ const args = [...definition.args];
88
+ args.forEach((type, index) => validateServiceType(type, `Host service ${serviceName}.${methodName} arg ${String(index)}`));
89
+ validateServiceType(definition.returns, `Host service ${serviceName}.${methodName} return`);
90
+ methods.push({
91
+ serviceName,
92
+ methodName,
93
+ importName,
94
+ args,
95
+ returns: definition.returns,
96
+ implementation: definition.implementation as (...args: readonly unknown[]) => unknown,
97
+ });
98
+ }
99
+ }
100
+ methods.sort((left, right) => left.importName.localeCompare(right.importName));
101
+ return methods;
102
+ }
103
+
104
+ export function getHostServiceImportNames(services: HostServicesDefinition | undefined): ReadonlySet<string> {
105
+ return new Set(listHostServiceMethods(services).map((method) => method.importName));
106
+ }
107
+
108
+ function expectNumber(value: unknown, context: string): number {
109
+ if (typeof value !== "number" || Number.isNaN(value)) {
110
+ throw new Error(`${context} must be a number.`);
111
+ }
112
+ return value;
113
+ }
114
+
115
+ function expectBoolean(value: unknown, context: string): boolean {
116
+ if (typeof value !== "boolean") {
117
+ throw new Error(`${context} must be a boolean.`);
118
+ }
119
+ return value;
120
+ }
121
+
122
+ function expectString(value: unknown, context: string): string {
123
+ if (typeof value !== "string") {
124
+ throw new Error(`${context} must be a string.`);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function expectI32(value: unknown, context: string): number {
130
+ const numberValue = expectNumber(value, context);
131
+ if (!Number.isInteger(numberValue) || numberValue < -2147483648 || numberValue > 2147483647) {
132
+ throw new Error(`${context} must be a signed 32-bit integer.`);
133
+ }
134
+ return numberValue;
135
+ }
136
+
137
+ function decodeHostServiceArgs(
138
+ method: NormalizedHostServiceMethod,
139
+ rawArgs: readonly unknown[],
140
+ io: HostServiceImportIo,
141
+ ): ReadonlyArray<unknown> {
142
+ const decodedArgs: Array<unknown> = [];
143
+ let index = 0;
144
+ method.args.forEach((type, argIndex) => {
145
+ const context = `Host service ${method.serviceName}.${method.methodName} arg ${String(argIndex)}`;
146
+ if (type === "string") {
147
+ const ptr = expectNumber(rawArgs[index], `${context} ptr`);
148
+ const len = expectNumber(rawArgs[index + 1], `${context} len`);
149
+ decodedArgs.push(len <= 0 ? "" : io.readString(ptr, len));
150
+ index += 2;
151
+ return;
152
+ }
153
+ const rawValue = rawArgs[index];
154
+ if (type === "bool") {
155
+ decodedArgs.push(expectNumber(rawValue, context) !== 0);
156
+ } else if (type === "i32") {
157
+ decodedArgs.push(expectI32(rawValue, context));
158
+ } else if (type === "f64") {
159
+ decodedArgs.push(expectNumber(rawValue, context));
160
+ } else {
161
+ throw new Error(`${context} uses unsupported type ${type}.`);
162
+ }
163
+ index += 1;
164
+ });
165
+ return decodedArgs;
166
+ }
167
+
168
+ export function createHostServiceImportModule(
169
+ services: HostServicesDefinition | undefined,
170
+ io: HostServiceImportIo,
171
+ ): Record<string, (...rawArgs: Array<unknown>) => number | void> {
172
+ const module: Record<string, (...rawArgs: Array<unknown>) => number | void> = {};
173
+ for (const method of listHostServiceMethods(services)) {
174
+ module[method.importName] = (...rawArgs: Array<unknown>): number | void => {
175
+ const decodedArgs = decodeHostServiceArgs(method, rawArgs, io);
176
+ const result = method.implementation(...decodedArgs);
177
+ const resultContext = `Host service ${method.serviceName}.${method.methodName} result`;
178
+ if (method.returns === "void") {
179
+ return;
180
+ }
181
+ if (method.returns === "string") {
182
+ let outputIndex = 0;
183
+ method.args.forEach((type) => {
184
+ outputIndex += type === "string" ? 2 : 1;
185
+ });
186
+ const ptr = expectNumber(rawArgs[outputIndex], `${resultContext} ptr`);
187
+ const capacity = expectNumber(rawArgs[outputIndex + 1], `${resultContext} capacity`);
188
+ return io.writeString(ptr, capacity, expectString(result, resultContext), resultContext);
189
+ }
190
+ if (method.returns === "bool") {
191
+ return expectBoolean(result, resultContext) ? 1 : 0;
192
+ }
193
+ if (method.returns === "i32") {
194
+ return expectI32(result, resultContext);
195
+ }
196
+ if (method.returns === "f64") {
197
+ return expectNumber(result, resultContext);
198
+ }
199
+ throw new Error(`${resultContext} uses unsupported type ${method.returns}.`);
200
+ };
201
+ }
202
+ return module;
203
+ }
@@ -0,0 +1,62 @@
1
+ export {
2
+ startHarness,
3
+ startManagedHarness,
4
+ canManagedNavigateBack,
5
+ canManagedNavigateForward,
6
+ pushManagedHistoryEntry,
7
+ readManagedHistoryState,
8
+ replaceManagedHistoryEntry,
9
+ setCurrentManagedHistorySnapshotId,
10
+ syncManagedHistoryPop,
11
+ } from './common-harness';
12
+
13
+ export type {
14
+ HarnessAppOptions,
15
+ HarnessContext,
16
+ HarnessController,
17
+ HarnessDebugApi,
18
+ HarnessExports,
19
+ HarnessNavigationMode,
20
+ HarnessOptions,
21
+ HarnessState,
22
+ ManagedHarnessOptions,
23
+ ManagedHistoryState,
24
+ } from './common-harness';
25
+
26
+ export {
27
+ startRoutedHarness,
28
+ } from './routed-harness';
29
+
30
+ export type {
31
+ RoutedHarnessConfig,
32
+ RoutedHarnessManagerState,
33
+ RoutedHarnessRoute,
34
+ } from './routed-harness';
35
+
36
+ export {
37
+ defineHostEvents,
38
+ hostEvent,
39
+ } from './host-events';
40
+
41
+ export type {
42
+ HostEventMethodDefinition,
43
+ HostEventsDefinition,
44
+ NormalizedHostEventMethod,
45
+ } from './host-events';
46
+
47
+ export {
48
+ defineHostServices,
49
+ hostService,
50
+ } from './host-services';
51
+
52
+ export type {
53
+ HostServiceImportIo,
54
+ HostServiceMethodDefinition,
55
+ HostServiceTypeName,
56
+ HostServicesDefinition,
57
+ NormalizedHostServiceMethod,
58
+ } from './host-services';
59
+
60
+ export type {
61
+ WorkerHostServicesBundleConfig,
62
+ } from './worker-types';
@@ -0,0 +1,206 @@
1
+ export const PERSISTED_UI_STATE_DB_NAME = 'effindom-ui-state';
2
+ export const PERSISTED_UI_STATE_DB_VERSION = 1;
3
+ export const PERSISTED_UI_STATE_SNAPSHOT_SCHEMA_VERSION = 1;
4
+ export const PERSISTED_UI_STATE_SNAPSHOTS_STORE = 'snapshots';
5
+ export const PERSISTED_UI_STATE_ROUTE_HEADS_STORE = 'route-heads';
6
+ export const PERSISTED_SCROLL_ENTRY_KIND = 'scroll-position';
7
+ export const PERSISTED_SCROLL_ENTRY_VERSION = 1;
8
+
9
+ const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
10
+
11
+ export interface PersistedSnapshotEntry {
12
+ readonly nodeId: string;
13
+ readonly kind: string;
14
+ readonly version: number;
15
+ readonly payload: unknown;
16
+ }
17
+
18
+ export interface PersistedScrollPayload {
19
+ readonly x: number;
20
+ readonly y: number;
21
+ }
22
+
23
+ export interface PersistedSnapshotRecord {
24
+ readonly snapshotId: string;
25
+ readonly appKey: string;
26
+ readonly routeHref: string;
27
+ readonly createdAt: number;
28
+ readonly lastAccessedAt: number;
29
+ readonly schemaVersion: number;
30
+ readonly entries: readonly PersistedSnapshotEntry[];
31
+ }
32
+
33
+ export interface PersistedRouteHeadRecord {
34
+ readonly routeKey: string;
35
+ readonly appKey: string;
36
+ readonly routeHref: string;
37
+ readonly snapshotId: string;
38
+ readonly updatedAt: number;
39
+ }
40
+
41
+ export interface PersistedUiStateStore {
42
+ saveSnapshot(record: PersistedSnapshotRecord): Promise<void>;
43
+ loadSnapshot(snapshotId: string): Promise<PersistedSnapshotRecord | null>;
44
+ loadRouteHead(appKey: string, routeHref: string): Promise<PersistedRouteHeadRecord | null>;
45
+ deleteSnapshot(snapshotId: string): Promise<void>;
46
+ collectGarbage(now: number): Promise<void>;
47
+ }
48
+
49
+ export function buildPersistedUiRouteKey(appKey: string, routeHref: string): string {
50
+ return `${appKey}\n${routeHref}`;
51
+ }
52
+
53
+ function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
54
+ return new Promise<T>((resolve, reject) => {
55
+ request.onsuccess = () => resolve(request.result);
56
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.'));
57
+ });
58
+ }
59
+
60
+ function transactionToPromise(transaction: IDBTransaction): Promise<void> {
61
+ return new Promise<void>((resolve, reject) => {
62
+ transaction.oncomplete = () => resolve();
63
+ transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.'));
64
+ transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
65
+ });
66
+ }
67
+
68
+ function openDatabase(factory: IDBFactory): Promise<IDBDatabase> {
69
+ return new Promise<IDBDatabase>((resolve, reject) => {
70
+ const request = factory.open(PERSISTED_UI_STATE_DB_NAME, PERSISTED_UI_STATE_DB_VERSION);
71
+ request.onupgradeneeded = () => {
72
+ const database = request.result;
73
+ let snapshots = database.objectStoreNames.contains(PERSISTED_UI_STATE_SNAPSHOTS_STORE)
74
+ ? request.transaction?.objectStore(PERSISTED_UI_STATE_SNAPSHOTS_STORE) ?? null
75
+ : null;
76
+ if (snapshots === null) {
77
+ snapshots = database.createObjectStore(PERSISTED_UI_STATE_SNAPSHOTS_STORE, {
78
+ keyPath: 'snapshotId',
79
+ });
80
+ }
81
+ if (!snapshots.indexNames.contains('byLastAccessedAt')) {
82
+ snapshots.createIndex('byLastAccessedAt', 'lastAccessedAt');
83
+ }
84
+
85
+ let routeHeads = database.objectStoreNames.contains(PERSISTED_UI_STATE_ROUTE_HEADS_STORE)
86
+ ? request.transaction?.objectStore(PERSISTED_UI_STATE_ROUTE_HEADS_STORE) ?? null
87
+ : null;
88
+ if (routeHeads === null) {
89
+ routeHeads = database.createObjectStore(PERSISTED_UI_STATE_ROUTE_HEADS_STORE, {
90
+ keyPath: 'routeKey',
91
+ });
92
+ }
93
+ if (!routeHeads.indexNames.contains('byUpdatedAt')) {
94
+ routeHeads.createIndex('byUpdatedAt', 'updatedAt');
95
+ }
96
+ if (!routeHeads.indexNames.contains('bySnapshotId')) {
97
+ routeHeads.createIndex('bySnapshotId', 'snapshotId');
98
+ }
99
+ };
100
+ request.onsuccess = () => resolve(request.result);
101
+ request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB database.'));
102
+ });
103
+ }
104
+
105
+ class IndexedDbPersistedUiStateStore implements PersistedUiStateStore {
106
+ private readonly databasePromise: Promise<IDBDatabase>;
107
+
108
+ constructor(factory: IDBFactory) {
109
+ this.databasePromise = openDatabase(factory);
110
+ }
111
+
112
+ async saveSnapshot(record: PersistedSnapshotRecord): Promise<void> {
113
+ const database = await this.databasePromise;
114
+ const transaction = database.transaction(
115
+ [PERSISTED_UI_STATE_SNAPSHOTS_STORE, PERSISTED_UI_STATE_ROUTE_HEADS_STORE],
116
+ 'readwrite',
117
+ );
118
+ const snapshots = transaction.objectStore(PERSISTED_UI_STATE_SNAPSHOTS_STORE);
119
+ const routeHeads = transaction.objectStore(PERSISTED_UI_STATE_ROUTE_HEADS_STORE);
120
+ snapshots.put(record);
121
+ const updatedAt = Math.max(record.createdAt, record.lastAccessedAt);
122
+ routeHeads.put({
123
+ routeKey: buildPersistedUiRouteKey(record.appKey, record.routeHref),
124
+ appKey: record.appKey,
125
+ routeHref: record.routeHref,
126
+ snapshotId: record.snapshotId,
127
+ updatedAt,
128
+ } satisfies PersistedRouteHeadRecord);
129
+ await transactionToPromise(transaction);
130
+ }
131
+
132
+ async loadSnapshot(snapshotId: string): Promise<PersistedSnapshotRecord | null> {
133
+ const database = await this.databasePromise;
134
+ const loadTransaction = database.transaction(PERSISTED_UI_STATE_SNAPSHOTS_STORE, 'readonly');
135
+ const snapshots = loadTransaction.objectStore(PERSISTED_UI_STATE_SNAPSHOTS_STORE);
136
+ const existing = await requestToPromise<PersistedSnapshotRecord | undefined>(snapshots.get(snapshotId));
137
+ await transactionToPromise(loadTransaction);
138
+ if (existing === undefined) {
139
+ return null;
140
+ }
141
+
142
+ const updatedRecord: PersistedSnapshotRecord = {
143
+ ...existing,
144
+ lastAccessedAt: Date.now(),
145
+ };
146
+ const saveTransaction = database.transaction(PERSISTED_UI_STATE_SNAPSHOTS_STORE, 'readwrite');
147
+ saveTransaction.objectStore(PERSISTED_UI_STATE_SNAPSHOTS_STORE).put(updatedRecord);
148
+ await transactionToPromise(saveTransaction);
149
+ return updatedRecord;
150
+ }
151
+
152
+ async loadRouteHead(appKey: string, routeHref: string): Promise<PersistedRouteHeadRecord | null> {
153
+ const database = await this.databasePromise;
154
+ const transaction = database.transaction(PERSISTED_UI_STATE_ROUTE_HEADS_STORE, 'readonly');
155
+ const routeHeads = transaction.objectStore(PERSISTED_UI_STATE_ROUTE_HEADS_STORE);
156
+ const routeHead = await requestToPromise<PersistedRouteHeadRecord | undefined>(
157
+ routeHeads.get(buildPersistedUiRouteKey(appKey, routeHref)),
158
+ );
159
+ await transactionToPromise(transaction);
160
+ return routeHead ?? null;
161
+ }
162
+
163
+ async deleteSnapshot(snapshotId: string): Promise<void> {
164
+ const database = await this.databasePromise;
165
+ const transaction = database.transaction(PERSISTED_UI_STATE_SNAPSHOTS_STORE, 'readwrite');
166
+ transaction.objectStore(PERSISTED_UI_STATE_SNAPSHOTS_STORE).delete(snapshotId);
167
+ await transactionToPromise(transaction);
168
+ }
169
+
170
+ async collectGarbage(now: number): Promise<void> {
171
+ const database = await this.databasePromise;
172
+ const transaction = database.transaction(
173
+ [PERSISTED_UI_STATE_SNAPSHOTS_STORE, PERSISTED_UI_STATE_ROUTE_HEADS_STORE],
174
+ 'readwrite',
175
+ );
176
+ const snapshots = transaction.objectStore(PERSISTED_UI_STATE_SNAPSHOTS_STORE);
177
+ const routeHeads = transaction.objectStore(PERSISTED_UI_STATE_ROUTE_HEADS_STORE);
178
+ const snapshotRecords = await requestToPromise<Array<PersistedSnapshotRecord>>(snapshots.getAll());
179
+ const routeHeadRecords = await requestToPromise<Array<PersistedRouteHeadRecord>>(routeHeads.getAll());
180
+ const retainedSnapshotIds = new Set(routeHeadRecords.map((record) => record.snapshotId));
181
+ const knownSnapshotIds = new Set(snapshotRecords.map((record) => record.snapshotId));
182
+ const pruneBefore = now - SNAPSHOT_MAX_AGE_MS;
183
+
184
+ for (const routeHead of routeHeadRecords) {
185
+ if (!knownSnapshotIds.has(routeHead.snapshotId)) {
186
+ routeHeads.delete(routeHead.routeKey);
187
+ }
188
+ }
189
+
190
+ for (const snapshot of snapshotRecords) {
191
+ if (snapshot.lastAccessedAt >= pruneBefore || retainedSnapshotIds.has(snapshot.snapshotId)) {
192
+ continue;
193
+ }
194
+ snapshots.delete(snapshot.snapshotId);
195
+ }
196
+
197
+ await transactionToPromise(transaction);
198
+ }
199
+ }
200
+
201
+ export function createPersistedUiStateStore(): PersistedUiStateStore | null {
202
+ if (typeof globalThis.indexedDB === 'undefined') {
203
+ return null;
204
+ }
205
+ return new IndexedDbPersistedUiStateStore(globalThis.indexedDB);
206
+ }