@abide/abide 0.52.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 +25 -12
- package/CHANGELOG.md +82 -0
- package/README.md +8 -6
- package/package.json +3 -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/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/analyzeComponent.ts +2 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +38 -0
- package/src/lib/ui/compile/compileComponent.ts +10 -1
- package/src/lib/ui/compile/compileSSR.ts +12 -0
- package/src/lib/ui/compile/compileShadow.ts +37 -34
- package/src/lib/ui/compile/desugarSignals.ts +127 -34
- package/src/lib/ui/compile/generateBuild.ts +5 -1
- package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
- package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerContext.ts +4 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerScript.ts +11 -1
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +36 -24
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- package/src/lib/ui/dom/appendText.ts +29 -8
- package/src/lib/ui/dom/appendTextAt.ts +21 -4
- package/src/lib/ui/dom/attr.ts +16 -1
- package/src/lib/ui/dom/awaitBlock.ts +34 -13
- package/src/lib/ui/dom/each.ts +10 -2
- package/src/lib/ui/dom/eachAsync.ts +9 -1
- package/src/lib/ui/dom/readCellBlocking.ts +29 -0
- package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
- package/src/lib/ui/dom/spreadAttrs.ts +47 -1
- package/src/lib/ui/dom/switchBlock.ts +19 -2
- package/src/lib/ui/dom/tryBlock.ts +10 -0
- package/src/lib/ui/dom/when.ts +19 -1
- package/src/lib/ui/dom/withSuspense.ts +22 -0
- package/src/lib/ui/router.ts +8 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- package/src/lib/ui/runtime/flushEffects.ts +18 -10
- 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 +25 -7
- 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
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { amend } from './amend.ts'
|
|
1
2
|
import { invalidate } from './invalidate.ts'
|
|
2
3
|
import { keyForRemoteCall } from './keyForRemoteCall.ts'
|
|
3
4
|
import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
|
|
4
|
-
import { patch } from './patch.ts'
|
|
5
5
|
import { peek } from './peek.ts'
|
|
6
6
|
import { pending } from './pending.ts'
|
|
7
7
|
import { refresh } from './refresh.ts'
|
|
@@ -12,13 +12,13 @@ import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
|
12
12
|
/*
|
|
13
13
|
Attaches the pre-bound selector sugar onto an assembled RemoteFunction:
|
|
14
14
|
`fn.pending(args?)` ≡ `pending(fn, args?)`, likewise refreshing / refresh / invalidate / peek,
|
|
15
|
-
`fn.
|
|
15
|
+
`fn.amend(args?, value | updater)` ≡ `amend(fn, args, …)`, and `fn.error(args?)` — the typed
|
|
16
16
|
last error from the rpc error registry (most-recent across the rpc when args omitted, that
|
|
17
17
|
exact call when given). The cached read is the bare call `fn(args, opts)` itself; refetch is
|
|
18
18
|
`fn.refresh(args?)`. The methods only reference the globals at call time, so the shared import
|
|
19
19
|
edge carries no module-init dependency (safe against any cache ↔ createRemoteFunction cycle).
|
|
20
20
|
Attached in createRemoteFunction so the server (defineRpc) and client (remoteProxy) shapes are
|
|
21
|
-
identical. `
|
|
21
|
+
identical. `amend` is attached uniformly; the type omits it for a streaming rpc (harmless at
|
|
22
22
|
runtime — a stream has no cache entry).
|
|
23
23
|
*/
|
|
24
24
|
export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args, Return>): void {
|
|
@@ -28,8 +28,8 @@ export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args,
|
|
|
28
28
|
refresh: (args?: Args) => refresh(fn, args),
|
|
29
29
|
invalidate: (args?: Args) => invalidate(fn, args),
|
|
30
30
|
peek: (args?: Args) => peek(fn, args),
|
|
31
|
-
|
|
32
|
-
(
|
|
31
|
+
amend: (argsOrUpdater?: unknown, updater?: unknown) =>
|
|
32
|
+
(amend as (fn: unknown, a?: unknown, b?: unknown) => void)(fn, argsOrUpdater, updater),
|
|
33
33
|
/* Reaction sugar is client-only (`watch` is a ui primitive that must not ride into a
|
|
34
34
|
server bundle), so the shared attach binds an inert no-op — the server (defineRpc)
|
|
35
35
|
shape carries `.watch` too, and an author `fn.watch(…)` surviving the SSR effect-strip
|
|
@@ -22,15 +22,22 @@ Bare iteration is the live stream (replay 0); `.tail(n)` seeds from the
|
|
|
22
22
|
retained tail (no-arg = the whole tail). Each iterator mints its own sub id for
|
|
23
23
|
lifecycle routing (end/err), and `hooks.replayed` fires in-band after the
|
|
24
24
|
replay batch so a window reader commits its seed strictly before any live frame.
|
|
25
|
+
|
|
26
|
+
`initialFrame` warm-seeds `lastFrame` at construction (socketProxy passes the
|
|
27
|
+
server's retained frame from `__SSR__.sockets`) so `peek()` returns the SSR value
|
|
28
|
+
at hydration instead of undefined — congruent with the server HTML until the ws
|
|
29
|
+
delivers its own replay of the same frame.
|
|
25
30
|
*/
|
|
26
31
|
export function buildSocketOverChannel<T>(
|
|
27
32
|
name: string,
|
|
28
33
|
resolveChannel: () => SocketChannel,
|
|
34
|
+
initialFrame?: T,
|
|
29
35
|
): Socket<T> {
|
|
30
36
|
/* The latest frame seen across every iterator of this socket — what peek() returns and
|
|
31
37
|
what refresh() re-seeds from the server tail. A plain slot (not reactive): peek(socket)
|
|
32
|
-
is a synchronous snapshot; live reactivity is the for-await / watch(socket) path.
|
|
33
|
-
|
|
38
|
+
is a synchronous snapshot; live reactivity is the for-await / watch(socket) path. Seeded
|
|
39
|
+
from the SSR-retained frame so a not-yet-connected client's peek matches the server. */
|
|
40
|
+
let lastFrame: T | undefined = initialFrame
|
|
34
41
|
function iterate(replay: number | undefined, hooks?: TailHooks): AsyncIterable<T> {
|
|
35
42
|
return {
|
|
36
43
|
[Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
|
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
|
+
}
|
|
@@ -56,6 +56,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
|
|
|
56
56
|
{ name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
|
|
57
57
|
{ name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
|
|
58
58
|
{ name: 'readCell', specifier: 'ui/dom/readCell', alias: '$$readCell' },
|
|
59
|
+
{ name: 'readCellBlocking', specifier: 'ui/dom/readCellBlocking', alias: '$$readCellBlocking' },
|
|
59
60
|
{ name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
|
|
60
61
|
{ name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
|
|
61
62
|
{ name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },
|
|
@@ -120,6 +120,7 @@ export function analyzeComponent(
|
|
|
120
120
|
derivedNames,
|
|
121
121
|
computedNames,
|
|
122
122
|
cellReadNames,
|
|
123
|
+
blockingCellNames: allBlockingCellNames,
|
|
123
124
|
droppedReactiveImports,
|
|
124
125
|
} = lowerScript(
|
|
125
126
|
fullScriptBody,
|
|
@@ -142,6 +143,7 @@ export function analyzeComponent(
|
|
|
142
143
|
derivedNames,
|
|
143
144
|
computedNames,
|
|
144
145
|
cellReadNames,
|
|
146
|
+
blockingCellNames: allBlockingCellNames,
|
|
145
147
|
nodes,
|
|
146
148
|
styles,
|
|
147
149
|
/* Hydration adopts every block in place — including `await`, which resumes
|
|
@@ -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,7 +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 {
|
|
32
|
+
const {
|
|
33
|
+
script,
|
|
34
|
+
stateNames,
|
|
35
|
+
derivedNames,
|
|
36
|
+
computedNames,
|
|
37
|
+
cellReadNames,
|
|
38
|
+
blockingCellNames,
|
|
39
|
+
nodes,
|
|
40
|
+
} = resolved
|
|
33
41
|
const build = generateBuild(
|
|
34
42
|
nodes,
|
|
35
43
|
'host',
|
|
@@ -38,6 +46,7 @@ export function compileComponent(
|
|
|
38
46
|
computedNames,
|
|
39
47
|
isLayout,
|
|
40
48
|
cellReadNames,
|
|
49
|
+
blockingCellNames,
|
|
41
50
|
)
|
|
42
51
|
/* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
|
|
43
52
|
injected at runtime; the build only needs the `data-a-…` scope attributes on
|
|
@@ -66,6 +66,18 @@ export function compileSSR(
|
|
|
66
66
|
flightDecls,
|
|
67
67
|
hasStagedChildren,
|
|
68
68
|
} = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout, cellReadNames)
|
|
69
|
+
/* ADR-0042 D7 (barrier completeness): every async cell — and so every BLOCKING (`await`) cell —
|
|
70
|
+
is declared in the lowered script (`lowered`), which the body below emits BEFORE the
|
|
71
|
+
`$$settleAsyncCells` barrier; the template back-end (`ssr`) constructs no cells. A blocking
|
|
72
|
+
cell built AFTER its barrier would ship an unresolved value the client then SUSPENDS against,
|
|
73
|
+
desyncing hydration. The invariant holds by construction today (cells are a script-block
|
|
74
|
+
construct); assert it as a tripwire so a future template lowering that emits a cell
|
|
75
|
+
construction fails the build LOUDLY here instead of as a runtime hydration desync. */
|
|
76
|
+
if (/\$\$scope\(\)\.(trackedComputed|computed|linked)\(/.test(ssr)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'[abide] a reactive cell is constructed in template output, after the async-cell barrier (ADR-0042 D7) — cells must be declared in the component script so they settle before the SSR flush.',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
69
81
|
/* ADR-0039: a component with hoistable children declares `$childSlots` (the body walk reserves an
|
|
70
82
|
output slot per child) and, after the walk, awaits `$$finalizeStreamedChildren` — which fills
|
|
71
83
|
each slot inline (settled child, byte-identical to the old inline await) or with a streaming
|