@async/framework 0.1.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/signals.js ADDED
@@ -0,0 +1,483 @@
1
+ import { asyncSignal as createAsyncSignal, isAsyncSignal } from "./async-signal.js";
2
+
3
+ const signalKind = Symbol.for("@async/framework.signal");
4
+ const computedKind = Symbol.for("@async/framework.computed");
5
+ const effectKind = Symbol.for("@async/framework.effect");
6
+ const refKind = Symbol.for("@async/framework.signalRef");
7
+ const dependencyFrames = [];
8
+
9
+ export function createSignal(initial) {
10
+ let value = initial;
11
+ const subscribers = new Set();
12
+
13
+ return {
14
+ [signalKind]: true,
15
+ kind: "signal",
16
+
17
+ get value() {
18
+ return value;
19
+ },
20
+
21
+ set value(nextValue) {
22
+ this.set(nextValue);
23
+ },
24
+
25
+ set(nextValue) {
26
+ if (Object.is(value, nextValue)) {
27
+ return value;
28
+ }
29
+ value = nextValue;
30
+ notify();
31
+ return value;
32
+ },
33
+
34
+ update(fn) {
35
+ return this.set(fn(value));
36
+ },
37
+
38
+ subscribe(fn) {
39
+ if (typeof fn !== "function") {
40
+ throw new TypeError("subscribe(fn) requires a function.");
41
+ }
42
+ subscribers.add(fn);
43
+ return () => subscribers.delete(fn);
44
+ },
45
+
46
+ snapshot() {
47
+ return value;
48
+ }
49
+ };
50
+
51
+ function notify() {
52
+ for (const subscriber of [...subscribers]) {
53
+ subscriber(value);
54
+ }
55
+ }
56
+ }
57
+
58
+ export const signal = createSignal;
59
+
60
+ export function computed(fn) {
61
+ if (typeof fn !== "function") {
62
+ throw new TypeError("computed(fn) requires a function.");
63
+ }
64
+ const backing = createSignal(undefined);
65
+
66
+ return {
67
+ [computedKind]: true,
68
+ kind: "computed",
69
+
70
+ get value() {
71
+ return backing.value;
72
+ },
73
+
74
+ set(nextValue) {
75
+ return backing.set(nextValue);
76
+ },
77
+
78
+ update(fn) {
79
+ return backing.update(fn);
80
+ },
81
+
82
+ subscribe(fn) {
83
+ return backing.subscribe(fn);
84
+ },
85
+
86
+ snapshot() {
87
+ return backing.snapshot();
88
+ },
89
+
90
+ _bindRegistry(registry, id) {
91
+ return registry.effect(() => {
92
+ backing.set(fn.call({
93
+ signals: registry,
94
+ id,
95
+ server: registry._context?.().server,
96
+ router: registry._context?.().router,
97
+ loader: registry._context?.().loader,
98
+ cache: registry._context?.().cache
99
+ }));
100
+ });
101
+ }
102
+ };
103
+ }
104
+
105
+ export function effect(fn) {
106
+ if (typeof fn !== "function") {
107
+ throw new TypeError("effect(fn) requires a function.");
108
+ }
109
+ return {
110
+ [effectKind]: true,
111
+ kind: "effect",
112
+ fn,
113
+ _bindRegistry(registry) {
114
+ return registry.effect(fn);
115
+ }
116
+ };
117
+ }
118
+
119
+ export function createSignalRegistry(initialMap = {}) {
120
+ const entries = new Map();
121
+ const registryCleanups = new Set();
122
+ const runtimeContext = {};
123
+
124
+ const registry = {
125
+ register(id, signalLike) {
126
+ assertId(id);
127
+ if (entries.has(id)) {
128
+ throw new Error(`Signal "${id}" is already registered.`);
129
+ }
130
+ const entry = normalizeSignal(signalLike);
131
+ entries.set(id, entry);
132
+ if (typeof entry._bindRegistry === "function") {
133
+ const cleanup = entry._bindRegistry(registry, id);
134
+ if (typeof cleanup === "function") {
135
+ registryCleanups.add(cleanup);
136
+ }
137
+ }
138
+ return registry.ref(id);
139
+ },
140
+
141
+ registerMany(map) {
142
+ for (const [id, signalLike] of Object.entries(map ?? {})) {
143
+ registry.register(id, signalLike);
144
+ }
145
+ return registry;
146
+ },
147
+
148
+ ensure(id, initial) {
149
+ assertId(id);
150
+ if (!entries.has(id)) {
151
+ registry.register(id, createSignal(initial));
152
+ }
153
+ return registry.ref(id);
154
+ },
155
+
156
+ has(id) {
157
+ return entries.has(id);
158
+ },
159
+
160
+ get(path) {
161
+ const parsed = parsePath(path, entries);
162
+ track(parsed.path);
163
+ const entry = requireEntry(entries, parsed.id);
164
+ return readEntry(entry, parsed.parts);
165
+ },
166
+
167
+ set(path, value) {
168
+ const parsed = parsePath(path, entries);
169
+ const entry = requireEntry(entries, parsed.id);
170
+ if (parsed.parts.length === 0) {
171
+ return entry.set(value);
172
+ }
173
+ const nextValue = setPath(entry.value, parsed.parts, value);
174
+ entry.set(nextValue);
175
+ return value;
176
+ },
177
+
178
+ update(path, fn) {
179
+ if (typeof fn !== "function") {
180
+ throw new TypeError("update(path, fn) requires a function.");
181
+ }
182
+ return registry.set(path, fn(registry.get(path)));
183
+ },
184
+
185
+ ref(id) {
186
+ assertId(id);
187
+ return createRef(registry, id);
188
+ },
189
+
190
+ subscribe(path, fn) {
191
+ if (typeof fn !== "function") {
192
+ throw new TypeError("subscribe(path, fn) requires a function.");
193
+ }
194
+ const parsed = parsePath(path, entries);
195
+ const entry = requireEntry(entries, parsed.id);
196
+ return entry.subscribe(() => {
197
+ fn(registry.get(parsed.path), {
198
+ id: parsed.id,
199
+ path: parsed.path,
200
+ signal: entry
201
+ });
202
+ });
203
+ },
204
+
205
+ snapshot() {
206
+ const snapshot = {};
207
+ for (const [id, entry] of entries) {
208
+ snapshot[id] = typeof entry.snapshot === "function" ? entry.snapshot() : entry.value;
209
+ }
210
+ return snapshot;
211
+ },
212
+
213
+ asyncSignal(id, fn) {
214
+ registry.register(id, createAsyncSignal(id, fn));
215
+ return registry.ref(id);
216
+ },
217
+
218
+ effect(fn) {
219
+ let cleanup;
220
+ let dependencyCleanups = [];
221
+ let stopped = false;
222
+
223
+ const run = () => {
224
+ if (stopped) {
225
+ return;
226
+ }
227
+ if (typeof cleanup === "function") {
228
+ cleanup();
229
+ }
230
+ for (const stop of dependencyCleanups) {
231
+ stop();
232
+ }
233
+ dependencyCleanups = [];
234
+
235
+ const outcome = registry._collectDependencies(() => fn.call({
236
+ signals: registry,
237
+ server: runtimeContext.server,
238
+ router: runtimeContext.router,
239
+ loader: runtimeContext.loader,
240
+ cache: runtimeContext.cache
241
+ }));
242
+ cleanup = outcome.value;
243
+ dependencyCleanups = outcome.dependencies.map((dependency) => registry.subscribe(dependency, run));
244
+ };
245
+
246
+ run();
247
+
248
+ return () => {
249
+ stopped = true;
250
+ if (typeof cleanup === "function") {
251
+ cleanup();
252
+ }
253
+ for (const stop of dependencyCleanups) {
254
+ stop();
255
+ }
256
+ };
257
+ },
258
+
259
+ destroy() {
260
+ for (const cleanup of registryCleanups) {
261
+ cleanup();
262
+ }
263
+ registryCleanups.clear();
264
+ for (const entry of entries.values()) {
265
+ entry._dispose?.();
266
+ }
267
+ entries.clear();
268
+ },
269
+
270
+ _collectDependencies(fn) {
271
+ const frame = new Set();
272
+ dependencyFrames.push(frame);
273
+ try {
274
+ const value = fn();
275
+ return { value, dependencies: [...frame] };
276
+ } finally {
277
+ dependencyFrames.pop();
278
+ }
279
+ },
280
+
281
+ _entry(id) {
282
+ return requireEntry(entries, id);
283
+ },
284
+
285
+ _setContext(context = {}) {
286
+ Object.assign(runtimeContext, context);
287
+ return registry;
288
+ },
289
+
290
+ _context() {
291
+ return runtimeContext;
292
+ }
293
+ };
294
+
295
+ registry.registerMany(initialMap);
296
+ return registry;
297
+ }
298
+
299
+ function normalizeSignal(signalLike) {
300
+ if (isSignalLike(signalLike)) {
301
+ return signalLike;
302
+ }
303
+ return createSignal(signalLike);
304
+ }
305
+
306
+ function isSignalLike(value) {
307
+ return Boolean(value && typeof value === "object" && typeof value.subscribe === "function");
308
+ }
309
+
310
+ function createRef(registry, id) {
311
+ return {
312
+ [refKind]: true,
313
+ kind: "signal-ref",
314
+ id,
315
+
316
+ get value() {
317
+ return registry.get(id);
318
+ },
319
+
320
+ set value(nextValue) {
321
+ registry.set(id, nextValue);
322
+ },
323
+
324
+ get loading() {
325
+ return registry._entry(id).loading ?? false;
326
+ },
327
+
328
+ get error() {
329
+ return registry._entry(id).error ?? null;
330
+ },
331
+
332
+ get status() {
333
+ return registry._entry(id).status ?? "ready";
334
+ },
335
+
336
+ get version() {
337
+ return registry._entry(id).version ?? 0;
338
+ },
339
+
340
+ get() {
341
+ return registry.get(id);
342
+ },
343
+
344
+ set(nextValue) {
345
+ return registry.set(id, nextValue);
346
+ },
347
+
348
+ update(fn) {
349
+ return registry.update(id, fn);
350
+ },
351
+
352
+ subscribe(fn) {
353
+ return registry.subscribe(id, fn);
354
+ },
355
+
356
+ refresh() {
357
+ const entry = registry._entry(id);
358
+ if (typeof entry.refresh !== "function") {
359
+ throw new Error(`Signal "${id}" cannot refresh.`);
360
+ }
361
+ return entry.refresh();
362
+ },
363
+
364
+ cancel(reason) {
365
+ const entry = registry._entry(id);
366
+ if (typeof entry.cancel !== "function") {
367
+ throw new Error(`Signal "${id}" cannot cancel.`);
368
+ }
369
+ return entry.cancel(reason);
370
+ },
371
+
372
+ toString() {
373
+ return id;
374
+ },
375
+
376
+ [Symbol.toPrimitive]() {
377
+ return id;
378
+ }
379
+ };
380
+ }
381
+
382
+ export function isSignalRef(value) {
383
+ return Boolean(value?.[refKind]);
384
+ }
385
+
386
+ function parsePath(path, entries) {
387
+ if (typeof path !== "string" || path.length === 0) {
388
+ throw new TypeError("Signal path must be a non-empty string.");
389
+ }
390
+ const segments = path.split(".");
391
+ for (let end = segments.length; end > 0; end -= 1) {
392
+ const id = segments.slice(0, end).join(".");
393
+ if (entries.has(id)) {
394
+ return { id, parts: segments.slice(end), path };
395
+ }
396
+ }
397
+ const [id, ...parts] = segments;
398
+ return { id, parts, path };
399
+ }
400
+
401
+ function readEntry(entry, parts) {
402
+ if (isAsyncSignal(entry) && parts[0]?.startsWith("$")) {
403
+ const metadata = readAsyncMetadata(entry, parts[0]);
404
+ return readPath(metadata, parts.slice(1));
405
+ }
406
+ return readPath(entry.value, parts);
407
+ }
408
+
409
+ function readAsyncMetadata(entry, part) {
410
+ switch (part) {
411
+ case "$value":
412
+ return entry.value;
413
+ case "$loading":
414
+ return entry.loading;
415
+ case "$error":
416
+ return entry.error;
417
+ case "$status":
418
+ return entry.status;
419
+ case "$version":
420
+ return entry.version;
421
+ default:
422
+ return undefined;
423
+ }
424
+ }
425
+
426
+ function readPath(value, parts) {
427
+ let cursor = value;
428
+ for (const part of parts) {
429
+ if (cursor == null) {
430
+ return undefined;
431
+ }
432
+ cursor = cursor[part];
433
+ }
434
+ return cursor;
435
+ }
436
+
437
+ function setPath(value, parts, nextValue) {
438
+ const root = cloneContainer(value, parts[0]);
439
+ let cursor = root;
440
+ for (let index = 0; index < parts.length - 1; index += 1) {
441
+ const part = parts[index];
442
+ const nextPart = parts[index + 1];
443
+ cursor[part] = cloneContainer(cursor[part], nextPart);
444
+ cursor = cursor[part];
445
+ }
446
+ cursor[parts.at(-1)] = nextValue;
447
+ return root;
448
+ }
449
+
450
+ function cloneContainer(value, nextPart) {
451
+ if (Array.isArray(value)) {
452
+ return [...value];
453
+ }
454
+ if (value && typeof value === "object") {
455
+ return { ...value };
456
+ }
457
+ return isArrayIndex(nextPart) ? [] : {};
458
+ }
459
+
460
+ function isArrayIndex(part) {
461
+ return String(Number(part)) === String(part);
462
+ }
463
+
464
+ function requireEntry(entries, id) {
465
+ const entry = entries.get(id);
466
+ if (!entry) {
467
+ throw new Error(`Signal "${id}" is not registered.`);
468
+ }
469
+ return entry;
470
+ }
471
+
472
+ function assertId(id) {
473
+ if (typeof id !== "string" || id.length === 0) {
474
+ throw new TypeError("Signal id must be a non-empty string.");
475
+ }
476
+ }
477
+
478
+ function track(path) {
479
+ const frame = dependencyFrames.at(-1);
480
+ if (frame) {
481
+ frame.add(path);
482
+ }
483
+ }