@async/framework 0.7.0 → 0.8.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/router.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Loader } from "./loader.js";
2
2
  import { createHandlerRegistry } from "./handlers.js";
3
+ import { createScheduler } from "./scheduler.js";
3
4
  import { createSignalRegistry } from "./signals.js";
4
5
  import { applyServerResult } from "./server.js";
5
6
  import { createRegistryStore } from "./registry-store.js";
@@ -121,12 +122,15 @@ export function createRouter({
121
122
  partials,
122
123
  fetch: fetchImpl = globalThis.fetch?.bind(globalThis),
123
124
  routeEndpoint = "/__async/route",
124
- attributes
125
+ attributes,
126
+ scheduler
125
127
  } = {}) {
126
128
  const documentRef = root?.ownerDocument ?? root ?? globalThis.document;
127
129
  const rootNode = root ?? documentRef;
128
130
  const signalRegistry = signals ?? loader?.signals ?? createSignalRegistry();
129
131
  const handlerRegistry = handlers ?? loader?.handlers ?? createHandlerRegistry();
132
+ const schedulerInstance = scheduler ?? loader?.scheduler ?? createScheduler();
133
+ const ownsScheduler = !scheduler && !loader?.scheduler;
130
134
  const attributeConfig = normalizeAttributeConfig(attributes ?? loader?.attributes);
131
135
  const loaderInstance =
132
136
  loader ??
@@ -136,6 +140,7 @@ export function createRouter({
136
140
  handlers: handlerRegistry,
137
141
  server,
138
142
  cache,
143
+ scheduler: schedulerInstance,
139
144
  attributes: attributeConfig
140
145
  });
141
146
  const ownsLoader = !loader;
@@ -155,12 +160,13 @@ export function createRouter({
155
160
  server,
156
161
  cache,
157
162
  partials,
163
+ scheduler: schedulerInstance,
158
164
  attributes: attributeConfig,
159
165
 
160
166
  start() {
161
167
  assertActive();
162
168
  loaderInstance.router = api;
163
- signalRegistry._setContext?.({ router: api, loader: loaderInstance, server, cache });
169
+ signalRegistry._setContext?.({ router: api, loader: loaderInstance, server, cache, scheduler: schedulerInstance });
164
170
  if (ownsLoader) {
165
171
  loaderInstance.start();
166
172
  }
@@ -224,6 +230,9 @@ export function createRouter({
224
230
  cleanup();
225
231
  }
226
232
  cleanups.clear();
233
+ if (ownsScheduler) {
234
+ schedulerInstance.destroy();
235
+ }
227
236
  }
228
237
  };
229
238
 
@@ -329,13 +338,16 @@ export function createRouter({
329
338
  loader: loaderInstance,
330
339
  router: api,
331
340
  cache,
341
+ scheduler: schedulerInstance,
332
342
  abort: navigation?.abort
333
343
  });
344
+ await schedulerInstance.flush();
334
345
  if (!isActiveNavigation(navigation)) {
335
346
  return;
336
347
  }
337
348
  if (result?.html != null && !result.boundary && !result.redirect) {
338
349
  loaderInstance.swap(boundary, result.html);
350
+ await schedulerInstance.flush();
339
351
  }
340
352
  if (result?.redirect || options.history === false) {
341
353
  return;
@@ -380,6 +392,7 @@ export function createRouter({
380
392
  loader: loaderInstance,
381
393
  server,
382
394
  cache,
395
+ scheduler: schedulerInstance,
383
396
  abort: navigation?.abort
384
397
  };
385
398
  }
@@ -0,0 +1,296 @@
1
+ const defaultPhases = ["binding", "lifecycle", "effect", "async", "post"];
2
+
3
+ export function createScheduler(options = {}) {
4
+ const phases = [...(options.phases ?? defaultPhases)];
5
+ const queues = new Map(phases.map((phase) => [phase, []]));
6
+ const keyedJobs = new Map();
7
+ const destroyedScopes = new Set();
8
+ const objectScopeIds = new WeakMap();
9
+ const onError = typeof options.onError === "function" ? options.onError : undefined;
10
+ const maxDepth = options.maxDepth ?? 100;
11
+ const strategy = options.strategy ?? "microtask";
12
+ let destroyed = false;
13
+ let flushing = false;
14
+ let scheduled = false;
15
+ let batchDepth = 0;
16
+ let jobCounter = 0;
17
+ let scopeCounter = 0;
18
+
19
+ const api = {
20
+ strategy,
21
+ phases,
22
+
23
+ batch(fn) {
24
+ if (typeof fn !== "function") {
25
+ throw new TypeError("scheduler.batch(fn) requires a function.");
26
+ }
27
+ assertActive();
28
+ batchDepth += 1;
29
+ let asyncBatch = false;
30
+ try {
31
+ const value = fn();
32
+ if (value && typeof value.then === "function") {
33
+ asyncBatch = true;
34
+ return value.finally(() => {
35
+ batchDepth -= 1;
36
+ requestFlush();
37
+ });
38
+ }
39
+ return value;
40
+ } finally {
41
+ if (!asyncBatch && batchDepth > 0) {
42
+ batchDepth -= 1;
43
+ requestFlush();
44
+ }
45
+ }
46
+ },
47
+
48
+ enqueue(phase, fn, options = {}) {
49
+ assertActive();
50
+ assertPhase(phase);
51
+ if (typeof fn !== "function") {
52
+ throw new TypeError("scheduler.enqueue(phase, fn) requires a function.");
53
+ }
54
+ const scope = options.scope;
55
+ if (scope !== undefined && destroyedScopes.has(scope)) {
56
+ return noop;
57
+ }
58
+
59
+ const dedupeKey = options.key === undefined ? undefined : `${phase}:${scopeKey(scope)}:${String(options.key)}`;
60
+ if (dedupeKey && keyedJobs.has(dedupeKey)) {
61
+ return keyedJobs.get(dedupeKey).cancel;
62
+ }
63
+
64
+ const job = {
65
+ id: ++jobCounter,
66
+ phase,
67
+ fn,
68
+ scope,
69
+ boundary: options.boundary,
70
+ key: dedupeKey,
71
+ canceled: false,
72
+ cancel() {
73
+ job.canceled = true;
74
+ if (job.key) {
75
+ keyedJobs.delete(job.key);
76
+ }
77
+ }
78
+ };
79
+ queues.get(phase).push(job);
80
+ if (job.key) {
81
+ keyedJobs.set(job.key, job);
82
+ }
83
+ requestFlush();
84
+ return job.cancel;
85
+ },
86
+
87
+ afterFlush(fn, options = {}) {
88
+ return api.enqueue("post", fn, options);
89
+ },
90
+
91
+ async flush() {
92
+ assertActive();
93
+ if (flushing) {
94
+ return;
95
+ }
96
+ scheduled = false;
97
+ flushing = true;
98
+ let depth = 0;
99
+ try {
100
+ while (hasJobs()) {
101
+ depth += 1;
102
+ if (depth > maxDepth) {
103
+ throw new Error(`Scheduler exceeded maxDepth ${maxDepth}.`);
104
+ }
105
+ for (const phase of phases) {
106
+ await flushPhase(phase);
107
+ }
108
+ }
109
+ } finally {
110
+ flushing = false;
111
+ if (hasJobs()) {
112
+ requestFlush();
113
+ }
114
+ }
115
+ },
116
+
117
+ async flushScope(scope) {
118
+ assertActive();
119
+ if (flushing) {
120
+ return;
121
+ }
122
+ scheduled = false;
123
+ flushing = true;
124
+ let depth = 0;
125
+ try {
126
+ while (hasJobsForScope(scope)) {
127
+ depth += 1;
128
+ if (depth > maxDepth) {
129
+ throw new Error(`Scheduler exceeded maxDepth ${maxDepth}.`);
130
+ }
131
+ for (const phase of phases) {
132
+ await flushPhase(phase, scope);
133
+ }
134
+ }
135
+ } finally {
136
+ flushing = false;
137
+ if (hasJobs()) {
138
+ requestFlush();
139
+ }
140
+ }
141
+ },
142
+
143
+ cancelScope(scope) {
144
+ if (scope === undefined) {
145
+ return api;
146
+ }
147
+ for (const queue of queues.values()) {
148
+ for (const job of queue) {
149
+ if (job.scope === scope) {
150
+ job.cancel();
151
+ }
152
+ }
153
+ }
154
+ return api;
155
+ },
156
+
157
+ markScopeDestroyed(scope) {
158
+ if (scope !== undefined) {
159
+ destroyedScopes.add(scope);
160
+ api.cancelScope(scope);
161
+ }
162
+ return api;
163
+ },
164
+
165
+ inspect() {
166
+ const counts = {};
167
+ for (const [phase, queue] of queues) {
168
+ counts[phase] = queue.filter((job) => !job.canceled).length;
169
+ }
170
+ return {
171
+ strategy,
172
+ phases: [...phases],
173
+ pending: counts,
174
+ scopesDestroyed: destroyedScopes.size,
175
+ flushing,
176
+ scheduled
177
+ };
178
+ },
179
+
180
+ destroy() {
181
+ destroyed = true;
182
+ for (const queue of queues.values()) {
183
+ for (const job of queue) {
184
+ job.cancel();
185
+ }
186
+ queue.length = 0;
187
+ }
188
+ keyedJobs.clear();
189
+ destroyedScopes.clear();
190
+ }
191
+ };
192
+
193
+ return api;
194
+
195
+ function requestFlush() {
196
+ if (strategy === "manual" || destroyed || flushing || batchDepth > 0 || scheduled) {
197
+ return;
198
+ }
199
+ scheduled = true;
200
+ scheduleMicrotask(() => {
201
+ if (!destroyed) {
202
+ void api.flush();
203
+ }
204
+ });
205
+ }
206
+
207
+ async function flushPhase(phase, scope) {
208
+ const queue = queues.get(phase);
209
+ const remaining = [];
210
+ const runnable = [];
211
+
212
+ for (const job of queue.splice(0)) {
213
+ if (job.canceled) {
214
+ continue;
215
+ }
216
+ if (scope !== undefined && job.scope !== scope) {
217
+ remaining.push(job);
218
+ continue;
219
+ }
220
+ runnable.push(job);
221
+ }
222
+
223
+ queue.push(...remaining);
224
+
225
+ for (const job of runnable) {
226
+ if (job.key) {
227
+ keyedJobs.delete(job.key);
228
+ }
229
+ if (job.canceled || (job.scope !== undefined && destroyedScopes.has(job.scope))) {
230
+ continue;
231
+ }
232
+ try {
233
+ await job.fn();
234
+ } catch (error) {
235
+ if (onError) {
236
+ onError(error, job);
237
+ } else {
238
+ throw error;
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ function hasJobs() {
245
+ for (const queue of queues.values()) {
246
+ if (queue.some((job) => !job.canceled)) {
247
+ return true;
248
+ }
249
+ }
250
+ return false;
251
+ }
252
+
253
+ function hasJobsForScope(scope) {
254
+ for (const queue of queues.values()) {
255
+ if (queue.some((job) => !job.canceled && job.scope === scope)) {
256
+ return true;
257
+ }
258
+ }
259
+ return false;
260
+ }
261
+
262
+ function assertActive() {
263
+ if (destroyed) {
264
+ throw new Error("Scheduler has been destroyed.");
265
+ }
266
+ }
267
+
268
+ function assertPhase(phase) {
269
+ if (!queues.has(phase)) {
270
+ throw new Error(`Unknown scheduler phase "${phase}".`);
271
+ }
272
+ }
273
+
274
+ function scopeKey(scope) {
275
+ if (scope === undefined) {
276
+ return "global";
277
+ }
278
+ if ((typeof scope === "object" && scope !== null) || typeof scope === "function") {
279
+ if (!objectScopeIds.has(scope)) {
280
+ objectScopeIds.set(scope, `scope:${++scopeCounter}`);
281
+ }
282
+ return objectScopeIds.get(scope);
283
+ }
284
+ return String(scope);
285
+ }
286
+ }
287
+
288
+ function scheduleMicrotask(fn) {
289
+ if (typeof queueMicrotask === "function") {
290
+ queueMicrotask(fn);
291
+ return;
292
+ }
293
+ Promise.resolve().then(fn);
294
+ }
295
+
296
+ function noop() {}
@@ -0,0 +1,20 @@
1
+ import {
2
+ createApp as createBaseApp,
3
+ defineApp as defineBaseApp,
4
+ readSnapshot
5
+ } from "./app.js";
6
+ import { createServerRegistry } from "./server-registry.js";
7
+
8
+ export function createApp(appOrDefinition = Async, options = {}) {
9
+ return createBaseApp(appOrDefinition, {
10
+ serverFactory: createServerRegistry,
11
+ ...options
12
+ });
13
+ }
14
+
15
+ export function defineApp(initial) {
16
+ return defineBaseApp(initial, { createRuntime: createApp });
17
+ }
18
+
19
+ export const Async = defineApp();
20
+ export { readSnapshot };
@@ -0,0 +1,97 @@
1
+ import { readRequestContext } from "./request-context.js";
2
+ import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
3
+ import { assertServerId, createServerNamespace, createSignalReader } from "./server.js";
4
+
5
+ export function createServerRegistry(initialMap = {}, options = {}) {
6
+ const registryStore = options.registry ?? createRegistryStore();
7
+ const type = options.type ?? "server";
8
+ const entries = registryStore._map(type);
9
+ const defaults = {};
10
+
11
+ const registry = attachRegistryInspection({
12
+ register(id, fn) {
13
+ assertServerId(id);
14
+ if (typeof fn !== "function") {
15
+ throw new TypeError(`Server function "${id}" must be a function.`);
16
+ }
17
+ if (entries.has(id)) {
18
+ throw new Error(`Server function "${id}" is already registered.`);
19
+ }
20
+ entries.set(id, fn);
21
+ return id;
22
+ },
23
+
24
+ registerMany(map) {
25
+ for (const [id, fn] of Object.entries(map ?? {})) {
26
+ registry.register(id, fn);
27
+ }
28
+ return registry;
29
+ },
30
+
31
+ unregister(id) {
32
+ assertServerId(id);
33
+ return entries.delete(id);
34
+ },
35
+
36
+ resolve(id) {
37
+ assertServerId(id);
38
+ return entries.get(id);
39
+ },
40
+
41
+ async run(id, args = [], context = {}) {
42
+ assertServerId(id);
43
+ const fn = registry.resolve(id);
44
+ if (!fn) {
45
+ throw new Error(`Server function "${id}" is not registered.`);
46
+ }
47
+
48
+ let runContext;
49
+ const server = createServerNamespace((childId, childArgs, childContext = {}) => {
50
+ return registry.run(childId, childArgs, { ...runContext, ...childContext });
51
+ }, {}, () => runContext);
52
+
53
+ const mergedContext = mergeRequestContext({
54
+ ...defaults,
55
+ ...context,
56
+ cache: defaults.cache ?? context.cache
57
+ });
58
+
59
+ runContext = {
60
+ ...mergedContext,
61
+ id,
62
+ args,
63
+ input: mergedContext.input,
64
+ signals: createSignalReader(mergedContext.signals),
65
+ abort: mergedContext.abort,
66
+ cache: mergedContext.cache,
67
+ server
68
+ };
69
+
70
+ return fn.call(runContext, ...args);
71
+ },
72
+
73
+ _setContext(context = {}) {
74
+ Object.assign(defaults, context);
75
+ return registry;
76
+ },
77
+
78
+ _adoptMany() {
79
+ return registry;
80
+ }
81
+ }, registryStore, type);
82
+
83
+ registry.registerMany(initialMap);
84
+ return createServerNamespace((id, args, context) => registry.run(id, args, context), registry, () => defaults);
85
+ }
86
+
87
+ function mergeRequestContext(context) {
88
+ const requestContext = readRequestContext(context.requestContext);
89
+ return {
90
+ ...context,
91
+ requestContext,
92
+ request: requestContext.request ?? context.request,
93
+ headers: requestContext.headers ?? context.headers,
94
+ cookies: requestContext.cookies ?? context.cookies,
95
+ locals: requestContext.locals ?? context.locals
96
+ };
97
+ }
package/src/server.js CHANGED
@@ -1,91 +1,7 @@
1
- import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
2
-
3
1
  const serverEnvelopeKeys = new Set(["value", "signals", "boundary", "html", "redirect", "error"]);
4
2
  const appliedServerResult = Symbol.for("@async/framework.appliedServerResult");
5
3
  const appliedServerValues = new WeakSet();
6
4
 
7
- export function createServerRegistry(initialMap = {}, options = {}) {
8
- const registryStore = options.registry ?? createRegistryStore();
9
- const type = options.type ?? "server";
10
- const entries = registryStore._map(type);
11
- const defaults = {};
12
-
13
- const registry = attachRegistryInspection({
14
- register(id, fn) {
15
- assertServerId(id);
16
- if (typeof fn !== "function") {
17
- throw new TypeError(`Server function "${id}" must be a function.`);
18
- }
19
- if (entries.has(id)) {
20
- throw new Error(`Server function "${id}" is already registered.`);
21
- }
22
- entries.set(id, fn);
23
- return id;
24
- },
25
-
26
- registerMany(map) {
27
- for (const [id, fn] of Object.entries(map ?? {})) {
28
- registry.register(id, fn);
29
- }
30
- return registry;
31
- },
32
-
33
- unregister(id) {
34
- assertServerId(id);
35
- return entries.delete(id);
36
- },
37
-
38
- resolve(id) {
39
- assertServerId(id);
40
- return entries.get(id);
41
- },
42
-
43
- async run(id, args = [], context = {}) {
44
- assertServerId(id);
45
- const fn = registry.resolve(id);
46
- if (!fn) {
47
- throw new Error(`Server function "${id}" is not registered.`);
48
- }
49
-
50
- let runContext;
51
- const server = createServerNamespace((childId, childArgs, childContext = {}) => {
52
- return registry.run(childId, childArgs, { ...runContext, ...childContext });
53
- }, {}, () => runContext);
54
-
55
- const mergedContext = {
56
- ...defaults,
57
- ...context,
58
- cache: defaults.cache ?? context.cache
59
- };
60
-
61
- runContext = {
62
- ...mergedContext,
63
- id,
64
- args,
65
- input: mergedContext.input,
66
- signals: createSignalReader(mergedContext.signals),
67
- abort: mergedContext.abort,
68
- cache: mergedContext.cache,
69
- server
70
- };
71
-
72
- return fn.call(runContext, ...args);
73
- },
74
-
75
- _setContext(context = {}) {
76
- Object.assign(defaults, context);
77
- return registry;
78
- },
79
-
80
- _adoptMany() {
81
- return registry;
82
- }
83
- }, registryStore, type);
84
-
85
- registry.registerMany(initialMap);
86
- return createServerNamespace((id, args, context) => registry.run(id, args, context), registry, () => defaults);
87
- }
88
-
89
5
  export function createServerProxy({
90
6
  endpoint = "/__async/server",
91
7
  fetch: fetchImpl = globalThis.fetch?.bind(globalThis),
@@ -93,13 +9,14 @@ export function createServerProxy({
93
9
  loader,
94
10
  router,
95
11
  cache,
12
+ scheduler,
96
13
  headers = {}
97
14
  } = {}) {
98
15
  if (typeof fetchImpl !== "function") {
99
16
  throw new TypeError("createServerProxy(...) requires fetch to be available.");
100
17
  }
101
18
 
102
- const defaults = { signals, loader, router, cache };
19
+ const defaults = { signals, loader, router, cache, scheduler };
103
20
 
104
21
  async function run(id, args = [], context = {}) {
105
22
  assertServerId(id);
@@ -229,7 +146,7 @@ export function defaultInput(context = {}) {
229
146
  };
230
147
  }
231
148
 
232
- function createServerNamespace(run, root = {}, contextProvider = () => ({})) {
149
+ export function createServerNamespace(run, root = {}, contextProvider = () => ({})) {
233
150
  const cache = new Map();
234
151
 
235
152
  function namespace(parts) {
@@ -311,7 +228,7 @@ function readSignal(signals, path) {
311
228
  return signals.get(path);
312
229
  }
313
230
 
314
- function createSignalReader(signals) {
231
+ export function createSignalReader(signals) {
315
232
  if (!signals || typeof signals.get === "function") {
316
233
  return signals;
317
234
  }
@@ -433,7 +350,7 @@ function toError(value) {
433
350
  return new Error(String(value));
434
351
  }
435
352
 
436
- function assertServerId(id) {
353
+ export function assertServerId(id) {
437
354
  if (typeof id !== "string" || id.length === 0) {
438
355
  throw new TypeError("Server function id must be a non-empty string.");
439
356
  }