@abide/abide 0.53.0 → 0.54.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/AGENTS.md +9 -5
- package/CHANGELOG.md +57 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/checkAbide.ts +1 -1
- package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +26 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/defineSocket.ts +11 -1
- package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
- package/src/lib/shared/SOCKET_SEED.ts +13 -0
- package/src/lib/shared/amend.ts +48 -0
- package/src/lib/shared/amendBroadcastSlot.ts +19 -0
- package/src/lib/shared/applyAmendLocally.ts +20 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
- package/src/lib/shared/buildSocketOverChannel.ts +9 -2
- package/src/lib/shared/cache.ts +35 -10
- package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
- package/src/lib/shared/createCacheStore.ts +8 -0
- package/src/lib/shared/peek.ts +1 -1
- package/src/lib/shared/socketTailsSlot.ts +13 -0
- package/src/lib/shared/types/AmendApply.ts +15 -0
- package/src/lib/shared/types/CacheReaderHook.ts +11 -0
- package/src/lib/shared/types/RemoteFunction.ts +30 -19
- package/src/lib/shared/types/SocketTails.ts +13 -0
- package/src/lib/shared/types/SsrPayload.ts +6 -0
- package/src/lib/test/createTestApp.ts +6 -0
- package/src/lib/ui/amendReaderHook.ts +125 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +38 -0
- package/src/lib/ui/compile/compileComponent.ts +9 -2
- package/src/lib/ui/compile/desugarSignals.ts +68 -3
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +18 -24
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- package/src/lib/ui/dom/awaitBlock.ts +1 -1
- package/src/lib/ui/dom/tryBlock.ts +1 -1
- package/src/lib/ui/router.ts +8 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- package/src/lib/ui/runtime/restoreWarmSeeds.ts +20 -0
- package/src/lib/ui/runtime/types/Cell.ts +4 -2
- package/src/lib/ui/runtime/types/Doc.ts +3 -1
- package/src/lib/ui/runtime/warmSeedBackup.ts +10 -0
- package/src/lib/ui/socketProxy.ts +17 -1
- package/src/lib/ui/startClient.ts +30 -0
- package/src/lib/ui/types/Scope.ts +1 -1
- package/src/lib/ui/watch.ts +18 -5
- package/src/serverEntry.ts +15 -0
- package/src/lib/shared/patch.ts +0 -41
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
package/src/lib/shared/cache.ts
CHANGED
|
@@ -920,26 +920,27 @@ cached list). The next value is stored onto `entry.value`, which the read path
|
|
|
920
920
|
serves warm (cloned per read), so it persists across reads until a refresh /
|
|
921
921
|
invalidate replaces the entry. `current` comes from `entry.value` when already
|
|
922
922
|
materialized, else decoded from the settled/in-flight promise (async — readers
|
|
923
|
-
re-render when the
|
|
924
|
-
/ tags select a value to mutate; a not-yet-read key has nothing to
|
|
923
|
+
re-render when the amend lands). Follows the selector grammar, but only fn / args
|
|
924
|
+
/ tags select a value to mutate; a not-yet-read key has nothing to amend.
|
|
925
925
|
*/
|
|
926
|
-
function
|
|
926
|
+
function amend<Args, Return>(
|
|
927
927
|
arg: CacheSelector<Args, Return>,
|
|
928
928
|
args: Args | undefined,
|
|
929
929
|
updater: (current: Return) => Return,
|
|
930
|
+
isValue: boolean,
|
|
930
931
|
): void {
|
|
931
932
|
const prefix = selectorPrefix(arg, args)
|
|
932
933
|
const matches = selectorMatcher(arg, args, prefix)
|
|
933
934
|
for (const store of cacheStores()) {
|
|
934
935
|
for (const entry of store.entries.values()) {
|
|
935
936
|
if (matches(entry)) {
|
|
936
|
-
|
|
937
|
+
applyAmend(store, entry, updater as (current: unknown) => unknown, isValue)
|
|
937
938
|
}
|
|
938
939
|
}
|
|
939
940
|
}
|
|
940
941
|
}
|
|
941
942
|
|
|
942
|
-
cache.
|
|
943
|
+
cache.amend = amend
|
|
943
944
|
|
|
944
945
|
/*
|
|
945
946
|
Synchronous, non-triggering value probe: returns the currently-retained value for
|
|
@@ -947,7 +948,7 @@ a call, or undefined when nothing is retained (no entry, or not yet settled). Ne
|
|
|
947
948
|
invokes — reading it opens no fetch. Reactive: it subscribes the key AND taps the
|
|
948
949
|
key's lifecycle channel (the exact key is a one-entry prefix, same as
|
|
949
950
|
pending(fn, args)) so a state.computed / on / template scope re-runs both when the
|
|
950
|
-
value changes (invalidate,
|
|
951
|
+
value changes (invalidate, amend) and when a retained value lands —
|
|
951
952
|
materializeRetained's async decode signals only markLifecycle, no invalidate event,
|
|
952
953
|
so without the lifecycle tap a scope that read undefined would never see the value
|
|
953
954
|
arrive. Outside a tracking scope it is a one-shot snapshot. Returns a clone (like
|
|
@@ -1003,21 +1004,26 @@ function peek<Args, Return>(
|
|
|
1003
1004
|
cache.peek = peek
|
|
1004
1005
|
|
|
1005
1006
|
/*
|
|
1006
|
-
Applies one entry's
|
|
1007
|
+
Applies one entry's amend: materialize the current decoded value (warm value if
|
|
1007
1008
|
present, else decode the promise — a Response for a remote entry, cloned so the
|
|
1008
1009
|
readers' own clones still succeed), run the updater, store it warm, and emit so
|
|
1009
|
-
readers re-read. Fire-and-forget on the async branch:
|
|
1010
|
+
readers re-read. Fire-and-forget on the async branch: amend() stays sync-return.
|
|
1010
1011
|
*/
|
|
1011
|
-
function
|
|
1012
|
+
function applyAmend(
|
|
1012
1013
|
store: CacheStore,
|
|
1013
1014
|
entry: CacheEntry,
|
|
1014
1015
|
updater: (current: unknown) => unknown,
|
|
1016
|
+
isValue: boolean,
|
|
1015
1017
|
): void {
|
|
1016
1018
|
function apply(current: unknown): void {
|
|
1017
1019
|
entry.value = updater(current)
|
|
1018
1020
|
notify(store, [entry.key], [entry.key])
|
|
1019
1021
|
}
|
|
1020
|
-
|
|
1022
|
+
/* A value-form amend is current-independent (its updater ignores the argument), so set it
|
|
1023
|
+
directly and synchronously like the wire path (amendByKey) — never defer to the in-flight
|
|
1024
|
+
promise. Deferring would drop the value if that fetch rejects (the reject handler below
|
|
1025
|
+
swallows it), diverging local value-amend from the broadcast receive path. */
|
|
1026
|
+
if (isValue || entry.value !== undefined) {
|
|
1021
1027
|
apply(entry.value)
|
|
1022
1028
|
return
|
|
1023
1029
|
}
|
|
@@ -1030,6 +1036,25 @@ function applyPatch(
|
|
|
1030
1036
|
currentValue.then(apply, () => undefined)
|
|
1031
1037
|
}
|
|
1032
1038
|
|
|
1039
|
+
/*
|
|
1040
|
+
Wire-driven value apply (ADR-0043): set the retained value of the entry at an exact
|
|
1041
|
+
cache key and re-render its readers — the receive half of a server amend broadcast. A
|
|
1042
|
+
direct set, not the applyAmend materialize-current dance: the frame carries the
|
|
1043
|
+
authoritative value now, and the receiving subscriber is by construction reading this
|
|
1044
|
+
key. A key with no local entry is a no-op (nothing to amend).
|
|
1045
|
+
*/
|
|
1046
|
+
function amendByKey(key: string, value: unknown): void {
|
|
1047
|
+
for (const store of cacheStores()) {
|
|
1048
|
+
const entry = store.entries.get(key)
|
|
1049
|
+
if (entry !== undefined) {
|
|
1050
|
+
entry.value = value
|
|
1051
|
+
notify(store, [entry.key], [entry.key])
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
cache.amendByKey = amendByKey
|
|
1057
|
+
|
|
1033
1058
|
/*
|
|
1034
1059
|
Event-driven cache maintenance: subscribes to a NamedAsyncIterable (socket or rpc
|
|
1035
1060
|
stream) and runs `handler` once per frame — the declarative home for "this
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createResolverSlot } from './createResolverSlot.ts'
|
|
2
|
+
import type { CacheReaderHook } from './types/CacheReaderHook.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Client-only seam for the reactive-reader lifecycle (ADR-0043). No fallback: unresolved,
|
|
6
|
+
get() is undefined and the store's engage/disengage calls are inert — so the server's
|
|
7
|
+
request store and isolated unit tests do nothing. The client entry (startClient) installs
|
|
8
|
+
a hook that opens a per-key amend value subscription on a key's first reader and closes it
|
|
9
|
+
on its last, keeping the subscription set congruent with what this tab is reading.
|
|
10
|
+
*/
|
|
11
|
+
export const cacheReaderSocketSlot = createResolverSlot<CacheReaderHook>()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { REACTIVE_CONTEXT } from '../ui/runtime/REACTIVE_CONTEXT.ts'
|
|
2
|
+
import { cacheReaderSocketSlot } from './cacheReaderSocketSlot.ts'
|
|
2
3
|
import { createLifecycleChannel } from './createLifecycleChannel.ts'
|
|
3
4
|
import { createSubscriber } from './createSubscriber.ts'
|
|
4
5
|
import { keyMatchesPrefix } from './keyMatchesPrefix.ts'
|
|
@@ -62,10 +63,17 @@ export function createCacheStore(): CacheStore {
|
|
|
62
63
|
Drop the marker so the tab-scoped store can't accrete one per
|
|
63
64
|
invalidated-but-never-reread key over a session. */
|
|
64
65
|
pendingRefresh.delete(key)
|
|
66
|
+
/* Last reader gone: close any per-key amend subscription opened on engage
|
|
67
|
+
(ADR-0043). Inert unless the client entry installed the hook. */
|
|
68
|
+
cacheReaderSocketSlot.get()?.disengage(key)
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
})
|
|
68
72
|
subscribers.set(key, registered)
|
|
73
|
+
/* First reader for this key: open a per-key amend value subscription (ADR-0043), so a
|
|
74
|
+
server amend(args, value) push lands on a key this tab is actually reading. Inert on
|
|
75
|
+
the server / in unit tests (slot unresolved). Paired with disengage in the cleanup. */
|
|
76
|
+
cacheReaderSocketSlot.get()?.engage(key)
|
|
69
77
|
registered()
|
|
70
78
|
}
|
|
71
79
|
|
package/src/lib/shared/peek.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Socket } from './types/Socket.ts'
|
|
|
8
8
|
The value member of the probe family: the currently-retained value, synchronously,
|
|
9
9
|
without triggering anything — `T | undefined` (undefined when nothing is retained
|
|
10
10
|
yet). For a cached read it is the retained cache value (reactive in a tracking
|
|
11
|
-
scope — re-runs when a refresh lands or
|
|
11
|
+
scope — re-runs when a refresh lands or an amend mutates it; a one-shot snapshot
|
|
12
12
|
otherwise). For a subscribable (socket / stream) it is the latest frame, read off
|
|
13
13
|
the source's own `.peek()`. Instance sugar `getFoo.peek(args?)` ≡ `peek(getFoo,
|
|
14
14
|
args?)`, `socket.peek()` ≡ `peek(socket)`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createResolverSlot } from './createResolverSlot.ts'
|
|
2
|
+
import type { SocketTails } from './types/SocketTails.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The active socket-tail slot — mirrors `resolvedCellsSlot`, but deliberately WITHOUT a lazily-created
|
|
6
|
+
shared fallback. `defineSocket`'s server `peek()` records into it on every retained-frame read, and a
|
|
7
|
+
socket peeked outside a request (a ws message handler, boot, cron) has no request store — a shared
|
|
8
|
+
fallback would then accumulate entries no page render ever drains, a slow leak. With no fallback,
|
|
9
|
+
`get()` returns undefined off-request and the record is skipped. During an SSR render the server
|
|
10
|
+
entry's resolver returns the request store's list, which `createUiPageRenderer` drains into
|
|
11
|
+
`__SSR__.sockets`.
|
|
12
|
+
*/
|
|
13
|
+
export const socketTailsSlot = createResolverSlot<SocketTails>()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CacheSelector } from './CacheSelector.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The side-swappable applier stored in amendBroadcastSlot (ADR-0043). Given a selector,
|
|
5
|
+
its args, whether the payload is a concrete value (vs. an updater closure), and the
|
|
6
|
+
payload itself, either apply it to this side's store (client / fallback) or broadcast
|
|
7
|
+
the keyed value to every authorized reader (server). Only the value form is
|
|
8
|
+
broadcastable; the server resolver throws on an updater (a closure has no wire form).
|
|
9
|
+
*/
|
|
10
|
+
export type AmendApply = <Args, Return>(
|
|
11
|
+
selector: CacheSelector<Args, Return>,
|
|
12
|
+
args: Args | undefined,
|
|
13
|
+
isValue: boolean,
|
|
14
|
+
payload: Return | ((current: Return) => Return),
|
|
15
|
+
) => void
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The client-installed reactive-reader lifecycle hook stored in cacheReaderSocketSlot
|
|
3
|
+
(ADR-0043). The cache store calls engage(key) when a key gains its first live reader
|
|
4
|
+
and disengage(key) when it loses its last, so the client can open/close a per-key
|
|
5
|
+
amend value subscription whose lifetime tracks "a reader is holding this key on
|
|
6
|
+
screen". Inert (slot unresolved) on the server and in isolated unit tests.
|
|
7
|
+
*/
|
|
8
|
+
export interface CacheReaderHook {
|
|
9
|
+
engage(key: string): void
|
|
10
|
+
disengage(key: string): void
|
|
11
|
+
}
|
|
@@ -71,7 +71,7 @@ export type RemoteFunction<
|
|
|
71
71
|
reloads lazily, while `refresh(args?)` REFETCHES now keeping the stale value visible — both
|
|
72
72
|
apply locally on the client and broadcast to every connected client from the server.
|
|
73
73
|
`peek(args?)` reads the retained value synchronously (a streaming rpc peeks its latest
|
|
74
|
-
frame). `
|
|
74
|
+
frame). `amend` is fetch-only, so it is omitted for a streaming rpc (a stream isn't a
|
|
75
75
|
memoized value) via the intersection below. */
|
|
76
76
|
pending(args?: Args): boolean
|
|
77
77
|
refreshing(args?: Args): boolean
|
|
@@ -84,22 +84,33 @@ export type RemoteFunction<
|
|
|
84
84
|
shadow the server `error()` thrower). Reads the rpc error registry captured at the call
|
|
85
85
|
boundary; the cache is untouched. */
|
|
86
86
|
error(args?: Args): RpcError<Errors> | undefined
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
} /* `patch` is fetch-only: a streaming rpc has no single memoized value to mutate, so the
|
|
96
|
-
method is present only when `Return` is not an AsyncIterable. Two signatures: with args
|
|
97
|
-
(`patch(args, updater)`) and without (`patch(updater)` — every args-variant). Tuple-wrapped
|
|
98
|
-
so the conditional doesn't distribute over a `never` Return (an error-only rpc). */ & ([
|
|
99
|
-
Return,
|
|
100
|
-
] extends [AsyncIterable<unknown>]
|
|
101
|
-
? Record<never, never>
|
|
87
|
+
} /* Client-only reaction sugar: `fn.watch(handler)` / `fn.watch(args, handler)` ≡ `watch(fn, …)`
|
|
88
|
+
— runs the smart read reactively and pipes each resolved value to the handler, returning a
|
|
89
|
+
scope-tied disposer. Inert no-op server-side (reaction is a ui concern; the real method is
|
|
90
|
+
attached client-side by remoteProxy). The keyed overload collapses away for a no-input rpc
|
|
91
|
+
(`undefined extends Args`) — there is no key to scope to (ADR-0043 §5, type-only). */ & (undefined extends Args
|
|
92
|
+
? {
|
|
93
|
+
watch(handler: (value: Return) => void): () => void
|
|
94
|
+
}
|
|
102
95
|
: {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
})
|
|
96
|
+
watch(handler: (value: Return) => void): () => void
|
|
97
|
+
watch(args: Args, handler: (value: Return) => void): () => void
|
|
98
|
+
}) /* `amend` is fetch-only: a streaming rpc has no single memoized value to mutate, so the
|
|
99
|
+
method is present only when `Return` is not an AsyncIterable. The last argument is a
|
|
100
|
+
replacement `Return` value (broadcastable — ADR-0043) or an updater `(current) => Return`
|
|
101
|
+
(a local closure). For a keyed rpc the value/updater takes the args key first; the bare
|
|
102
|
+
`amend(updater)` covers every args-variant (local-only). For a no-input rpc the key
|
|
103
|
+
collapses away (`amend(value)` / `amend(updater)`). Tuple-wrapped so the conditional
|
|
104
|
+
doesn't distribute over a `never` Return (an error-only rpc). */ &
|
|
105
|
+
([Return] extends [AsyncIterable<unknown>]
|
|
106
|
+
? Record<never, never>
|
|
107
|
+
: undefined extends Args
|
|
108
|
+
? {
|
|
109
|
+
amend(value: Return): void
|
|
110
|
+
amend(updater: (current: Return) => Return): void
|
|
111
|
+
}
|
|
112
|
+
: {
|
|
113
|
+
amend(args: Args, value: Return): void
|
|
114
|
+
amend(args: Args, updater: (current: Return) => Return): void
|
|
115
|
+
amend(updater: (current: Return) => Return): void
|
|
116
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The per-request list of retained socket frames read via `peek(socket)` during an SSR render, each
|
|
3
|
+
keyed by the socket's name. `defineSocket`'s server `peek()` pushes an entry when a retained frame is
|
|
4
|
+
read server-side; the page renderer stamps them into `__SSR__.sockets` (ref-json-encoded, last write
|
|
5
|
+
per name winning) so the client seeds the socket's latest frame WARM — a not-yet-connected client's
|
|
6
|
+
`peek(socket)` then returns the same retained value the SSR render committed to instead of `undefined`,
|
|
7
|
+
which would otherwise diverge from the server HTML and trip a hydration desync. Sibling of
|
|
8
|
+
`ResolvedCells` — sockets carry a server value forward like cells, not withhold on the client like
|
|
9
|
+
cache (whose server peek is uniformly undefined).
|
|
10
|
+
*/
|
|
11
|
+
export type SocketTails = {
|
|
12
|
+
entries: { name: string; value: unknown }[]
|
|
13
|
+
}
|
|
@@ -27,4 +27,10 @@ export type SsrPayload = SsrBootState & {
|
|
|
27
27
|
write to each slot adopts the server value, so a plain `state(uuid())`/`state(Date.now())`
|
|
28
28
|
keeps the SSR value instead of recomputing a divergent one. */
|
|
29
29
|
docs?: Record<string, string>
|
|
30
|
+
/* Retained socket frames read via `peek(socket)` during SSR, keyed by socket name → ref-json
|
|
31
|
+
string. `startClient` seeds these into `SOCKET_SEED`; a hydrating `socketProxy` adopts the frame
|
|
32
|
+
as its `lastFrame` so `peek(socket)` returns the SAME retained value the server rendered instead
|
|
33
|
+
of `undefined` on the not-yet-connected client — otherwise the two disagree and hydration
|
|
34
|
+
discards the server markup. */
|
|
35
|
+
sockets?: Record<string, string>
|
|
30
36
|
}
|
|
@@ -47,6 +47,7 @@ import { pendingAsyncCellsSlot } from '../shared/pendingAsyncCellsSlot.ts'
|
|
|
47
47
|
import { resolvedCellsSlot } from '../shared/resolvedCellsSlot.ts'
|
|
48
48
|
import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
|
|
49
49
|
import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
|
|
50
|
+
import { socketTailsSlot } from '../shared/socketTailsSlot.ts'
|
|
50
51
|
import { streamedCellsSlot } from '../shared/streamedCellsSlot.ts'
|
|
51
52
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
52
53
|
import type { Socket } from '../shared/types/Socket.ts'
|
|
@@ -111,6 +112,7 @@ export async function createTestApp(): Promise<TestApp> {
|
|
|
111
112
|
pendingAsyncCellsResolver: pendingAsyncCellsSlot.resolver,
|
|
112
113
|
resolvedCellsResolver: resolvedCellsSlot.resolver,
|
|
113
114
|
streamedCellsResolver: streamedCellsSlot.resolver,
|
|
115
|
+
socketTailsResolver: socketTailsSlot.resolver,
|
|
114
116
|
docSnapshotsResolver: docSnapshotsSlot.resolver,
|
|
115
117
|
activeServer: serverSlot.active,
|
|
116
118
|
pageRender: pageRenderSlot.render,
|
|
@@ -129,6 +131,9 @@ export async function createTestApp(): Promise<TestApp> {
|
|
|
129
131
|
const sharedStreamedCells = { entries: [] }
|
|
130
132
|
streamedCellsSlot.resolver = () =>
|
|
131
133
|
requestContext.getStore()?.streamedCells ?? sharedStreamedCells
|
|
134
|
+
/* No shared fallback — mirrors serverEntry: `defineSocket.peek` records off-request too, so a
|
|
135
|
+
fallback would leak. Off-request this resolves undefined and the record is skipped. */
|
|
136
|
+
socketTailsSlot.resolver = () => requestContext.getStore()?.socketTails
|
|
132
137
|
const sharedDocSnapshots = { entries: [] }
|
|
133
138
|
docSnapshotsSlot.resolver = () => requestContext.getStore()?.docSnapshots ?? sharedDocSnapshots
|
|
134
139
|
pageSlot.resolver = resolvePageSnapshot
|
|
@@ -218,6 +223,7 @@ export async function createTestApp(): Promise<TestApp> {
|
|
|
218
223
|
pendingAsyncCellsSlot.resolver = previous.pendingAsyncCellsResolver
|
|
219
224
|
resolvedCellsSlot.resolver = previous.resolvedCellsResolver
|
|
220
225
|
streamedCellsSlot.resolver = previous.streamedCellsResolver
|
|
226
|
+
socketTailsSlot.resolver = previous.socketTailsResolver
|
|
221
227
|
docSnapshotsSlot.resolver = previous.docSnapshotsResolver
|
|
222
228
|
serverSlot.active = previous.activeServer
|
|
223
229
|
pageRenderSlot.render = previous.pageRender
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { AMEND_TOPIC_PREFIX } from '../shared/AMEND_TOPIC_PREFIX.ts'
|
|
2
|
+
import { abideLog } from '../shared/abideLog.ts'
|
|
3
|
+
import { cache } from '../shared/cache.ts'
|
|
4
|
+
import { SocketDisconnectedError } from '../shared/SocketDisconnectedError.ts'
|
|
5
|
+
import type { CacheReaderHook } from '../shared/types/CacheReaderHook.ts'
|
|
6
|
+
import type { Socket } from '../shared/types/Socket.ts'
|
|
7
|
+
import { socketProxy } from './socketProxy.ts'
|
|
8
|
+
|
|
9
|
+
/* A cache key is a broadcastable amend target only when it carries cross-client wire
|
|
10
|
+
identity — a remote call key (`METHOD /url…`, per keyForRemoteCall). Producer keys
|
|
11
|
+
(per-process reference ids) and anything else can't be pushed across clients, so we
|
|
12
|
+
never open a subscription for them. */
|
|
13
|
+
const REMOTE_KEY = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD) \//
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
The client-only reactive-reader lifecycle hook (ADR-0043) installed into
|
|
17
|
+
cacheReaderSocketSlot by startClient. When a remote key gains its first live reader it
|
|
18
|
+
opens a subscription to that call's reserved amend topic and folds each pushed value
|
|
19
|
+
into the cache; when the key loses its last reader it closes the subscription — so the
|
|
20
|
+
set of subscribed topics stays congruent with the set of keys this tab is reading (and
|
|
21
|
+
therefore already authorized for). Refcounted per key so co-readers (or the active/shared
|
|
22
|
+
store both reporting a reader) share one subscription.
|
|
23
|
+
|
|
24
|
+
The channel is live-only (no tail): a value pushed while this tab was disconnected is
|
|
25
|
+
missed, so on a transport loss the hook re-opens a fresh live iterator AND refreshes the
|
|
26
|
+
key — the ADR-0043 convergence rule, reconciling against authoritative server state
|
|
27
|
+
rather than leaving a silently-stale value. `openSocket` is injectable so a test can drive
|
|
28
|
+
frames over its own channel without a live ws.
|
|
29
|
+
*/
|
|
30
|
+
export function createAmendReaderHook(
|
|
31
|
+
openSocket: (name: string) => Socket<unknown> = socketProxy,
|
|
32
|
+
): { hook: CacheReaderHook; dispose: () => void } {
|
|
33
|
+
const open = new Map<string, { readers: number; dispose: () => void }>()
|
|
34
|
+
|
|
35
|
+
function subscribe(key: string): () => void {
|
|
36
|
+
const socket = openSocket(AMEND_TOPIC_PREFIX + key)
|
|
37
|
+
const controller = new AbortController()
|
|
38
|
+
/* Opening the shared ws channel must never propagate into the cache read that engaged
|
|
39
|
+
this key — a read can't fail because an optional real-time subscription couldn't open
|
|
40
|
+
(e.g. no ws transport). Log and give up on this key's push channel; SWR still holds. */
|
|
41
|
+
let iterator: AsyncIterator<unknown>
|
|
42
|
+
try {
|
|
43
|
+
iterator = socket[Symbol.asyncIterator]()
|
|
44
|
+
} catch (error) {
|
|
45
|
+
abideLog.error(error)
|
|
46
|
+
return () => undefined
|
|
47
|
+
}
|
|
48
|
+
;(async () => {
|
|
49
|
+
while (!controller.signal.aborted) {
|
|
50
|
+
let next: IteratorResult<unknown>
|
|
51
|
+
try {
|
|
52
|
+
next = await iterator.next()
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (controller.signal.aborted) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
/* Transport loss: reconcile the possibly-missed value against server truth,
|
|
58
|
+
then re-open a fresh LIVE subscription (no replay) and keep going. */
|
|
59
|
+
if (error instanceof SocketDisconnectedError) {
|
|
60
|
+
cache.refreshMatching((entry) => entry.key === key, key, undefined)
|
|
61
|
+
try {
|
|
62
|
+
iterator = socket[Symbol.asyncIterator]()
|
|
63
|
+
} catch (reopenError) {
|
|
64
|
+
abideLog.error(reopenError)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
abideLog.error(error)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (controller.signal.aborted || next.done === true) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
/* One malformed frame must not tear down the live pipe. Frames come only from the
|
|
76
|
+
trusted server (clientPublish is false), so this is defense in depth. */
|
|
77
|
+
try {
|
|
78
|
+
cache.amendByKey(key, next.value)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
abideLog.error(error)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
})()
|
|
84
|
+
return () => {
|
|
85
|
+
controller.abort()
|
|
86
|
+
iterator.return?.(undefined)?.catch(() => undefined)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hook: CacheReaderHook = {
|
|
91
|
+
engage(key) {
|
|
92
|
+
if (!REMOTE_KEY.test(key)) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const existing = open.get(key)
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.readers += 1
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
open.set(key, { readers: 1, dispose: subscribe(key) })
|
|
101
|
+
},
|
|
102
|
+
disengage(key) {
|
|
103
|
+
const existing = open.get(key)
|
|
104
|
+
if (existing === undefined) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
existing.readers -= 1
|
|
108
|
+
if (existing.readers <= 0) {
|
|
109
|
+
existing.dispose()
|
|
110
|
+
open.delete(key)
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Tear down every open subscription — for the startClient disposer, in case reads are
|
|
116
|
+
still mounted when the whole client is torn down (e.g. a test harness). */
|
|
117
|
+
function dispose(): void {
|
|
118
|
+
for (const entry of open.values()) {
|
|
119
|
+
entry.dispose()
|
|
120
|
+
}
|
|
121
|
+
open.clear()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { hook, dispose }
|
|
125
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Get-or-build the warm shadow program for a project root, cached once per root and shared by
|
|
5
|
+
the interpolation and seed classifiers (whichever is requested first for a root warms it; the
|
|
6
|
+
other reuses it). The shadow program is what makes type-directed async detection possible —
|
|
7
|
+
resolving whether `{getFoo()}` is a promise/stream and lifting it to a streaming/blocking cell.
|
|
8
|
+
|
|
9
|
+
On a build FAILURE the program is cached as `undefined` and the classifiers degrade to today's
|
|
10
|
+
plain-value / syntactic behavior — the build never breaks. But the failure is WARNED once per
|
|
11
|
+
root: a silent `undefined` disables type-directed async detection for EVERY component in the
|
|
12
|
+
root, so a broken tsconfig would otherwise ship a bare `{getFoo()}` as the literal
|
|
13
|
+
`[object Promise]` text with no signal at all. The warn fires once (guarded by `cache.has`), so
|
|
14
|
+
the two classifiers sharing a cache never double-report.
|
|
15
|
+
|
|
16
|
+
`build` is injectable so the warn-once path is testable without a broken project on disk;
|
|
17
|
+
callers pass nothing and get `createShadowProgram`.
|
|
18
|
+
*/
|
|
19
|
+
export function cachedShadowProgram(
|
|
20
|
+
cache: Map<string, ShadowProgram | undefined>,
|
|
21
|
+
root: string,
|
|
22
|
+
build: (root: string) => ShadowProgram = createShadowProgram,
|
|
23
|
+
): ShadowProgram | undefined {
|
|
24
|
+
if (!cache.has(root)) {
|
|
25
|
+
try {
|
|
26
|
+
cache.set(root, build(root))
|
|
27
|
+
} catch (error) {
|
|
28
|
+
cache.set(root, undefined)
|
|
29
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
30
|
+
console.warn(
|
|
31
|
+
`[abide] couldn't build the type program for ${root}: ${detail}\n` +
|
|
32
|
+
' type-directed async detection is disabled for this project — a bare promise/stream\n' +
|
|
33
|
+
' read like {getFoo()} renders as text instead of streaming. Check the tsconfig.',
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return cache.get(root)
|
|
38
|
+
}
|
|
@@ -29,8 +29,15 @@ export function compileComponent(
|
|
|
29
29
|
a direct caller (tests) omits it and the front-end runs here — threading `classify` and
|
|
30
30
|
`seedClassify` so type-directed interpolation + cell lowering happen on this path too. */
|
|
31
31
|
const resolved = analyzed ?? analyzeComponent(source, scopeSeed, classify, seedClassify)
|
|
32
|
-
const {
|
|
33
|
-
|
|
32
|
+
const {
|
|
33
|
+
script,
|
|
34
|
+
stateNames,
|
|
35
|
+
derivedNames,
|
|
36
|
+
computedNames,
|
|
37
|
+
cellReadNames,
|
|
38
|
+
blockingCellNames,
|
|
39
|
+
nodes,
|
|
40
|
+
} = resolved
|
|
34
41
|
const build = generateBuild(
|
|
35
42
|
nodes,
|
|
36
43
|
'host',
|
|
@@ -36,6 +36,24 @@ function wrapSeed(argument: ts.Expression): ts.Expression {
|
|
|
36
36
|
)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/* Wraps a seed expression as an async unwrapping thunk `async () => await (arg)` — for a
|
|
40
|
+
type-directed PROMISE seed (ADR-0023/0043) the author wrote WITHOUT `await`
|
|
41
|
+
(`state.computed(getFoo())`). The `async`+`await` makes createAsyncCell unwrap the resolved
|
|
42
|
+
value; a plain `() => getFoo()` thunk would fall to trackedComputed's lazy opaque path (its
|
|
43
|
+
probe self-identifies only a stream, never a promise). The parens keep a comma/ternary seed
|
|
44
|
+
a single await operand. Distinct from `wrapSeed`, which only makes the arrow async when the
|
|
45
|
+
arg already carries a top-level `await`. */
|
|
46
|
+
function wrapAwaitSeed(argument: ts.Expression): ts.Expression {
|
|
47
|
+
return factory.createArrowFunction(
|
|
48
|
+
[factory.createModifier(ts.SyntaxKind.AsyncKeyword)],
|
|
49
|
+
undefined,
|
|
50
|
+
[],
|
|
51
|
+
undefined,
|
|
52
|
+
factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
53
|
+
factory.createAwaitExpression(factory.createParenthesizedExpression(argument)),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
/* True for an async seed thunk — an arrow/function carrying the `async` modifier, whether
|
|
40
58
|
from `wrapSeed`'s `await` lowering or a passthrough `async () => …` literal the author
|
|
41
59
|
wrote. The routing signal: an async seed becomes an async cell (read via `$$readCell`),
|
|
@@ -275,6 +293,21 @@ export function desugarSignals(
|
|
|
275
293
|
}
|
|
276
294
|
return isBareCallComputed(declaration)
|
|
277
295
|
}
|
|
296
|
+
/* A type-directed PROMISE seed (ADR-0023/0043): a no-`await` `state.computed(getFoo())` whose
|
|
297
|
+
seed's checker type resolves to a promise. Routed to an eager STREAMING async cell that
|
|
298
|
+
unwraps the resolved value and reactively re-resolves when its tracked deps change — the
|
|
299
|
+
script-level twin of a bare async interpolation (ADR-0032). Only fires with a warm classifier
|
|
300
|
+
('promise'); fail-open (no program) leaves it to `isEagerStreamComputed`'s bare-call probe,
|
|
301
|
+
and the `await`-marker seed is excluded (its wrapped thunk is already async). Kept separate
|
|
302
|
+
from `isEagerStreamComputed` so `computedStatements` can wrap it as `async () => await (seed)`
|
|
303
|
+
(a stream seed stays a bare probe thunk). */
|
|
304
|
+
const isPromiseComputed: EagerStreamPredicate = (declaration) => {
|
|
305
|
+
const argument = seedArgument(declaration)
|
|
306
|
+
if (argument === undefined || isAsyncSeed(wrapSeed(argument))) {
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
return seedKind(argument) === 'promise'
|
|
310
|
+
}
|
|
278
311
|
for (const statement of source.statements) {
|
|
279
312
|
if (!ts.isVariableStatement(statement)) {
|
|
280
313
|
continue
|
|
@@ -321,9 +354,15 @@ export function desugarSignals(
|
|
|
321
354
|
`name()` (a sync/promise seed), or an eager cell read via `$$readCell(name)`
|
|
322
355
|
when the wrapped seed is async (an `await`-lowered / passthrough-`async` thunk)
|
|
323
356
|
or its seed type resolves to a stream (`isEagerStreamComputed`, ADR-0023). */
|
|
324
|
-
if (
|
|
357
|
+
if (
|
|
358
|
+
isAsyncComputed(declaration) ||
|
|
359
|
+
isEagerStreamComputed(declaration) ||
|
|
360
|
+
isPromiseComputed(declaration)
|
|
361
|
+
) {
|
|
325
362
|
cellReadNames.add(declaration.name.text)
|
|
326
|
-
/* A blocking async computed (author `await`) reads suspend-on-pending
|
|
363
|
+
/* A blocking async computed (author `await`) reads suspend-on-pending; a bare
|
|
364
|
+
promise (isPromiseComputed) carries no `await` → streaming, so isBlockingSeed
|
|
365
|
+
is false and it never joins this set. */
|
|
327
366
|
const argument = seedArgument(declaration)
|
|
328
367
|
if (argument !== undefined && isBlockingSeed(argument)) {
|
|
329
368
|
scriptBlockingNames.add(declaration.name.text)
|
|
@@ -389,6 +428,7 @@ export function desugarSignals(
|
|
|
389
428
|
blockingCellNames,
|
|
390
429
|
writtenNames,
|
|
391
430
|
isEagerStreamComputed,
|
|
431
|
+
isPromiseComputed,
|
|
392
432
|
),
|
|
393
433
|
)
|
|
394
434
|
}
|
|
@@ -417,12 +457,19 @@ function loweredStatement(
|
|
|
417
457
|
blockingCellNames: ReadonlySet<string>,
|
|
418
458
|
writtenNames: ReadonlySet<string>,
|
|
419
459
|
isEagerStreamComputed: EagerStreamPredicate,
|
|
460
|
+
isPromiseComputed: EagerStreamPredicate,
|
|
420
461
|
): ts.Statement[] {
|
|
421
462
|
rejectMixedDeclaration(statement, bindings)
|
|
422
463
|
return (
|
|
423
464
|
injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
|
|
424
465
|
stateAssignmentStatements(statement, bindings) ??
|
|
425
|
-
computedStatements(
|
|
466
|
+
computedStatements(
|
|
467
|
+
statement,
|
|
468
|
+
bindings,
|
|
469
|
+
signalNames,
|
|
470
|
+
isEagerStreamComputed,
|
|
471
|
+
isPromiseComputed,
|
|
472
|
+
) ??
|
|
426
473
|
propsStatements(statement, bindings, writtenNames) ??
|
|
427
474
|
cellStatements(statement, bindings, signalNames) ?? [statement]
|
|
428
475
|
)
|
|
@@ -613,6 +660,7 @@ function computedStatements(
|
|
|
613
660
|
bindings: ReactiveImportBindings,
|
|
614
661
|
signalNames: ReadonlySet<string>,
|
|
615
662
|
isEagerStreamComputed: EagerStreamPredicate,
|
|
663
|
+
isPromiseComputed: EagerStreamPredicate,
|
|
616
664
|
): ts.Statement[] | undefined {
|
|
617
665
|
if (!ts.isVariableStatement(statement)) {
|
|
618
666
|
return undefined
|
|
@@ -659,6 +707,23 @@ function computedStatements(
|
|
|
659
707
|
]),
|
|
660
708
|
),
|
|
661
709
|
)
|
|
710
|
+
} else if (argument !== undefined && isPromiseComputed(declaration)) {
|
|
711
|
+
/* Type-directed PROMISE seed (ADR-0023/0043): a bare `state.computed(getFoo())` whose
|
|
712
|
+
seed resolves to a promise. Wrap it as `async () => await (seed)` so createAsyncCell
|
|
713
|
+
unwraps the resolved value — a plain `() => getFoo()` thunk would fall to
|
|
714
|
+
trackedComputed's lazy opaque path (its probe self-identifies only a stream). Passed
|
|
715
|
+
`streaming: true` (the `true` arg): no author `await` → it does NOT join the SSR
|
|
716
|
+
blocking barrier — the shell ships pending and the client resolves + reactively
|
|
717
|
+
re-resolves, the ADR-0032 no-await tier. `await getFoo()` stays the BLOCKING form. */
|
|
718
|
+
statements.push(
|
|
719
|
+
constDeclaration(
|
|
720
|
+
name,
|
|
721
|
+
scopeMethodCall('trackedComputed', [
|
|
722
|
+
wrapAwaitSeed(argument),
|
|
723
|
+
factory.createTrue(),
|
|
724
|
+
]),
|
|
725
|
+
),
|
|
726
|
+
)
|
|
662
727
|
} else if (isEagerStreamComputed(declaration)) {
|
|
663
728
|
/* Stream seed → the eager `trackedComputed`, which probes the seed and auto-tracks a
|
|
664
729
|
stream (`AsyncComputed`) or falls back to a lazy computed; read via `$$readCell`.
|