@async/framework 0.7.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/src/app.js CHANGED
@@ -4,16 +4,18 @@ import { createHandlerRegistry } from "./handlers.js";
4
4
  import { Loader } from "./loader.js";
5
5
  import { createPartialRegistry } from "./partials.js";
6
6
  import { createRouteRegistry, createRouter } from "./router.js";
7
- import { createServerRegistry } from "./server.js";
7
+ import { createScheduler } from "./scheduler.js";
8
+ import { createServerNamespace } from "./server.js";
8
9
  import { createSignal, createSignalRegistry } from "./signals.js";
9
10
  import { createRegistryStore } from "./registry-store.js";
10
11
  import { attributeName, normalizeAttributeConfig } from "./attributes.js";
11
12
 
12
13
  const registryTypes = new Set(["signal", "handler", "server", "partial", "route", "component"]);
13
14
 
14
- export function defineApp(initial) {
15
+ export function defineApp(initial, options = {}) {
15
16
  const registry = createRegistryStore(undefined, { target: "browser" });
16
17
  const runtimes = new Set();
18
+ const createRuntime = options.createRuntime ?? createApp;
17
19
 
18
20
  const app = {
19
21
  registry,
@@ -32,7 +34,7 @@ export function defineApp(initial) {
32
34
  },
33
35
 
34
36
  start(options = {}) {
35
- const runtime = createApp(app, options).start();
37
+ const runtime = createRuntime(app, options).start();
36
38
  app.runtime = runtime;
37
39
  return runtime;
38
40
  },
@@ -57,13 +59,18 @@ export function defineApp(initial) {
57
59
  export function createApp(appOrDefinition = Async, options = {}) {
58
60
  const app = isAppHub(appOrDefinition) ? appOrDefinition : defineApp(appOrDefinition ?? {});
59
61
  const target = options.target ?? "browser";
62
+ const scheduler = options.scheduler ?? options.loader?.scheduler ?? createScheduler({
63
+ strategy: target === "server" ? "manual" : "microtask"
64
+ });
65
+ const ownsScheduler = !options.scheduler && !options.loader?.scheduler;
60
66
  const attributes = normalizeAttributeConfig(options.attributes);
61
67
  const registry = options.registry ?? app.registry.view({ target });
62
68
  const signals = options.signals ?? createSignalRegistry(undefined, { registry, type: "signal" });
63
69
  const handlers = options.handlers ?? createHandlerRegistry(undefined, { registry, type: "handler" });
64
70
  const serverCache = createCacheRegistry(undefined, { registry, type: "cache.server" });
65
71
  const browserCache = createCacheRegistry(undefined, { registry, type: "cache.browser" });
66
- const server = options.server ?? createServerRegistry(undefined, { registry, type: "server" });
72
+ const serverFactory = options.serverFactory ?? createServerReferenceRegistry;
73
+ const server = options.server ?? serverFactory(undefined, { registry, type: "server" });
67
74
  const partials = options.partials ?? createPartialRegistry(undefined, { registry, type: "partial" });
68
75
  const routes = options.routes ?? createRouteRegistry(undefined, { registry, type: "route" });
69
76
  const components = options.components ?? createComponentRegistry(undefined, { registry, type: "component" });
@@ -91,6 +98,7 @@ export function createApp(appOrDefinition = Async, options = {}) {
91
98
  },
92
99
  loader,
93
100
  router,
101
+ scheduler,
94
102
  attributes,
95
103
 
96
104
  start() {
@@ -107,12 +115,13 @@ export function createApp(appOrDefinition = Async, options = {}) {
107
115
  handlers,
108
116
  server,
109
117
  cache: browserCache,
118
+ scheduler,
110
119
  attributes
111
120
  });
112
121
  runtime.loader = loader;
113
122
 
114
123
  configureServerContext({ cache: browserCache });
115
- signals._setContext?.({ server, loader, cache: browserCache });
124
+ signals._setContext?.({ server, loader, cache: browserCache, scheduler });
116
125
 
117
126
  loader.start();
118
127
 
@@ -128,6 +137,7 @@ export function createApp(appOrDefinition = Async, options = {}) {
128
137
  server,
129
138
  cache: browserCache,
130
139
  partials,
140
+ scheduler,
131
141
  fetch: options.fetch,
132
142
  routeEndpoint: options.routeEndpoint,
133
143
  attributes
@@ -139,7 +149,7 @@ export function createApp(appOrDefinition = Async, options = {}) {
139
149
  }
140
150
  } else {
141
151
  configureServerContext({ cache: serverCache });
142
- signals._setContext?.({ server, cache: serverCache });
152
+ signals._setContext?.({ server, cache: serverCache, scheduler });
143
153
  }
144
154
 
145
155
  return runtime;
@@ -153,9 +163,10 @@ export function createApp(appOrDefinition = Async, options = {}) {
153
163
  async render(url) {
154
164
  assertActive();
155
165
  configureServerContext({ cache: serverCache });
156
- signals._setContext?.({ server, cache: serverCache });
166
+ signals._setContext?.({ server, cache: serverCache, scheduler });
157
167
  const matched = routes.match(url);
158
168
  if (!matched) {
169
+ await scheduler.flush();
159
170
  return {
160
171
  html: renderDocument("", { status: 404, signals, browserCache, boundary: options.boundary ?? "route", attributes }),
161
172
  status: 404,
@@ -175,8 +186,8 @@ export function createApp(appOrDefinition = Async, options = {}) {
175
186
  cache: serverCache,
176
187
  browserCache,
177
188
  partials,
178
- request: options.request,
179
- locals: options.locals
189
+ scheduler,
190
+ ...currentRequestContext()
180
191
  })
181
192
  : { html: "" };
182
193
 
@@ -189,6 +200,8 @@ export function createApp(appOrDefinition = Async, options = {}) {
189
200
  browserCache.restore(result.cache.browser);
190
201
  }
191
202
 
203
+ await scheduler.flush();
204
+
192
205
  const status = result.status ?? 200;
193
206
  return {
194
207
  html: renderDocument(result.html, { status, signals, browserCache, boundary: result.boundary ?? options.boundary ?? "route", attributes }),
@@ -207,6 +220,9 @@ export function createApp(appOrDefinition = Async, options = {}) {
207
220
  router?.destroy?.();
208
221
  loader?.destroy?.();
209
222
  signals.destroy?.();
223
+ if (ownsScheduler) {
224
+ scheduler.destroy();
225
+ }
210
226
  },
211
227
 
212
228
  _applyUse(normalized) {
@@ -227,11 +243,23 @@ export function createApp(appOrDefinition = Async, options = {}) {
227
243
  loader,
228
244
  router,
229
245
  cache,
230
- request: options.request,
231
- locals: options.locals
246
+ scheduler,
247
+ requestContext: options.requestContext,
248
+ ...currentRequestContext()
232
249
  });
233
250
  }
234
251
 
252
+ function currentRequestContext() {
253
+ const context = readRequestContextLike(options.requestContext);
254
+ return {
255
+ requestContext: context,
256
+ request: context.request ?? options.request,
257
+ headers: context.headers ?? options.headers,
258
+ cookies: context.cookies ?? options.cookies,
259
+ locals: context.locals ?? options.locals
260
+ };
261
+ }
262
+
235
263
  function assertActive() {
236
264
  if (destroyed) {
237
265
  throw new Error("Async app runtime has been destroyed.");
@@ -385,6 +413,77 @@ function attachServerCache(server, cache) {
385
413
  }
386
414
  }
387
415
 
416
+ function createServerReferenceRegistry(initialMap = {}, options = {}) {
417
+ const registry = options.registry ?? createRegistryStore();
418
+ const type = options.type ?? "server";
419
+ const defaults = {};
420
+
421
+ const reference = {
422
+ registry,
423
+
424
+ register(id, value) {
425
+ registry.register(type, id, value);
426
+ return id;
427
+ },
428
+
429
+ registerMany(map) {
430
+ for (const [id, value] of Object.entries(map ?? {})) {
431
+ reference.register(id, value);
432
+ }
433
+ return reference;
434
+ },
435
+
436
+ unregister(id) {
437
+ return registry.unregister(type, id);
438
+ },
439
+
440
+ resolve() {
441
+ return undefined;
442
+ },
443
+
444
+ async run(id) {
445
+ throw new Error(`Server command "${id}" cannot run without a server proxy or server registry.`);
446
+ },
447
+
448
+ keys() {
449
+ return registry.keys(type);
450
+ },
451
+
452
+ entries() {
453
+ return registry.entries(type);
454
+ },
455
+
456
+ inspect() {
457
+ return registry.entries(type);
458
+ },
459
+
460
+ _setContext(context = {}) {
461
+ Object.assign(defaults, context);
462
+ return reference;
463
+ },
464
+
465
+ _adoptMany() {
466
+ return reference;
467
+ }
468
+ };
469
+
470
+ reference.registerMany(initialMap);
471
+ return createServerNamespace((id, args, context) => reference.run(id, args, context), reference, () => defaults);
472
+ }
473
+
474
+ function readRequestContextLike(store) {
475
+ if (!store) {
476
+ return {};
477
+ }
478
+ if (typeof store.get === "function") {
479
+ return store.get() ?? {};
480
+ }
481
+ if (typeof store.getStore === "function") {
482
+ return store.getStore() ?? {};
483
+ }
484
+ return {};
485
+ }
486
+
388
487
  function normalizeEntries(type, entries = {}) {
389
488
  if (type !== "signal") {
390
489
  return { ...(entries ?? {}) };
@@ -90,7 +90,8 @@ export function asyncSignal(id, fn) {
90
90
  router: context.router,
91
91
  loader: context.loader,
92
92
  cache: context.cache,
93
- abort: activeAbort
93
+ abort: activeAbort,
94
+ scheduler: context.scheduler
94
95
  });
95
96
  }
96
97
  return server;
@@ -104,6 +105,9 @@ export function asyncSignal(id, fn) {
104
105
  get cache() {
105
106
  return registry._context?.().cache;
106
107
  },
108
+ get scheduler() {
109
+ return registry._context?.().scheduler;
110
+ },
107
111
  get version() {
108
112
  return runVersion;
109
113
  },
@@ -180,11 +184,20 @@ export function asyncSignal(id, fn) {
180
184
  _bindRegistry(nextRegistry, nextId) {
181
185
  registry = nextRegistry;
182
186
  registeredId = nextId;
183
- queueMicrotask(() => {
187
+ const start = () => {
184
188
  if (registry === nextRegistry && status === "idle") {
185
189
  state.refresh();
186
190
  }
187
- });
191
+ };
192
+ const scheduler = registry._context?.().scheduler;
193
+ if (scheduler) {
194
+ scheduler.enqueue("async", start, {
195
+ scope: registeredId,
196
+ key: `asyncSignal:${registeredId}:initial`
197
+ });
198
+ } else {
199
+ queueMicrotask(start);
200
+ }
188
201
  },
189
202
 
190
203
  _dispose() {
@@ -220,11 +233,26 @@ export function asyncSignal(id, fn) {
220
233
  for (const dependency of dependencies) {
221
234
  const dependencyId = String(dependency).split(".")[0];
222
235
  if (dependencyId && dependencyId !== registeredId) {
223
- dependencyCleanups.add(registry.subscribe(dependency, () => state.refresh()));
236
+ dependencyCleanups.add(registry.subscribe(dependency, () => scheduleRefresh()));
224
237
  }
225
238
  }
226
239
  }
227
240
 
241
+ function scheduleRefresh() {
242
+ if (activeAbort && !activeAbort.aborted) {
243
+ activeAbort.cancel(new Error(`Async signal "${registeredId}" dependency changed.`));
244
+ }
245
+ const scheduler = registry?._context?.().scheduler;
246
+ if (!scheduler) {
247
+ state.refresh();
248
+ return;
249
+ }
250
+ scheduler.enqueue("async", () => state.refresh(), {
251
+ scope: registeredId,
252
+ key: `asyncSignal:${registeredId}:refresh`
253
+ });
254
+ }
255
+
228
256
  function notify() {
229
257
  for (const subscriber of [...subscribers]) {
230
258
  subscriber(state);
@@ -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 ADDED
@@ -0,0 +1,16 @@
1
+ export { asyncSignal } from "./async-signal.js";
2
+ export { Async, createApp, defineApp, readSnapshot } from "./app.js";
3
+ export { attributeName, defineAttributeConfig } from "./attributes.js";
4
+ export { createBoundaryReceiver } from "./boundary-receiver.js";
5
+ export { createCacheRegistry, defineCache } from "./cache.js";
6
+ export { component, createComponentRegistry, defineComponent } from "./component.js";
7
+ export { delay } from "./delay.js";
8
+ export { createHandlerRegistry } from "./handlers.js";
9
+ export { html } from "./html.js";
10
+ export { Loader, AsyncLoader } from "./loader.js";
11
+ export { createPartialRegistry } from "./partials.js";
12
+ export { createRegistryStore } from "./registry-store.js";
13
+ export { createRouteRegistry, createRouter, defineRoute, route } from "./router.js";
14
+ export { createScheduler } from "./scheduler.js";
15
+ export { applyServerResult, createServerProxy, resolveServerCommandArguments, unwrapServerResult } from "./server.js";
16
+ export { computed, createSignal, createSignalRegistry, effect, signal } from "./signals.js";