@async/framework 0.8.0 → 0.10.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.
@@ -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,11 +1,14 @@
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";
8
+ export { defineAsyncContainerElement, defineAsyncSuspenseElement } from "./elements.js";
7
9
  export { createHandlerRegistry } from "./handlers.js";
8
10
  export { html } from "./html.js";
11
+ export { createLazyRegistry, defineRegistrySnapshot } from "./lazy-registry.js";
9
12
  export { Loader, AsyncLoader } from "./loader.js";
10
13
  export { createPartialRegistry } from "./partials.js";
11
14
  export { createRegistryStore } from "./registry-store.js";
package/src/component.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { attributeName } from "./attributes.js";
2
2
  import { escapeHtml, rawHtml, renderTemplate } from "./html.js";
3
3
  import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
4
+ import { createLazyRegistry, isLazyDescriptor } from "./lazy-registry.js";
4
5
 
5
6
  const componentKind = Symbol.for("@async/framework.component");
6
7
  let componentCounter = 0;
@@ -22,13 +23,15 @@ export function createComponentRegistry(initialMap = {}, options = {}) {
22
23
  const registryStore = options.registry ?? createRegistryStore();
23
24
  const type = options.type ?? "component";
24
25
  const entries = registryStore._map(type);
26
+ const lazyRegistry = options.lazyRegistry ?? createLazyRegistry(options);
27
+ const lazyComponents = new Map();
25
28
 
26
29
  const registry = attachRegistryInspection({
27
30
  register(id, Component) {
28
31
  if (typeof id !== "string" || id.length === 0) {
29
32
  throw new TypeError("Component id must be a non-empty string.");
30
33
  }
31
- if (!isComponent(Component) && typeof Component !== "function") {
34
+ if (!isComponent(Component) && typeof Component !== "function" && !isLazyDescriptor(Component)) {
32
35
  throw new TypeError(`Component "${id}" must be a component function.`);
33
36
  }
34
37
  if (entries.has(id)) {
@@ -49,6 +52,7 @@ export function createComponentRegistry(initialMap = {}, options = {}) {
49
52
  if (typeof id !== "string" || id.length === 0) {
50
53
  throw new TypeError("Component id must be a non-empty string.");
51
54
  }
55
+ lazyComponents.delete(id);
52
56
  return entries.delete(id);
53
57
  },
54
58
 
@@ -56,7 +60,20 @@ export function createComponentRegistry(initialMap = {}, options = {}) {
56
60
  if (typeof id !== "string" || id.length === 0) {
57
61
  throw new TypeError("Component id must be a non-empty string.");
58
62
  }
59
- return entries.get(id);
63
+ const Component = entries.get(id);
64
+ if (!isLazyDescriptor(Component)) {
65
+ return Component;
66
+ }
67
+ if (!lazyComponents.has(id)) {
68
+ lazyComponents.set(id, async function LazyComponent(...args) {
69
+ const resolved = await lazyRegistry.resolve(type, id, Component);
70
+ if (typeof resolved !== "function") {
71
+ throw new TypeError(`Component "${id}" did not resolve to a function.`);
72
+ }
73
+ return resolved.apply(this, args);
74
+ });
75
+ }
76
+ return lazyComponents.get(id);
60
77
  },
61
78
 
62
79
  _adoptMany() {
@@ -0,0 +1,63 @@
1
+ import { Async } from "./app.js";
2
+
3
+ export function defineAsyncContainerElement(options = {}) {
4
+ const tagName = options.tagName ?? "async-container";
5
+ const registry = options.customElements ?? globalThis.customElements;
6
+ if (!registry) {
7
+ throw new Error("defineAsyncContainerElement(...) requires customElements.");
8
+ }
9
+ const existing = registry.get(tagName);
10
+ if (existing) {
11
+ return existing;
12
+ }
13
+ const app = options.app ?? options.Async ?? Async;
14
+ const HTMLElementBase = options.HTMLElement ?? options.window?.HTMLElement ?? globalThis.HTMLElement;
15
+ if (!HTMLElementBase) {
16
+ throw new Error("defineAsyncContainerElement(...) requires HTMLElement.");
17
+ }
18
+
19
+ class AsyncContainerElement extends HTMLElementBase {
20
+ connectedCallback() {
21
+ if (this.__asyncAttached) {
22
+ return;
23
+ }
24
+ const runtime = app.runtime ?? app.start?.();
25
+ runtime?.attachRoot?.(this);
26
+ this.__asyncRuntime = runtime;
27
+ this.__asyncAttached = true;
28
+ }
29
+
30
+ disconnectedCallback() {
31
+ if (!this.__asyncAttached) {
32
+ return;
33
+ }
34
+ this.__asyncRuntime?.detachRoot?.(this);
35
+ this.__asyncRuntime = undefined;
36
+ this.__asyncAttached = false;
37
+ }
38
+ }
39
+
40
+ registry.define(tagName, AsyncContainerElement);
41
+ return AsyncContainerElement;
42
+ }
43
+
44
+ export function defineAsyncSuspenseElement(options = {}) {
45
+ const tagName = options.tagName ?? "async-suspense";
46
+ const registry = options.customElements ?? globalThis.customElements;
47
+ if (!registry) {
48
+ throw new Error("defineAsyncSuspenseElement(...) requires customElements.");
49
+ }
50
+ const existing = registry.get(tagName);
51
+ if (existing) {
52
+ return existing;
53
+ }
54
+ const HTMLElementBase = options.HTMLElement ?? options.window?.HTMLElement ?? globalThis.HTMLElement;
55
+ if (!HTMLElementBase) {
56
+ throw new Error("defineAsyncSuspenseElement(...) requires HTMLElement.");
57
+ }
58
+
59
+ class AsyncSuspenseElement extends HTMLElementBase {}
60
+
61
+ registry.define(tagName, AsyncSuspenseElement);
62
+ return AsyncSuspenseElement;
63
+ }
package/src/handlers.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  unwrapServerResult
6
6
  } from "./server.js";
7
7
  import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
8
+ import { createLazyRegistry, isLazyDescriptor } from "./lazy-registry.js";
8
9
 
9
10
  const builtInTokens = new Set(["prevent", "preventDefault", "stopPropagation", "stopImmediatePropagation"]);
10
11
  const builtInHandlers = {
@@ -26,11 +27,13 @@ export function createHandlerRegistry(initialMap = {}, options = {}) {
26
27
  const registryStore = options.registry ?? createRegistryStore();
27
28
  const type = options.type ?? "handler";
28
29
  const handlers = registryStore._map(type);
30
+ const lazyRegistry = options.lazyRegistry ?? createLazyRegistry(options);
31
+ const lazyHandlers = new Map();
29
32
 
30
33
  const registry = attachRegistryInspection({
31
34
  register(id, fn) {
32
35
  assertId(id);
33
- if (typeof fn !== "function") {
36
+ if (typeof fn !== "function" && !isLazyDescriptor(fn)) {
34
37
  throw new TypeError(`Handler "${id}" must be a function.`);
35
38
  }
36
39
  if (handlers.has(id)) {
@@ -49,12 +52,26 @@ export function createHandlerRegistry(initialMap = {}, options = {}) {
49
52
 
50
53
  unregister(id) {
51
54
  assertId(id);
55
+ lazyHandlers.delete(id);
52
56
  return handlers.delete(id);
53
57
  },
54
58
 
55
59
  resolve(id) {
56
60
  assertId(id);
57
- return handlers.get(id);
61
+ const handler = handlers.get(id);
62
+ if (!isLazyDescriptor(handler)) {
63
+ return handler;
64
+ }
65
+ if (!lazyHandlers.has(id)) {
66
+ lazyHandlers.set(id, async function runLazyHandler(...args) {
67
+ const resolved = await lazyRegistry.resolve(type, id, handler);
68
+ if (typeof resolved !== "function") {
69
+ throw new TypeError(`Handler "${id}" did not resolve to a function.`);
70
+ }
71
+ return resolved.apply(this, args);
72
+ });
73
+ }
74
+ return lazyHandlers.get(id);
58
75
  },
59
76
 
60
77
  async run(ref, context = {}) {
package/src/index.js CHANGED
@@ -1,11 +1,14 @@
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";
8
+ export { defineAsyncContainerElement, defineAsyncSuspenseElement } from "./elements.js";
7
9
  export { createHandlerRegistry } from "./handlers.js";
8
10
  export { html } from "./html.js";
11
+ export { createLazyRegistry, defineRegistrySnapshot } from "./lazy-registry.js";
9
12
  export { Loader, AsyncLoader } from "./loader.js";
10
13
  export { createPartialRegistry } from "./partials.js";
11
14
  export { createRegistryStore } from "./registry-store.js";