@async/framework 0.6.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.
@@ -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,88 +1,6 @@
1
- import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
2
-
3
1
  const serverEnvelopeKeys = new Set(["value", "signals", "boundary", "html", "redirect", "error"]);
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 = {
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
- }
2
+ const appliedServerResult = Symbol.for("@async/framework.appliedServerResult");
3
+ const appliedServerValues = new WeakSet();
86
4
 
87
5
  export function createServerProxy({
88
6
  endpoint = "/__async/server",
@@ -91,13 +9,14 @@ export function createServerProxy({
91
9
  loader,
92
10
  router,
93
11
  cache,
12
+ scheduler,
94
13
  headers = {}
95
14
  } = {}) {
96
15
  if (typeof fetchImpl !== "function") {
97
16
  throw new TypeError("createServerProxy(...) requires fetch to be available.");
98
17
  }
99
18
 
100
- const defaults = { signals, loader, router, cache };
19
+ const defaults = { signals, loader, router, cache, scheduler };
101
20
 
102
21
  async function run(id, args = [], context = {}) {
103
22
  assertServerId(id);
@@ -107,6 +26,7 @@ export function createServerProxy({
107
26
  input: context.input ?? defaultInput(runContext),
108
27
  signals: context.signalValues ?? snapshotSignalPaths(context.signalPaths, runContext.signals)
109
28
  };
29
+ assertJsonTransportable(body);
110
30
 
111
31
  const response = await fetchImpl(joinEndpoint(endpoint, id), {
112
32
  method: "POST",
@@ -124,7 +44,7 @@ export function createServerProxy({
124
44
 
125
45
  const result = await readServerResponse(response);
126
46
  await applyServerResult(result, runContext);
127
- return unwrapServerResult(result);
47
+ return markAppliedServerValue(unwrapServerResult(result));
128
48
  }
129
49
 
130
50
  return createServerNamespace(run, {
@@ -159,6 +79,9 @@ export async function applyServerResult(result, context = {}) {
159
79
  if (!isServerEnvelope(result)) {
160
80
  return result;
161
81
  }
82
+ if (result[appliedServerResult] || appliedServerValues.has(result)) {
83
+ return result;
84
+ }
162
85
 
163
86
  if (result.signals && context.signals) {
164
87
  for (const [path, value] of Object.entries(result.signals)) {
@@ -182,6 +105,12 @@ export async function applyServerResult(result, context = {}) {
182
105
  throw toError(result.error);
183
106
  }
184
107
 
108
+ Object.defineProperty(result, appliedServerResult, {
109
+ configurable: true,
110
+ enumerable: false,
111
+ value: true
112
+ });
113
+
185
114
  return result;
186
115
  }
187
116
 
@@ -192,6 +121,13 @@ export function unwrapServerResult(result) {
192
121
  return result;
193
122
  }
194
123
 
124
+ function markAppliedServerValue(value) {
125
+ if (value && typeof value === "object") {
126
+ appliedServerValues.add(value);
127
+ }
128
+ return value;
129
+ }
130
+
195
131
  export function defaultInput(context = {}) {
196
132
  const form = findForm(context);
197
133
  if (form) {
@@ -210,7 +146,7 @@ export function defaultInput(context = {}) {
210
146
  };
211
147
  }
212
148
 
213
- function createServerNamespace(run, root = {}, contextProvider = () => ({})) {
149
+ export function createServerNamespace(run, root = {}, contextProvider = () => ({})) {
214
150
  const cache = new Map();
215
151
 
216
152
  function namespace(parts) {
@@ -292,7 +228,7 @@ function readSignal(signals, path) {
292
228
  return signals.get(path);
293
229
  }
294
230
 
295
- function createSignalReader(signals) {
231
+ export function createSignalReader(signals) {
296
232
  if (!signals || typeof signals.get === "function") {
297
233
  return signals;
298
234
  }
@@ -369,6 +305,30 @@ function formDataToObject(formData) {
369
305
  return output;
370
306
  }
371
307
 
308
+ function assertJsonTransportable(value, seen = new Set()) {
309
+ if (value == null || typeof value !== "object") {
310
+ return;
311
+ }
312
+ if (seen.has(value)) {
313
+ return;
314
+ }
315
+ seen.add(value);
316
+
317
+ const tag = Object.prototype.toString.call(value);
318
+ if (tag === "[object File]" || tag === "[object Blob]" || tag === "[object FormData]") {
319
+ throw new Error("Server proxy JSON transport does not support File, Blob, or FormData values yet.");
320
+ }
321
+ if (Array.isArray(value)) {
322
+ for (const item of value) {
323
+ assertJsonTransportable(item, seen);
324
+ }
325
+ return;
326
+ }
327
+ for (const item of Object.values(value)) {
328
+ assertJsonTransportable(item, seen);
329
+ }
330
+ }
331
+
372
332
  function joinEndpoint(endpoint, id) {
373
333
  return `${String(endpoint).replace(/\/$/, "")}/${encodeURIComponent(id)}`;
374
334
  }
@@ -390,7 +350,7 @@ function toError(value) {
390
350
  return new Error(String(value));
391
351
  }
392
352
 
393
- function assertServerId(id) {
353
+ export function assertServerId(id) {
394
354
  if (typeof id !== "string" || id.length === 0) {
395
355
  throw new TypeError("Server function id must be a non-empty string.");
396
356
  }