@async/framework 0.8.0 → 0.9.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/server.d.ts CHANGED
@@ -75,6 +75,7 @@ export interface Scheduler {
75
75
  afterFlush(job: () => MaybePromise<unknown>, options?: { scope?: unknown; boundary?: string; key?: string }): Cleanup;
76
76
  cancelScope(scope: unknown): this;
77
77
  markScopeDestroyed(scope: unknown): this;
78
+ isScopeDestroyed(scope: unknown): boolean;
78
79
  destroy(): void;
79
80
  inspect(): SchedulerInspection;
80
81
  }
@@ -458,6 +459,53 @@ export interface LoaderInstance {
458
459
  export type AsyncLoaderOptions = LoaderOptions;
459
460
  export type AsyncLoaderInstance = LoaderInstance;
460
461
 
462
+ export interface BoundaryPatch {
463
+ boundary: string;
464
+ seq: number;
465
+ html?: TemplateLike;
466
+ signals?: Record<string, unknown>;
467
+ cache?: { browser?: Record<string, unknown> };
468
+ redirect?: string;
469
+ error?: unknown;
470
+ parentScope?: string;
471
+ scope?: string;
472
+ meta?: Record<string, unknown>;
473
+ }
474
+
475
+ export type BoundaryApplyResult =
476
+ | { status: "applied"; boundary: string; seq: number }
477
+ | { status: "ignored-stale"; boundary: string; seq: number; lastSeq: number }
478
+ | { status: "ignored-destroyed"; boundary: string; seq: number; parentScope?: string }
479
+ | { status: "redirected"; boundary: string; seq: number; redirect: string }
480
+ | { status: "errored"; boundary: string; seq: number; error: Error };
481
+
482
+ export interface BoundaryReceiverInspection {
483
+ destroyed: boolean;
484
+ boundaries: Record<string, { lastSeq: number; applied: number; ignored: number; errored?: number; lastStatus?: BoundaryApplyResult["status"] }>;
485
+ recent: Array<{ boundary: string; seq: number; status: BoundaryApplyResult["status"]; lastSeq?: number; parentScope?: string; redirect?: string }>;
486
+ }
487
+
488
+ export interface BoundaryReceiverOptions {
489
+ loader: LoaderInstance;
490
+ signals?: SignalRegistry;
491
+ cache?: CacheRegistry;
492
+ scheduler?: Scheduler;
493
+ router?: Router;
494
+ onApply?(result: BoundaryApplyResult, patch: BoundaryPatch): void;
495
+ onIgnore?(result: BoundaryApplyResult, patch: BoundaryPatch): void;
496
+ onError?(error: Error, result: BoundaryApplyResult, patch: BoundaryPatch): void;
497
+ throwOnError?: boolean;
498
+ recentLimit?: number;
499
+ isScopeDestroyed?(scope: string): boolean;
500
+ }
501
+
502
+ export interface BoundaryReceiver {
503
+ apply(patch: BoundaryPatch): Promise<BoundaryApplyResult>;
504
+ inspect(): BoundaryReceiverInspection;
505
+ reset(boundary?: string): this;
506
+ destroy(): void;
507
+ }
508
+
461
509
  export interface RegistryStore {
462
510
  target: RuntimeTarget;
463
511
  register(type: RegistryType, id: string, value: unknown): string;
@@ -568,6 +616,7 @@ export interface AsyncNamespace extends AppHub {
568
616
  readSnapshot: typeof readSnapshot;
569
617
  attributeName: typeof attributeName;
570
618
  defineAttributeConfig: typeof defineAttributeConfig;
619
+ createBoundaryReceiver: typeof createBoundaryReceiver;
571
620
  createCacheRegistry: typeof createCacheRegistry;
572
621
  defineCache: typeof defineCache;
573
622
  component: typeof component;
@@ -605,6 +654,7 @@ export declare function defineApp(initial?: AppDefinition): AppHub;
605
654
  export declare function readSnapshot(root?: Document | Element, options?: { attributes?: AttributeConfig }): { signals?: Record<string, unknown>; cache?: { browser?: Record<string, unknown> } };
606
655
  export declare function attributeName(attributes: AttributeConfig | undefined, type: keyof NormalizedAttributeConfig, name: string): string;
607
656
  export declare function defineAttributeConfig(config?: AttributeConfig): NormalizedAttributeConfig;
657
+ export declare function createBoundaryReceiver(options: BoundaryReceiverOptions): BoundaryReceiver;
608
658
  export declare function createCacheRegistry(initialMap?: Record<string, CacheDefinition | CacheDefinitionOptions>, options?: { now?: () => number; registry?: RegistryStore; type?: "cache.browser" | "cache.server" }): CacheRegistry;
609
659
  export declare function defineCache(options?: CacheDefinitionOptions): CacheDefinition;
610
660
  export declare function component<TProps extends Record<string, unknown> = Record<string, unknown>>(fn: ComponentFunction<TProps>): ComponentFunction<TProps>;
@@ -0,0 +1,302 @@
1
+ const defaultRecentLimit = 50;
2
+
3
+ export function createBoundaryReceiver(options = {}) {
4
+ const loader = options.loader;
5
+ const signals = options.signals ?? loader?.signals;
6
+ const cache = options.cache ?? loader?.cache;
7
+ const scheduler = options.scheduler ?? loader?.scheduler;
8
+ const router = options.router ?? loader?.router;
9
+ const recentLimit = options.recentLimit ?? defaultRecentLimit;
10
+ const throwOnError = options.throwOnError === true;
11
+ const onApply = typeof options.onApply === "function" ? options.onApply : undefined;
12
+ const onIgnore = typeof options.onIgnore === "function" ? options.onIgnore : undefined;
13
+ const onError = typeof options.onError === "function" ? options.onError : undefined;
14
+ const isScopeDestroyed = typeof options.isScopeDestroyed === "function"
15
+ ? options.isScopeDestroyed
16
+ : (scope) => scheduler?.isScopeDestroyed?.(scope) ?? scheduler?.inspectDestroyed?.(scope) ?? false;
17
+
18
+ if (!loader || typeof loader.swap !== "function") {
19
+ throw new TypeError("createBoundaryReceiver(...) requires a loader with swap(boundary, html).");
20
+ }
21
+ if (!Number.isInteger(recentLimit) || recentLimit < 0) {
22
+ throw new TypeError("createBoundaryReceiver(...) recentLimit must be a non-negative integer.");
23
+ }
24
+
25
+ const boundaries = new Map();
26
+ const recent = [];
27
+ let destroyed = false;
28
+
29
+ const receiver = {
30
+ async apply(patch) {
31
+ if (destroyed) {
32
+ throw new Error("Boundary receiver has been destroyed.");
33
+ }
34
+
35
+ const normalized = validatePatch(patch);
36
+ const record = boundaryRecord(normalized.boundary);
37
+ if (normalized.seq <= record.lastSeq) {
38
+ const result = {
39
+ status: "ignored-stale",
40
+ boundary: normalized.boundary,
41
+ seq: normalized.seq,
42
+ lastSeq: record.lastSeq
43
+ };
44
+ record.ignored += 1;
45
+ record.lastStatus = result.status;
46
+ remember(result);
47
+ onIgnore?.(result, patch);
48
+ return result;
49
+ }
50
+
51
+ if (normalized.parentScope !== undefined && isScopeDestroyed(normalized.parentScope)) {
52
+ const result = {
53
+ status: "ignored-destroyed",
54
+ boundary: normalized.boundary,
55
+ seq: normalized.seq,
56
+ parentScope: normalized.parentScope
57
+ };
58
+ record.ignored += 1;
59
+ record.lastStatus = result.status;
60
+ remember(result);
61
+ onIgnore?.(result, patch);
62
+ return result;
63
+ }
64
+
65
+ record.lastSeq = normalized.seq;
66
+
67
+ if (Object.hasOwn(normalized, "error")) {
68
+ const error = toStableError(normalized.error);
69
+ const result = {
70
+ status: "errored",
71
+ boundary: normalized.boundary,
72
+ seq: normalized.seq,
73
+ error
74
+ };
75
+ record.errored += 1;
76
+ record.lastStatus = result.status;
77
+ remember(result);
78
+ onError?.(error, result, patch);
79
+ if (throwOnError) {
80
+ throw error;
81
+ }
82
+ return result;
83
+ }
84
+
85
+ if (normalized.signals) {
86
+ if (!signals || typeof signals.set !== "function") {
87
+ throw new Error("Boundary patch includes signals, but no signal registry is available.");
88
+ }
89
+ for (const [path, value] of Object.entries(normalized.signals)) {
90
+ signals.set(path, value);
91
+ }
92
+ }
93
+
94
+ if (normalized.cache?.browser) {
95
+ if (!cache || typeof cache.restore !== "function") {
96
+ throw new Error("Boundary patch includes browser cache, but no cache registry is available.");
97
+ }
98
+ cache.restore(normalized.cache.browser);
99
+ }
100
+
101
+ if (normalized.html != null) {
102
+ loader.swap(normalized.boundary, normalized.html);
103
+ }
104
+
105
+ await flushScheduler(scheduler, normalized.scope);
106
+
107
+ if (normalized.redirect) {
108
+ await followRedirect(normalized.redirect, router, loader);
109
+ const result = {
110
+ status: "redirected",
111
+ boundary: normalized.boundary,
112
+ seq: normalized.seq,
113
+ redirect: normalized.redirect
114
+ };
115
+ record.applied += 1;
116
+ record.lastStatus = result.status;
117
+ remember(result);
118
+ onApply?.(result, patch);
119
+ return result;
120
+ }
121
+
122
+ const result = {
123
+ status: "applied",
124
+ boundary: normalized.boundary,
125
+ seq: normalized.seq
126
+ };
127
+ record.applied += 1;
128
+ record.lastStatus = result.status;
129
+ remember(result);
130
+ onApply?.(result, patch);
131
+ return result;
132
+ },
133
+
134
+ inspect() {
135
+ const snapshot = {};
136
+ for (const [boundary, record] of boundaries) {
137
+ snapshot[boundary] = {
138
+ lastSeq: record.lastSeq,
139
+ applied: record.applied,
140
+ ignored: record.ignored,
141
+ lastStatus: record.lastStatus
142
+ };
143
+ if (record.errored > 0) {
144
+ snapshot[boundary].errored = record.errored;
145
+ }
146
+ }
147
+ return {
148
+ destroyed,
149
+ boundaries: snapshot,
150
+ recent: recent.map((entry) => ({ ...entry }))
151
+ };
152
+ },
153
+
154
+ reset(boundary) {
155
+ if (boundary === undefined) {
156
+ boundaries.clear();
157
+ recent.length = 0;
158
+ return receiver;
159
+ }
160
+ assertBoundary(boundary);
161
+ boundaries.delete(boundary);
162
+ for (let index = recent.length - 1; index >= 0; index -= 1) {
163
+ if (recent[index].boundary === boundary) {
164
+ recent.splice(index, 1);
165
+ }
166
+ }
167
+ return receiver;
168
+ },
169
+
170
+ destroy() {
171
+ destroyed = true;
172
+ boundaries.clear();
173
+ recent.length = 0;
174
+ }
175
+ };
176
+
177
+ return receiver;
178
+
179
+ function boundaryRecord(boundary) {
180
+ if (!boundaries.has(boundary)) {
181
+ boundaries.set(boundary, {
182
+ lastSeq: -Infinity,
183
+ applied: 0,
184
+ ignored: 0,
185
+ errored: 0,
186
+ lastStatus: undefined
187
+ });
188
+ }
189
+ return boundaries.get(boundary);
190
+ }
191
+
192
+ function remember(result) {
193
+ if (recentLimit === 0) {
194
+ return;
195
+ }
196
+ recent.push(toRecentEntry(result));
197
+ while (recent.length > recentLimit) {
198
+ recent.shift();
199
+ }
200
+ }
201
+ }
202
+
203
+ function validatePatch(patch) {
204
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
205
+ throw new TypeError("receiver.apply(patch) requires a boundary patch object.");
206
+ }
207
+
208
+ assertBoundary(patch.boundary);
209
+ if (typeof patch.seq !== "number" || !Number.isFinite(patch.seq)) {
210
+ throw new TypeError("Boundary patch seq must be a finite number.");
211
+ }
212
+
213
+ if (patch.signals !== undefined && !isPlainObject(patch.signals)) {
214
+ throw new TypeError("Boundary patch signals must be an object.");
215
+ }
216
+ if (patch.cache !== undefined && !isPlainObject(patch.cache)) {
217
+ throw new TypeError("Boundary patch cache must be an object.");
218
+ }
219
+ if (patch.cache?.browser !== undefined && !isPlainObject(patch.cache.browser)) {
220
+ throw new TypeError("Boundary patch cache.browser must be an object.");
221
+ }
222
+ if (patch.redirect !== undefined && (typeof patch.redirect !== "string" || patch.redirect.length === 0)) {
223
+ throw new TypeError("Boundary patch redirect must be a non-empty string.");
224
+ }
225
+ if (patch.parentScope !== undefined && typeof patch.parentScope !== "string") {
226
+ throw new TypeError("Boundary patch parentScope must be a string.");
227
+ }
228
+ if (patch.scope !== undefined && typeof patch.scope !== "string") {
229
+ throw new TypeError("Boundary patch scope must be a string.");
230
+ }
231
+
232
+ const hasHtml = Object.hasOwn(patch, "html") && patch.html != null;
233
+ const hasSignals = patch.signals && Object.keys(patch.signals).length > 0;
234
+ const hasBrowserCache = patch.cache?.browser && Object.keys(patch.cache.browser).length > 0;
235
+ const hasRedirect = Boolean(patch.redirect);
236
+ const hasError = Object.hasOwn(patch, "error");
237
+ if (!hasHtml && !hasSignals && !hasBrowserCache && !hasRedirect && !hasError) {
238
+ throw new TypeError("Boundary patch must include html, signals, cache.browser, redirect, or error.");
239
+ }
240
+
241
+ return patch;
242
+ }
243
+
244
+ function assertBoundary(boundary) {
245
+ if (typeof boundary !== "string" || boundary.length === 0) {
246
+ throw new TypeError("Boundary patch boundary must be a non-empty string.");
247
+ }
248
+ }
249
+
250
+ async function flushScheduler(scheduler, scope) {
251
+ if (!scheduler) {
252
+ return;
253
+ }
254
+ if (scope !== undefined && typeof scheduler.flushScope === "function") {
255
+ await scheduler.flushScope(scope);
256
+ return;
257
+ }
258
+ if (typeof scheduler.flush === "function") {
259
+ await scheduler.flush();
260
+ }
261
+ }
262
+
263
+ async function followRedirect(redirect, router, loader) {
264
+ if (router && typeof router.navigate === "function") {
265
+ await router.navigate(redirect);
266
+ return;
267
+ }
268
+ const location = loader?.root?.ownerDocument?.defaultView?.location ?? globalThis.location;
269
+ location?.assign?.(redirect);
270
+ }
271
+
272
+ function toStableError(value) {
273
+ if (value instanceof Error) {
274
+ return value;
275
+ }
276
+ if (value && typeof value === "object" && typeof value.message === "string") {
277
+ return Object.assign(new Error(value.message), value);
278
+ }
279
+ return new Error(String(value));
280
+ }
281
+
282
+ function toRecentEntry(result) {
283
+ const entry = {
284
+ boundary: result.boundary,
285
+ seq: result.seq,
286
+ status: result.status
287
+ };
288
+ if (result.status === "ignored-stale") {
289
+ entry.lastSeq = result.lastSeq;
290
+ }
291
+ if (result.status === "ignored-destroyed" && result.parentScope !== undefined) {
292
+ entry.parentScope = result.parentScope;
293
+ }
294
+ if (result.status === "redirected") {
295
+ entry.redirect = result.redirect;
296
+ }
297
+ return entry;
298
+ }
299
+
300
+ function isPlainObject(value) {
301
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
302
+ }
package/src/browser.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { asyncSignal } from "./async-signal.js";
2
2
  export { Async, createApp, defineApp, readSnapshot } from "./app.js";
3
3
  export { attributeName, defineAttributeConfig } from "./attributes.js";
4
+ export { createBoundaryReceiver } from "./boundary-receiver.js";
4
5
  export { createCacheRegistry, defineCache } from "./cache.js";
5
6
  export { component, createComponentRegistry, defineComponent } from "./component.js";
6
7
  export { delay } from "./delay.js";
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { asyncSignal } from "./async-signal.js";
2
2
  export { Async, createApp, defineApp, readSnapshot } from "./server-entry.js";
3
3
  export { attributeName, defineAttributeConfig } from "./attributes.js";
4
+ export { createBoundaryReceiver } from "./boundary-receiver.js";
4
5
  export { createCacheRegistry, defineCache } from "./cache.js";
5
6
  export { component, createComponentRegistry, defineComponent } from "./component.js";
6
7
  export { delay } from "./delay.js";
package/src/scheduler.js CHANGED
@@ -162,6 +162,10 @@ export function createScheduler(options = {}) {
162
162
  return api;
163
163
  },
164
164
 
165
+ isScopeDestroyed(scope) {
166
+ return scope !== undefined && destroyedScopes.has(scope);
167
+ },
168
+
165
169
  inspect() {
166
170
  const counts = {};
167
171
  for (const [phase, queue] of queues) {