ignotum 0.0.13 → 0.0.15

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 (131) hide show
  1. package/README.md +6 -6
  2. package/dist/cli/bin.mjs +6111 -4448
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/cli/emscripten-module-D5GzfUNv.mjs +533 -0
  5. package/dist/cli/emscripten-module-D5GzfUNv.mjs.map +1 -0
  6. package/dist/cli/ffi-7IElU4Co.mjs +148 -0
  7. package/dist/cli/ffi-7IElU4Co.mjs.map +1 -0
  8. package/dist/cli/managed-release-WHYLZwaS.mjs +10902 -0
  9. package/dist/cli/managed-release-WHYLZwaS.mjs.map +1 -0
  10. package/dist/cli/managed-release.d.mts +1 -0
  11. package/dist/cli/managed-release.mjs +14 -0
  12. package/dist/cli/managed-release.mjs.map +1 -0
  13. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs +1289 -0
  14. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs.map +1 -0
  15. package/dist/runtime/{api-Cv3hMbzo.d.ts → api-B4fwp5a2.d.ts} +4 -4
  16. package/dist/runtime/bootstrap-BISQSn_O.js +916 -0
  17. package/dist/runtime/bootstrap-BISQSn_O.js.map +1 -0
  18. package/dist/runtime/bootstrap-BTFScKAc.d.ts +545 -0
  19. package/dist/runtime/client.d.ts +50 -15
  20. package/dist/runtime/client.js +7 -1138
  21. package/dist/runtime/client.js.map +1 -1
  22. package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js +269 -0
  23. package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js.map +1 -0
  24. package/dist/runtime/{id-Bt9XWRGL.js → id--1m0NuUL.js} +4 -4
  25. package/dist/runtime/id--1m0NuUL.js.map +1 -0
  26. package/dist/runtime/{id-BzFHf3Wo-DGPCjgrf.d.ts → id-7an3nxTu-DZJSM5xh.d.ts} +2 -2
  27. package/dist/runtime/id-CBOt2kDo.d.ts +1 -0
  28. package/dist/runtime/identity-QjtJRxBD.d.ts +2 -0
  29. package/dist/runtime/image-Djtjy4Z8.js +19 -0
  30. package/dist/runtime/image-Djtjy4Z8.js.map +1 -0
  31. package/dist/runtime/index-D4seNHlX.d.ts +184 -0
  32. package/dist/runtime/internal/api.d.ts +1 -1
  33. package/dist/runtime/internal/api.js +30 -1
  34. package/dist/runtime/internal/api.js.map +1 -0
  35. package/dist/runtime/internal/bootstrap.d.ts +2 -0
  36. package/dist/runtime/internal/bootstrap.js +2 -0
  37. package/dist/runtime/internal/client.d.ts +2 -0
  38. package/dist/runtime/internal/client.js +75 -0
  39. package/dist/runtime/internal/client.js.map +1 -0
  40. package/dist/runtime/internal/host.d.ts +25 -8
  41. package/dist/runtime/internal/host.js +27 -6
  42. package/dist/runtime/internal/host.js.map +1 -1
  43. package/dist/runtime/internal/routes.d.ts +2 -0
  44. package/dist/runtime/internal/routes.js +2 -0
  45. package/dist/runtime/internal/server.d.ts +1 -1
  46. package/dist/runtime/internal/server.js +1 -1
  47. package/dist/runtime/internal/types.d.ts +2 -1
  48. package/dist/runtime/internal/types.js +1 -1
  49. package/dist/runtime/pagination-CBOt2kDo.d.ts +1 -0
  50. package/dist/runtime/pagination-D3qd6s9N.js +33 -0
  51. package/dist/runtime/pagination-D3qd6s9N.js.map +1 -0
  52. package/dist/runtime/result-BkziOG1L.d.ts +1 -0
  53. package/dist/runtime/router-BSBI1oN1.js +2377 -0
  54. package/dist/runtime/router-BSBI1oN1.js.map +1 -0
  55. package/dist/runtime/routes-Ce8TVP-T.js +690 -0
  56. package/dist/runtime/routes-Ce8TVP-T.js.map +1 -0
  57. package/dist/runtime/{schema-1Zs03-iS.js → schema-D1wOqiNH.js} +40 -12
  58. package/dist/runtime/schema-D1wOqiNH.js.map +1 -0
  59. package/dist/runtime/server.d.ts +5 -5
  60. package/dist/runtime/server.js +2 -2
  61. package/dist/runtime/{sync-Bs8J3fIr.d.ts → sync-D-GK2sv9.d.ts} +3 -2
  62. package/dist/runtime/{api-CAgKDij7.js → sync-DzUwA8W9.js} +22 -41
  63. package/dist/runtime/sync-DzUwA8W9.js.map +1 -0
  64. package/dist/runtime/types-DeCCyqLU-BIm7rU-0.d.ts +341 -0
  65. package/package.json +29 -4
  66. package/src/cli/agent-files.ts +14 -8
  67. package/src/cli/build/managed-client.ts +411 -0
  68. package/src/cli/build/managed-server.ts +294 -0
  69. package/src/cli/build/managed.ts +67 -0
  70. package/src/cli/build/output.ts +47 -0
  71. package/src/cli/build/server.ts +51 -220
  72. package/src/cli/client-config.ts +1 -140
  73. package/src/cli/client-styles.ts +1 -0
  74. package/src/cli/codegen.ts +9 -4
  75. package/src/cli/command.ts +28 -5
  76. package/src/cli/control-client.ts +84 -101
  77. package/src/cli/deploy.ts +133 -177
  78. package/src/cli/dev.ts +506 -107
  79. package/src/cli/image-assets.ts +499 -0
  80. package/src/cli/managed-client-boundaries.ts +103 -0
  81. package/src/cli/managed-dev-platform.ts +118 -0
  82. package/src/cli/managed-exports.ts +219 -0
  83. package/src/cli/managed-release-bin.ts +13 -0
  84. package/src/cli/managed-release.ts +260 -0
  85. package/src/cli/module-boundaries.ts +11 -1
  86. package/src/cli/new-app.ts +111 -87
  87. package/src/cli/route-codegen.ts +311 -0
  88. package/src/cli/route-static.ts +710 -0
  89. package/src/client/bootstrap.ts +110 -0
  90. package/src/client/errors.ts +13 -7
  91. package/src/client/files.ts +64 -0
  92. package/src/client/id.ts +9 -5
  93. package/src/client/image.ts +28 -0
  94. package/src/client/index.ts +25 -2
  95. package/src/client/managed-client.ts +729 -0
  96. package/src/client/managed-upgrade.ts +283 -0
  97. package/src/client/recovery-journal.ts +195 -0
  98. package/src/client/route-boundaries.ts +184 -0
  99. package/src/client/router-history.ts +152 -0
  100. package/src/client/router-store.ts +644 -0
  101. package/src/client/router.ts +324 -0
  102. package/src/client/routes.ts +28 -0
  103. package/src/client/sync.ts +66 -592
  104. package/src/dev-runtime/database.ts +77 -2
  105. package/src/dev-runtime/functions.ts +3 -3
  106. package/src/dev-runtime/id.ts +19 -4
  107. package/src/dev-runtime/managed-functions.ts +255 -0
  108. package/src/dev-runtime/managed-sync.ts +263 -0
  109. package/src/dev-runtime/managed-websocket.ts +71 -0
  110. package/src/dev-runtime/migrations.ts +20 -0
  111. package/src/dev-runtime/sync.ts +236 -236
  112. package/src/image-assets.d.ts +36 -0
  113. package/src/internal/client.ts +186 -0
  114. package/src/internal/host.ts +1 -1
  115. package/src/internal/http-paths.ts +0 -3
  116. package/src/internal/routes.ts +45 -0
  117. package/dist/runtime/api-CAgKDij7.js.map +0 -1
  118. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +0 -154
  119. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +0 -1
  120. package/dist/runtime/id-Bt9XWRGL.js.map +0 -1
  121. package/dist/runtime/id-Dz0apuB3.d.ts +0 -1
  122. package/dist/runtime/index-D54flWtH.d.ts +0 -402
  123. package/dist/runtime/pagination-DnKg3dkI-r5ZUxBBx.d.ts +0 -112
  124. package/dist/runtime/pagination-Dz0apuB3.d.ts +0 -1
  125. package/dist/runtime/result-DKAA4gpS.d.ts +0 -1
  126. package/dist/runtime/schema-1Zs03-iS.js.map +0 -1
  127. package/src/cli/build/client.ts +0 -119
  128. package/src/cli/build/public.ts +0 -186
  129. package/src/cli/client-entry.ts +0 -152
  130. package/src/cli/client-plugin.ts +0 -136
  131. package/src/client/app.ts +0 -15
@@ -0,0 +1,283 @@
1
+ import {
2
+ ExecutionSelection,
3
+ RecoverySessionId,
4
+ sameExecutionSelection,
5
+ } from "@ignotum/contracts/platform";
6
+ import * as Versioned from "@ignotum/contracts/versioned";
7
+ import { AppId } from "@ignotum/contracts/runtime/identity";
8
+ import { Option, Schema } from "effect";
9
+
10
+ const TabStateV1 = Schema.Struct({
11
+ formatVersion: Schema.Literal(1),
12
+ appId: AppId,
13
+ clientSessionId: RecoverySessionId,
14
+ attempts: Schema.Natural.check(Schema.isLessThanOrEqualTo(3)),
15
+ attemptedAt: Schema.Natural,
16
+ target: Schema.NullOr(ExecutionSelection),
17
+ uncertainStorage: Schema.Boolean,
18
+ }).annotate({ parseOptions: { onExcessProperty: "error" } });
19
+ const TabState = Versioned.initial(TabStateV1);
20
+ const TabStateJson = Schema.fromJsonString(TabState);
21
+
22
+ export interface ManagedUpdateSnapshot {
23
+ readonly phase: "connecting" | "ready" | "settling" | "reloading" | "error";
24
+ readonly message?: string;
25
+ readonly recovery: readonly string[];
26
+ }
27
+
28
+ export interface ManagedUpgradeBrowser {
29
+ readonly crypto: Pick<Crypto, "randomUUID">;
30
+ readonly sessionStorage: Pick<Storage, "getItem" | "setItem">;
31
+ readonly history: Pick<History, "state" | "replaceState">;
32
+ readonly location: Pick<Location, "href">;
33
+ readonly fetch: typeof fetch;
34
+ }
35
+
36
+ /** One attempt owns admission, the settling deadline, and the reload across target changes. */
37
+ export class ManagedUpgrade {
38
+ readonly clientSessionId: RecoverySessionId;
39
+ private readonly key: string;
40
+ private tab: typeof TabState.Type;
41
+ private snapshot: ManagedUpdateSnapshot = { phase: "connecting", recovery: [] };
42
+ private readonly listeners = new Set<() => void>();
43
+ private target: ExecutionSelection | undefined;
44
+ private attempt: Promise<void> | undefined;
45
+ private disposed = false;
46
+ private storageAvailable = true;
47
+ private readyTimer: ReturnType<typeof setTimeout> | undefined;
48
+
49
+ constructor(
50
+ readonly selection: ExecutionSelection,
51
+ private readonly browser: ManagedUpgradeBrowser,
52
+ private readonly callbacks: {
53
+ readonly stopAdmission: () => void;
54
+ readonly unresolved: () => number;
55
+ readonly persist: () => readonly string[];
56
+ readonly reload: () => void;
57
+ readonly freshSelection: () => Promise<ExecutionSelection | undefined>;
58
+ },
59
+ ) {
60
+ this.key = `ignotum.managed.tab.${selection.appId}`;
61
+ this.tab = {
62
+ formatVersion: 1,
63
+ appId: selection.appId,
64
+ clientSessionId: RecoverySessionId.make(
65
+ `rec_${browser.crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`,
66
+ ),
67
+ attempts: 0,
68
+ attemptedAt: 0,
69
+ target: null,
70
+ uncertainStorage: false,
71
+ };
72
+ try {
73
+ const raw = browser.sessionStorage.getItem(this.key);
74
+ if (raw !== null) {
75
+ const decoded = Schema.decodeOption(TabStateJson)(raw);
76
+ if (
77
+ Option.isSome(decoded) &&
78
+ decoded.value.appId === selection.appId &&
79
+ (decoded.value.target === null || decoded.value.target.appId === selection.appId)
80
+ ) {
81
+ this.tab = decoded.value;
82
+ } else {
83
+ this.reportRecovery(
84
+ "Saved update information could not be read. Previous mutation outcomes may be unknown.",
85
+ );
86
+ }
87
+ }
88
+ } catch {
89
+ this.storageAvailable = false;
90
+ }
91
+ const history = Schema.decodeUnknownOption(Schema.Struct({ __ignotumUpdate: TabState }))(
92
+ browser.history.state,
93
+ );
94
+ if (
95
+ Option.isSome(history) &&
96
+ history.value.__ignotumUpdate.appId === selection.appId &&
97
+ history.value.__ignotumUpdate.attemptedAt >= this.tab.attemptedAt &&
98
+ (history.value.__ignotumUpdate.target === null ||
99
+ history.value.__ignotumUpdate.target.appId === selection.appId)
100
+ )
101
+ this.tab = history.value.__ignotumUpdate;
102
+ if (this.tab.uncertainStorage)
103
+ this.reportRecovery(
104
+ "Some previous mutation outcomes could not be saved in browser storage and remain unknown.",
105
+ );
106
+ this.clientSessionId = this.tab.clientSessionId;
107
+ this.save();
108
+ }
109
+
110
+ getSnapshot = (): ManagedUpdateSnapshot => this.snapshot;
111
+ subscribe = (listener: () => void): (() => void) => {
112
+ this.listeners.add(listener);
113
+ return () => this.listeners.delete(listener);
114
+ };
115
+ private update(next: ManagedUpdateSnapshot): void {
116
+ if (this.disposed) return;
117
+ this.snapshot = next;
118
+ for (const listener of this.listeners) listener();
119
+ }
120
+ reportRecovery(message: string): void {
121
+ if (!this.snapshot.recovery.includes(message))
122
+ this.update({ ...this.snapshot, recovery: [...this.snapshot.recovery, message].slice(-100) });
123
+ }
124
+ dismissRecovery = (): void => {
125
+ this.tab = { ...this.tab, uncertainStorage: false };
126
+ this.save();
127
+ this.update({ ...this.snapshot, recovery: [] });
128
+ };
129
+ private save(): boolean {
130
+ let saved = false;
131
+ try {
132
+ this.browser.sessionStorage.setItem(this.key, Schema.encodeSync(TabStateJson)(this.tab));
133
+ this.storageAvailable = true;
134
+ saved = true;
135
+ } catch {
136
+ this.storageAvailable = false;
137
+ }
138
+ try {
139
+ const existing = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))(
140
+ this.browser.history.state,
141
+ );
142
+ this.browser.history.replaceState(
143
+ {
144
+ ...Option.getOrElse(existing, () => ({})),
145
+ __ignotumUpdate: Schema.encodeSync(TabState)(this.tab),
146
+ },
147
+ "",
148
+ );
149
+ saved = true;
150
+ } catch {
151
+ /* The automatic retry budget must survive reload through at least one browser store. */
152
+ }
153
+ return saved;
154
+ }
155
+ get obsolete(): boolean {
156
+ return this.target !== undefined || this.attempt !== undefined;
157
+ }
158
+ get ready(): boolean {
159
+ return this.snapshot.phase === "ready" && !this.obsolete;
160
+ }
161
+ connecting(): void {
162
+ if (this.readyTimer !== undefined) clearTimeout(this.readyTimer);
163
+ if (!this.obsolete) this.update({ ...this.snapshot, phase: "connecting", message: undefined });
164
+ }
165
+ accepted(): void {
166
+ if (this.obsolete) return;
167
+ this.update({ ...this.snapshot, phase: "ready", message: undefined });
168
+ if (this.readyTimer !== undefined) clearTimeout(this.readyTimer);
169
+ // A repeatedly broken lazy asset must not reset the reload budget at every handshake.
170
+ this.readyTimer = setTimeout(() => {
171
+ this.tab = { ...this.tab, attempts: 0, attemptedAt: 0, target: null };
172
+ this.save();
173
+ }, 60_000);
174
+ }
175
+ unavailable(message = "The app could not check for updates. Reconnect and try again."): void {
176
+ if (this.readyTimer !== undefined) clearTimeout(this.readyTimer);
177
+ if (!this.obsolete) this.update({ ...this.snapshot, phase: "error", message });
178
+ }
179
+ require(target?: ExecutionSelection): Promise<void> {
180
+ if (this.disposed) return Promise.resolve();
181
+ if (target !== undefined) this.target = target;
182
+ this.callbacks.stopAdmission();
183
+ if (this.attempt !== undefined && this.snapshot.phase !== "error") return this.attempt;
184
+ if (this.readyTimer !== undefined) clearTimeout(this.readyTimer);
185
+ this.update({
186
+ ...this.snapshot,
187
+ phase: "settling",
188
+ message: "Updating the app. Waiting briefly for pending changes…",
189
+ });
190
+ this.attempt = this.run();
191
+ return this.attempt;
192
+ }
193
+ private async run(): Promise<void> {
194
+ const deadline = Date.now() + 2_000;
195
+ while (!this.disposed && this.callbacks.unresolved() > 0 && Date.now() < deadline)
196
+ await new Promise<void>((resolve) => setTimeout(resolve, 25));
197
+ if (this.disposed) return;
198
+ try {
199
+ const issues = this.callbacks.persist();
200
+ for (const issue of issues) this.reportRecovery(issue);
201
+ if (issues.length > 0) {
202
+ this.tab = { ...this.tab, uncertainStorage: true };
203
+ await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
204
+ }
205
+ const fresh = await this.callbacks.freshSelection();
206
+ if (fresh !== undefined && fresh.appId !== this.selection.appId)
207
+ throw new Error("The app address now refers to a different app.");
208
+ this.target = fresh ?? this.target;
209
+ const response = await this.browser.fetch(this.browser.location.href, {
210
+ cache: "no-store",
211
+ credentials: "same-origin",
212
+ headers: { accept: "text/html" },
213
+ signal: AbortSignal.timeout(10_000),
214
+ });
215
+ if (!response.ok) throw new Error("The updated app document is temporarily unavailable.");
216
+ await response.body?.cancel();
217
+ if (this.disposed) return;
218
+ // A verified new selection starts a separate update budget.
219
+ const attempts =
220
+ fresh !== undefined &&
221
+ this.tab.target !== null &&
222
+ !sameExecutionSelection(this.tab.target, fresh)
223
+ ? 0
224
+ : this.tab.attempts;
225
+ if (attempts >= 2)
226
+ throw new Error(
227
+ "The app update could not load a consistent release. Try again when the connection or release is available.",
228
+ );
229
+ this.tab = {
230
+ ...this.tab,
231
+ attempts: attempts + 1,
232
+ attemptedAt: Date.now(),
233
+ target: fresh ?? this.target ?? this.selection,
234
+ };
235
+ if (!this.save()) {
236
+ throw new Error(
237
+ "Browser storage is unavailable. Pending changes may have unknown outcomes. Use Reload app to continue the update.",
238
+ );
239
+ }
240
+ this.update({ ...this.snapshot, phase: "reloading", message: "Loading the updated app…" });
241
+ this.callbacks.reload();
242
+ } catch (error) {
243
+ this.update({
244
+ ...this.snapshot,
245
+ phase: "error",
246
+ message:
247
+ error instanceof Error ? error.message : "The app update could not be loaded. Try again.",
248
+ });
249
+ }
250
+ }
251
+ /** An explicit retry resets the automatic budget, while obsolete code remains fenced. */
252
+ retry = (): void => {
253
+ if (this.disposed) return;
254
+ if (!this.storageAvailable) {
255
+ this.callbacks.persist();
256
+ this.callbacks.reload();
257
+ return;
258
+ }
259
+ this.tab = { ...this.tab, attempts: 0, attemptedAt: 0 };
260
+ this.save();
261
+ this.attempt = undefined;
262
+ void this.require(this.target);
263
+ };
264
+ async check(): Promise<boolean> {
265
+ if (this.obsolete || this.disposed) return false;
266
+ try {
267
+ const fresh = await this.callbacks.freshSelection();
268
+ if (fresh === undefined || !sameExecutionSelection(this.selection, fresh)) {
269
+ void this.require(fresh);
270
+ return false;
271
+ }
272
+ return !this.obsolete && !this.disposed;
273
+ } catch {
274
+ this.unavailable();
275
+ return false;
276
+ }
277
+ }
278
+ dispose(): void {
279
+ this.disposed = true;
280
+ if (this.readyTimer !== undefined) clearTimeout(this.readyTimer);
281
+ this.listeners.clear();
282
+ }
283
+ }
@@ -0,0 +1,195 @@
1
+ import { RecoveryIdentity, RecoverySessionId } from "@ignotum/contracts/platform";
2
+ import { AppId } from "@ignotum/contracts/runtime/identity";
3
+ import * as Versioned from "@ignotum/contracts/versioned";
4
+ import { DateTime, Predicate, Schema } from "effect";
5
+
6
+ export const RecoveryJournalLimits = {
7
+ maxEntries: 100,
8
+ retentionMs: 24 * 60 * 60 * 1000,
9
+ maxBytes: 64 * 1024,
10
+ } as const;
11
+ const RecoveryEntry = Schema.Struct({
12
+ identity: RecoveryIdentity,
13
+ recordedAt: Schema.Natural,
14
+ expiresAt: Schema.Natural,
15
+ state: Schema.Literals(["pending", "settled", "interrupted", "uncertain"]),
16
+ });
17
+ export type RecoveryJournalEntry = typeof RecoveryEntry.Type;
18
+ const RecoveryJournalV1 = Schema.Struct({
19
+ formatVersion: Schema.Literal(1),
20
+ appId: AppId,
21
+ sessionEpoch: Schema.String,
22
+ clientSessionId: RecoverySessionId,
23
+ entries: Schema.Array(RecoveryEntry).check(Schema.isMaxLength(RecoveryJournalLimits.maxEntries)),
24
+ })
25
+ .check(
26
+ Schema.makeFilter((record) => {
27
+ const keys = record.entries.map((entry) => identityKey(entry.identity));
28
+ return (
29
+ new Set(keys).size === keys.length &&
30
+ record.entries.every(
31
+ (entry) =>
32
+ entry.identity.originalSelection.appId === record.appId &&
33
+ entry.expiresAt > entry.recordedAt &&
34
+ entry.expiresAt - entry.recordedAt <= RecoveryJournalLimits.retentionMs,
35
+ )
36
+ );
37
+ }),
38
+ )
39
+ .annotate({ parseOptions: { onExcessProperty: "error" } });
40
+ export const RecoveryJournalRecord = Versioned.initial(RecoveryJournalV1);
41
+ export type RecoveryJournalRecord = typeof RecoveryJournalRecord.Type;
42
+ export type RecoveryJournalIssue = "denied" | "corrupt" | "full" | "expired" | "session-changed";
43
+ export interface RecoveryJournalSnapshot {
44
+ readonly clientSessionId: RecoverySessionId;
45
+ readonly entries: ReadonlyArray<RecoveryJournalEntry>;
46
+ readonly issues: ReadonlyArray<RecoveryJournalIssue>;
47
+ }
48
+ type JournalStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
49
+ export interface RecoveryJournalOptions {
50
+ readonly appId: AppId;
51
+ readonly sessionEpoch: string;
52
+ readonly clientSessionId: RecoverySessionId;
53
+ readonly storage?: JournalStorage | (() => JournalStorage);
54
+ readonly now?: () => number;
55
+ }
56
+ const identityKey = (identity: RecoveryIdentity): string =>
57
+ `${identity.originalSelection.dataHistoryId}/${identity.invocationId}`;
58
+ const bytes = (value: string): number => new TextEncoder().encode(value).byteLength;
59
+
60
+ /** sessionStorage isolates tabs. A cloned tab may look up copied identities, never replay them. */
61
+ export class RecoveryJournal {
62
+ readonly storageKey: string;
63
+ private clientSessionId: RecoverySessionId;
64
+ constructor(private readonly options: RecoveryJournalOptions) {
65
+ this.storageKey = `ignotum:recovery:${options.appId}`;
66
+ this.clientSessionId = options.clientSessionId;
67
+ }
68
+ private storage(): JournalStorage {
69
+ const storage = this.options.storage;
70
+ return storage === undefined
71
+ ? globalThis.sessionStorage
72
+ : Predicate.isFunction(storage)
73
+ ? storage()
74
+ : storage;
75
+ }
76
+ private now(): number {
77
+ return this.options.now?.() ?? DateTime.toEpochMillis(DateTime.nowUnsafe());
78
+ }
79
+ private snapshot(
80
+ entries: ReadonlyArray<RecoveryJournalEntry>,
81
+ issues: ReadonlyArray<RecoveryJournalIssue>,
82
+ ): RecoveryJournalSnapshot {
83
+ return { clientSessionId: this.clientSessionId, entries, issues };
84
+ }
85
+ read(): RecoveryJournalSnapshot {
86
+ let raw: string | null;
87
+ try {
88
+ raw = this.storage().getItem(this.storageKey);
89
+ } catch {
90
+ return this.snapshot([], ["denied"]);
91
+ }
92
+ if (raw === null) return this.snapshot([], []);
93
+ if (bytes(raw) > RecoveryJournalLimits.maxBytes) return this.discard("full");
94
+ const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(RecoveryJournalRecord))(raw);
95
+ if (parsed._tag === "None" || parsed.value.appId !== this.options.appId)
96
+ return this.discard("corrupt");
97
+ if (parsed.value.sessionEpoch !== this.options.sessionEpoch)
98
+ return this.discard("session-changed");
99
+ this.clientSessionId = parsed.value.clientSessionId;
100
+ const now = this.now();
101
+ const entries = parsed.value.entries.filter(
102
+ (entry) => entry.expiresAt > now && entry.recordedAt <= now,
103
+ );
104
+ const issues: RecoveryJournalIssue[] =
105
+ entries.length === parsed.value.entries.length ? [] : ["expired"];
106
+ return issues.length === 0 ? this.snapshot(entries, issues) : this.persist(entries, issues);
107
+ }
108
+ private discard(issue: RecoveryJournalIssue): RecoveryJournalSnapshot {
109
+ try {
110
+ this.storage().removeItem(this.storageKey);
111
+ return this.snapshot([], [issue]);
112
+ } catch {
113
+ return this.snapshot([], [issue, "denied"]);
114
+ }
115
+ }
116
+ private persist(
117
+ entries: ReadonlyArray<RecoveryJournalEntry>,
118
+ issues: ReadonlyArray<RecoveryJournalIssue>,
119
+ ): RecoveryJournalSnapshot {
120
+ const record: RecoveryJournalRecord = {
121
+ formatVersion: 1,
122
+ appId: this.options.appId,
123
+ sessionEpoch: this.options.sessionEpoch,
124
+ clientSessionId: this.clientSessionId,
125
+ entries,
126
+ };
127
+ const encoded = Schema.encodeOption(Schema.fromJsonString(RecoveryJournalRecord))(record);
128
+ if (encoded._tag === "None") return this.snapshot(entries, [...issues, "corrupt"]);
129
+ if (bytes(encoded.value) > RecoveryJournalLimits.maxBytes)
130
+ return this.snapshot(entries, [...issues, "full"]);
131
+ try {
132
+ this.storage().setItem(this.storageKey, encoded.value);
133
+ return this.snapshot(entries, issues);
134
+ } catch (error) {
135
+ return this.snapshot(entries, [
136
+ ...issues,
137
+ error instanceof DOMException && error.name === "QuotaExceededError" ? "full" : "denied",
138
+ ]);
139
+ }
140
+ }
141
+ /** Call before abandoning the page; any issue means durable recovery is not proven. */
142
+ write(identities: ReadonlyArray<RecoveryIdentity>): RecoveryJournalSnapshot {
143
+ const current = this.read();
144
+ if (current.issues.includes("denied")) return current;
145
+ const entries = new Map(current.entries.map((entry) => [identityKey(entry.identity), entry]));
146
+ const now = this.now();
147
+ for (const identity of identities) {
148
+ if (identity.originalSelection.appId !== this.options.appId)
149
+ return this.snapshot(current.entries, [...current.issues, "corrupt"]);
150
+ const key = identityKey(identity);
151
+ if (!entries.has(key))
152
+ entries.set(key, {
153
+ identity: {
154
+ invocationId: identity.invocationId,
155
+ originalSelection: identity.originalSelection,
156
+ },
157
+ recordedAt: now,
158
+ expiresAt: now + RecoveryJournalLimits.retentionMs,
159
+ state: "pending",
160
+ });
161
+ }
162
+ if (entries.size > RecoveryJournalLimits.maxEntries)
163
+ return this.snapshot(current.entries, [...current.issues, "full"]);
164
+ return this.persist([...entries.values()], current.issues);
165
+ }
166
+ reconcile(
167
+ identity: RecoveryIdentity,
168
+ state: Exclude<RecoveryJournalEntry["state"], "pending">,
169
+ ): RecoveryJournalSnapshot {
170
+ const current = this.read();
171
+ if (current.issues.includes("denied")) return current;
172
+ return this.persist(
173
+ current.entries.map((entry) =>
174
+ identityKey(entry.identity) === identityKey(identity) ? { ...entry, state } : entry,
175
+ ),
176
+ current.issues,
177
+ );
178
+ }
179
+ remove(identity: RecoveryIdentity): RecoveryJournalSnapshot {
180
+ const current = this.read();
181
+ if (current.issues.includes("denied")) return current;
182
+ return this.persist(
183
+ current.entries.filter((entry) => identityKey(entry.identity) !== identityKey(identity)),
184
+ current.issues,
185
+ );
186
+ }
187
+ clear(): RecoveryJournalSnapshot {
188
+ try {
189
+ this.storage().removeItem(this.storageKey);
190
+ return this.snapshot([], []);
191
+ } catch {
192
+ return this.snapshot([], ["denied"]);
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,184 @@
1
+ import { createElement as h, Fragment, type ComponentChildren, type ComponentType } from "preact";
2
+ import { useErrorBoundary, useState } from "preact/hooks";
3
+ import type {
4
+ ErrorBoundaryProps,
5
+ LoadedBranch,
6
+ RouterSnapshot,
7
+ RouterStore,
8
+ } from "./router-store.js";
9
+ import type { RouteLocation } from "./routes.js";
10
+
11
+ export const DefaultError = ({ error, retry }: ErrorBoundaryProps): ComponentChildren =>
12
+ h(
13
+ "div",
14
+ { role: "alert" },
15
+ error instanceof Error ? error.message : "This page could not load or render. ",
16
+ h("button", { onClick: retry }, "Retry"),
17
+ );
18
+
19
+ interface BoundaryProps {
20
+ readonly children: ComponentChildren;
21
+ readonly component: ComponentType<ErrorBoundaryProps>;
22
+ readonly branch: LoadedBranch;
23
+ readonly router: RouterStore;
24
+ readonly onRetry?: () => void;
25
+ }
26
+ const Boundary = ({
27
+ children,
28
+ component,
29
+ branch,
30
+ router,
31
+ onRetry,
32
+ }: BoundaryProps): ComponentChildren => {
33
+ const [failure, setFailure] = useState<{
34
+ readonly branch: LoadedBranch;
35
+ readonly error: unknown;
36
+ }>();
37
+ const [, reset] = useErrorBoundary((error) => {
38
+ setFailure({ branch, error });
39
+ router.setRenderError(true);
40
+ });
41
+ return failure?.branch !== branch
42
+ ? children
43
+ : h(component, {
44
+ error: failure.error,
45
+ retry: () => {
46
+ router.setRenderError(false);
47
+ onRetry?.();
48
+ setFailure(undefined);
49
+ reset();
50
+ },
51
+ });
52
+ };
53
+
54
+ export const Branch = ({
55
+ branch,
56
+ location,
57
+ router,
58
+ failure,
59
+ }: {
60
+ readonly branch: LoadedBranch;
61
+ readonly location: RouteLocation;
62
+ readonly router: RouterStore;
63
+ readonly failure: RouterSnapshot["failure"];
64
+ }): ComponentChildren => {
65
+ const outcome = branch.outcome;
66
+ let child: ComponentChildren;
67
+ let lastLayout = branch.layouts.length - 1;
68
+ let boundaryOwner: number | undefined;
69
+ if (outcome._tag === "Matched" && branch.page !== undefined) {
70
+ child = h(branch.page, {
71
+ key: outcome.route.id,
72
+ params: outcome.params,
73
+ searchParams: outcome.searchParams,
74
+ });
75
+ } else if (outcome._tag === "Invalid" && branch.pageBoundaries?.invalid !== undefined) {
76
+ child = h(branch.pageBoundaries.invalid, { location, issues: outcome.issues });
77
+ } else {
78
+ child = h(
79
+ "main",
80
+ { "data-ignotum-outcome": outcome._tag },
81
+ outcome._tag === "Invalid" ? "Invalid URL" : "Page not found",
82
+ );
83
+ for (let index = lastLayout; index >= 0; index--) {
84
+ const layout = branch.layouts[index]!;
85
+ if (outcome._tag === "Invalid" && layout.invalid !== undefined) {
86
+ child = h(layout.invalid, { location, issues: outcome.issues });
87
+ lastLayout = index;
88
+ boundaryOwner = index;
89
+ break;
90
+ }
91
+ if (outcome._tag === "NotFound" && layout.notFound !== undefined) {
92
+ child = h(layout.notFound, { location });
93
+ lastLayout = index;
94
+ boundaryOwner = index;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ if (branch.pageBoundaries?.error !== undefined && outcome._tag === "Matched")
100
+ child = h(Boundary, {
101
+ key: "page-boundary",
102
+ component: branch.pageBoundaries.error,
103
+ branch,
104
+ router,
105
+ children: child,
106
+ });
107
+ let sharedLayouts = 0;
108
+ while (
109
+ sharedLayouts <= lastLayout &&
110
+ branch.layouts[sharedLayouts]?.id === failure?.branch?.layouts[sharedLayouts]?.id
111
+ )
112
+ sharedLayouts++;
113
+ const loadFailure =
114
+ failure === undefined
115
+ ? null
116
+ : h(LoadFailure, {
117
+ failure,
118
+ router,
119
+ layouts: branch.layouts.slice(0, sharedLayouts),
120
+ });
121
+ for (let index = lastLayout; index >= 0; index--) {
122
+ const layout = branch.layouts[index]!;
123
+ if (layout.error !== undefined && index !== boundaryOwner)
124
+ child = h(Boundary, {
125
+ key: layout.id,
126
+ component: layout.error,
127
+ branch,
128
+ router,
129
+ children: child,
130
+ });
131
+ child = h(layout.component, {
132
+ key: layout.id,
133
+ children: h(Fragment, {}, child, index === sharedLayouts - 1 ? loadFailure : null),
134
+ });
135
+ }
136
+ return h(Boundary, {
137
+ component: DefaultError,
138
+ branch,
139
+ router,
140
+ children: h(Fragment, {}, child, sharedLayouts === 0 ? loadFailure : null),
141
+ });
142
+ };
143
+
144
+ export const LoadFailure = ({
145
+ failure,
146
+ router,
147
+ layouts,
148
+ }: {
149
+ readonly failure: NonNullable<RouterSnapshot["failure"]>;
150
+ readonly layouts?: LoadedBranch["layouts"];
151
+ readonly router: RouterStore;
152
+ }): ComponentChildren => {
153
+ const branch = failure.branch;
154
+ if (branch === undefined)
155
+ return h("div", { "data-ignotum-load-error": "" }, h(DefaultError, failure));
156
+ let child: ComponentChildren = h(DefaultError, failure);
157
+ let found = false;
158
+ for (const layout of [...(layouts ?? branch.layouts)].reverse()) {
159
+ if (layout.error === undefined) continue;
160
+ if (!found) {
161
+ child = h(layout.error, { error: failure.error, retry: failure.retry });
162
+ found = true;
163
+ } else {
164
+ child = h(Boundary, {
165
+ component: layout.error,
166
+ branch,
167
+ router,
168
+ onRetry: failure.retry,
169
+ children: child,
170
+ });
171
+ }
172
+ }
173
+ return h(
174
+ "div",
175
+ { "data-ignotum-load-error": "" },
176
+ h(Boundary, {
177
+ component: DefaultError,
178
+ branch,
179
+ router,
180
+ onRetry: failure.retry,
181
+ children: child,
182
+ }),
183
+ );
184
+ };