@abide/abide 0.46.0 → 0.48.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 +370 -320
- package/CHANGELOG.md +94 -0
- package/README.md +203 -168
- package/package.json +12 -8
- package/src/abideLsp.ts +11 -12
- package/src/abideResolverPlugin.ts +9 -4
- package/src/lib/bundle/disconnected.abide +3 -0
- package/src/lib/cli/printCommandHelp.ts +43 -0
- package/src/lib/cli/printSessionHelp.ts +2 -1
- package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
- package/src/lib/cli/runCli.ts +2 -1
- package/src/lib/mcp/buildPrompts.ts +25 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
- package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
- package/src/lib/mcp/mcpSurface.ts +13 -252
- package/src/lib/mcp/mcpTools.ts +187 -0
- package/src/lib/mcp/renderPrompt.ts +25 -0
- package/src/lib/mcp/types/McpSurface.ts +15 -0
- package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
- package/src/lib/mcp/types/PromptMessage.ts +2 -0
- package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
- package/src/lib/mcp/types/ToolResult.ts +2 -0
- package/src/lib/server/agent.ts +1 -1
- package/src/lib/server/jsonl.ts +2 -2
- package/src/lib/server/rpc/defineRpc.ts +5 -0
- package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
- package/src/lib/server/runtime/containsTraversal.ts +21 -17
- package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
- package/src/lib/server/runtime/createServer.ts +101 -71
- package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
- package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
- package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
- package/src/lib/server/runtime/finalizeResponse.ts +32 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
- package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
- package/src/lib/server/socket.ts +1 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
- package/src/lib/server/sockets/defineSocket.ts +26 -7
- package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
- package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
- package/src/lib/server/sse.ts +2 -2
- package/src/lib/shared/DEFER.ts +8 -0
- package/src/lib/shared/activeCacheStore.ts +2 -2
- package/src/lib/shared/activePage.ts +2 -2
- package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
- package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
- package/src/lib/shared/basePath.ts +2 -2
- package/src/lib/shared/baseSlot.ts +6 -5
- package/src/lib/shared/bodyValueForKind.ts +1 -2
- package/src/lib/shared/buildSocketOverChannel.ts +40 -4
- package/src/lib/shared/cache.ts +522 -117
- package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
- package/src/lib/shared/cacheStoreSlot.ts +9 -5
- package/src/lib/shared/cacheStores.ts +3 -3
- package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
- package/src/lib/shared/createRemoteFunction.ts +58 -9
- package/src/lib/shared/createResolverSlot.ts +24 -31
- package/src/lib/shared/createSocketSubRegistry.ts +2 -2
- package/src/lib/shared/decodeRefJson.ts +4 -2
- package/src/lib/shared/decodeResponse.ts +8 -6
- package/src/lib/shared/done.ts +14 -0
- package/src/lib/shared/encodeRefJson.ts +4 -2
- package/src/lib/shared/hydratingSlot.ts +12 -0
- package/src/lib/shared/isLayoutFile.ts +12 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/keyPrefixForRemote.ts +12 -0
- package/src/lib/shared/matchRoute.ts +175 -0
- package/src/lib/shared/normalizePathname.ts +11 -0
- package/src/lib/shared/pageSlot.ts +14 -5
- package/src/lib/shared/pageUrlForFile.ts +3 -3
- package/src/lib/shared/parseRouteSegments.ts +16 -7
- package/src/lib/shared/patch.ts +41 -0
- package/src/lib/shared/peek.ts +35 -0
- package/src/lib/shared/prepareRemoteExport.ts +40 -0
- package/src/lib/shared/prepareRpcModule.ts +98 -16
- package/src/lib/shared/prepareSocketModule.ts +11 -15
- package/src/lib/shared/queryStringFromArgs.ts +11 -0
- package/src/lib/shared/reachable.ts +102 -0
- package/src/lib/shared/refresh.ts +22 -0
- package/src/lib/shared/requestScopeSlot.ts +10 -7
- package/src/lib/shared/routeParamsShape.ts +9 -9
- package/src/lib/shared/rpcErrorRegistry.ts +69 -0
- package/src/lib/shared/selectorPrefix.ts +3 -10
- package/src/lib/shared/setOwnProperty.ts +19 -0
- package/src/lib/shared/sharedCacheStore.ts +14 -0
- package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
- package/src/lib/shared/snippet.ts +1 -1
- package/src/lib/shared/subscribableProbes.ts +111 -0
- package/src/lib/shared/tailProbeSlot.ts +8 -1
- package/src/lib/shared/types/CacheEntry.ts +9 -15
- package/src/lib/shared/types/CacheOptions.ts +23 -11
- package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
- package/src/lib/shared/types/CacheStats.ts +1 -1
- package/src/lib/shared/types/RemoteCallable.ts +25 -7
- package/src/lib/shared/types/RemoteFunction.ts +48 -13
- package/src/lib/shared/types/ResolverSlot.ts +7 -4
- package/src/lib/shared/types/RpcError.ts +26 -0
- package/src/lib/shared/types/SmartReadOptions.ts +36 -0
- package/src/lib/shared/types/Socket.ts +54 -0
- package/src/lib/shared/types/SsrBootState.ts +27 -0
- package/src/lib/shared/types/SsrPayload.ts +21 -0
- package/src/lib/shared/types/Subscribable.ts +13 -13
- package/src/lib/shared/types/TailHooks.ts +2 -1
- package/src/lib/shared/url.ts +29 -14
- package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
- package/src/lib/shared/writeTestSocketsDts.ts +1 -1
- package/src/lib/test/createScriptedSurface.ts +1 -1
- package/src/lib/test/createTestApp.ts +8 -8
- package/src/lib/test/createTestSocketChannel.ts +3 -3
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
- package/src/lib/ui/compile/analyzeComponent.ts +28 -6
- package/src/lib/ui/compile/compileModule.ts +137 -11
- package/src/lib/ui/compile/compileShadow.ts +101 -84
- package/src/lib/ui/compile/desugarSignals.ts +120 -107
- package/src/lib/ui/compile/generateBuild.ts +48 -16
- package/src/lib/ui/compile/generateSSR.ts +117 -14
- package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
- package/src/lib/ui/compile/lowerContext.ts +10 -4
- package/src/lib/ui/compile/lowerScript.ts +72 -5
- package/src/lib/ui/compile/parseTemplate.ts +1 -14
- package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
- package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
- package/src/lib/ui/compile/signalCallee.ts +24 -0
- package/src/lib/ui/compile/stripEffects.ts +14 -11
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
- package/src/lib/ui/dom/appendStatic.ts +9 -1
- package/src/lib/ui/dom/appendText.ts +15 -3
- package/src/lib/ui/dom/assertClaimedText.ts +20 -0
- package/src/lib/ui/dom/awaitBlock.ts +19 -83
- package/src/lib/ui/dom/bindSelectValue.ts +48 -0
- package/src/lib/ui/dom/each.ts +12 -1
- package/src/lib/ui/dom/hydrate.ts +10 -0
- package/src/lib/ui/dom/mountChild.ts +2 -5
- package/src/lib/ui/dom/mountRange.ts +0 -75
- package/src/lib/ui/effect.ts +4 -3
- package/src/lib/ui/installHotBridge.ts +4 -2
- package/src/lib/ui/remoteProxy.ts +18 -2
- package/src/lib/ui/renderToStream.ts +9 -13
- package/src/lib/ui/resumeSeedScript.ts +2 -2
- package/src/lib/ui/router.ts +21 -2
- package/src/lib/ui/runtime/RESUME.ts +0 -6
- package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
- package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
- package/src/lib/ui/scope.ts +6 -3
- package/src/lib/ui/seedBootState.ts +53 -0
- package/src/lib/ui/socketChannel.ts +2 -2
- package/src/lib/ui/socketProxy.ts +9 -3
- package/src/lib/ui/startClient.ts +17 -31
- package/src/lib/ui/state.ts +44 -13
- package/src/lib/ui/sync.ts +11 -5
- package/src/lib/ui/tryEncodeResume.ts +2 -5
- package/src/lib/ui/types/Scope.ts +14 -10
- package/src/lib/ui/watch.ts +140 -0
- package/src/serverEntry.ts +13 -11
- package/template/CLAUDE.md +1 -1
- package/template/package.json +1 -1
- package/template/src/server/rpc/getHello.ts +3 -3
- package/template/src/ui/pages/page.abide +2 -2
- package/template/test/app.test.ts +1 -1
- package/src/lib/server/reachable.ts +0 -45
- package/src/lib/server/sockets/types/Socket.ts +0 -23
- package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
- package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
- package/src/lib/shared/baseResolver.ts +0 -10
- package/src/lib/shared/cacheKeyOf.ts +0 -7
- package/src/lib/shared/cacheKeyStore.ts +0 -8
- package/src/lib/shared/cacheStoreResolver.ts +0 -12
- package/src/lib/shared/globalCacheStore.ts +0 -15
- package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
- package/src/lib/shared/globalCacheStoreSlot.ts +0 -9
- package/src/lib/shared/pageResolver.ts +0 -16
- package/src/lib/shared/recordCacheKey.ts +0 -6
- package/src/lib/shared/requestScopeResolver.ts +0 -12
- package/src/lib/shared/setBaseResolver.ts +0 -4
- package/src/lib/shared/setCacheStoreResolver.ts +0 -4
- package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
- package/src/lib/shared/setPageResolver.ts +0 -4
- package/src/lib/shared/setRequestScopeResolver.ts +0 -4
- package/src/lib/shared/toBunRoutePattern.ts +0 -28
- package/src/lib/shared/types/TailOptions.ts +0 -10
- package/src/lib/ui/deferResume.ts +0 -36
- package/src/lib/ui/dom/firstElementBetween.ts +0 -14
- package/src/lib/ui/matchRoute.ts +0 -106
- package/src/lib/ui/runtime/scheduleWake.ts +0 -28
- package/src/lib/ui/runtime/whenIdle.ts +0 -21
- package/src/lib/ui/runtime/whenVisible.ts +0 -105
- package/src/lib/ui/tail.ts +0 -324
- /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
- /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
package/src/lib/shared/cache.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { abideLog } from './abideLog.ts'
|
|
2
2
|
import { activeCacheStore } from './activeCacheStore.ts'
|
|
3
|
-
import { CACHE_WRAPPED } from './CACHE_WRAPPED.ts'
|
|
4
3
|
import { cacheStores } from './cacheStores.ts'
|
|
5
4
|
import { decodeResponse } from './decodeResponse.ts'
|
|
6
5
|
import { getRemoteMeta } from './getRemoteMeta.ts'
|
|
7
|
-
import { globalCacheStore } from './globalCacheStore.ts'
|
|
8
6
|
import { HttpError } from './HttpError.ts'
|
|
7
|
+
import { hydratingSlot } from './hydratingSlot.ts'
|
|
9
8
|
import { invalidateEvent } from './invalidateEvent.ts'
|
|
10
9
|
import { invalidateTripwire } from './invalidateTripwire.ts'
|
|
11
10
|
import { keyForRemoteCall } from './keyForRemoteCall.ts'
|
|
12
11
|
import { producerKey } from './producerKey.ts'
|
|
13
12
|
import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
|
|
14
13
|
import { REPLAYABLE_METHODS } from './REPLAYABLE_METHODS.ts'
|
|
15
|
-
import {
|
|
14
|
+
import { rpcErrorRegistry } from './rpcErrorRegistry.ts'
|
|
16
15
|
import { SocketDisconnectedError } from './SocketDisconnectedError.ts'
|
|
17
16
|
import { selectorMatcher } from './selectorMatcher.ts'
|
|
18
17
|
import { selectorPrefix } from './selectorPrefix.ts'
|
|
18
|
+
import { sharedCacheStore } from './sharedCacheStore.ts'
|
|
19
|
+
import { openStreamProbe } from './subscribableProbes.ts'
|
|
19
20
|
import { toTagSet } from './toTagSet.ts'
|
|
20
21
|
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
21
22
|
import type { CacheOnContext } from './types/CacheOnContext.ts'
|
|
@@ -35,7 +36,7 @@ const cacheLog = abideLog.channel('abide:cache')
|
|
|
35
36
|
|
|
36
37
|
/*
|
|
37
38
|
Tallies one read and narrates it on the diagnostics channel. The sink is the
|
|
38
|
-
request/tab store even when the data store is the process-level
|
|
39
|
+
request/tab store even when the data store is the process-level shared one —
|
|
39
40
|
attribution follows the asker, so a request's closing record reflects every
|
|
40
41
|
read it made. A settled retained entry (including the warm SSR sync path) is
|
|
41
42
|
a hit; an unsettled entry is a coalesced join of an in-flight call; no entry
|
|
@@ -57,16 +58,17 @@ function recordRead(sink: CacheStore, key: string, existing: CacheEntry | undefi
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
Reads a call through a cache store. `cache(fn, args?, options?)` checks the store
|
|
62
|
+
for a prior entry and returns a shared promise on hit, or invokes `fn` once and
|
|
63
|
+
stores its promise on miss — a direct read-through call, not a curried invoker.
|
|
64
|
+
Args lead (the common refinement); options trail in a fixed final position so
|
|
65
|
+
they can't collide with arg shapes. ttl = 0 → dedupe only; ttl > 0 → entry
|
|
66
|
+
expires `ttl` ms after the promise resolves. Omitted ttl → forever for a
|
|
67
|
+
producer and for a remote call on the client; a remote call on the server with
|
|
68
|
+
neither ttl nor swr stated defaults to 0 (coalesce-only, see CacheOptions).
|
|
67
69
|
|
|
68
70
|
Coalescing is always on: identical in-flight calls share one flight, so
|
|
69
|
-
`cache(createPost, { ttl: 0 })` is the mutation idiom — double-submit
|
|
71
|
+
`cache(createPost, args, { ttl: 0 })` is the mutation idiom — double-submit
|
|
70
72
|
coalescing and pending() visibility with nothing retained beyond the store's
|
|
71
73
|
atomic unit (the whole request on the server: one render, one effect; the
|
|
72
74
|
in-flight window in the tab). Caching is the retention `ttl` adds on top.
|
|
@@ -74,9 +76,10 @@ in-flight window in the tab). Caching is the retention `ttl` adds on top.
|
|
|
74
76
|
`fn` is either a remote function (a GET/POST/... helper) or a plain producer
|
|
75
77
|
returning a Promise:
|
|
76
78
|
|
|
77
|
-
cache(getPost
|
|
78
|
-
cache(getPost.raw
|
|
79
|
-
cache(fetchRates)
|
|
79
|
+
cache(getPost, { id }) // → Promise<Post> (decoded body)
|
|
80
|
+
cache(getPost.raw, { id }) // → Promise<Response> (raw escape hatch)
|
|
81
|
+
cache(fetchRates) // → Promise<Rates> (plain producer, no args)
|
|
82
|
+
cache(createPost, body, { ttl: 0 }) // options trail; no-arg-with-options: cache(fn, undefined, opts)
|
|
80
83
|
|
|
81
84
|
Remote calls key on fn.method + fn.url + args and store the underlying Response
|
|
82
85
|
(the decoded view is derived on the way out for the non-raw variant; both share
|
|
@@ -86,24 +89,24 @@ new reference every call and never does; a warning fires once per such call
|
|
|
86
89
|
site), and the promise is stored and handed back as-is (no Response, no decode,
|
|
87
90
|
no SSR snapshot).
|
|
88
91
|
|
|
89
|
-
`options.
|
|
92
|
+
`options.shared` puts the entry in the process-level store instead of the
|
|
90
93
|
request-scoped one, so a value computed in one request is reused by later
|
|
91
94
|
requests — the memoise-an-external-endpoint case. Default (omitted) is
|
|
92
95
|
request-scoped on the server, which keeps per-user data from leaking across
|
|
93
96
|
requests; on the client there is one tab store either way, so it is a no-op.
|
|
94
97
|
|
|
95
|
-
Reactivity is implicit: the
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
Reactivity is implicit: the read calls `store.subscribe(key)`, which registers
|
|
99
|
+
the surrounding state.computed() / effect() scope. Invalidating the key then
|
|
100
|
+
re-runs that scope, which calls cache() again and gets a fresh entry. Outside a
|
|
101
|
+
tracking scope subscribe() is a no-op, so cache() works the same in server code
|
|
102
|
+
and plain client code.
|
|
100
103
|
|
|
101
104
|
SSR: how you consume the call decides inline vs streaming (during SSR only the
|
|
102
105
|
pending branch of a `<template await>` renders):
|
|
103
106
|
|
|
104
|
-
const post = await cache(getPost
|
|
107
|
+
const post = await cache(getPost, { id }) // blocks render → baked into
|
|
105
108
|
// the initial SSR HTML
|
|
106
|
-
<template await={cache(getPost
|
|
109
|
+
<template await={cache(getPost, { id })}> // renders pending → shell flushes
|
|
107
110
|
// now, value streams in on the
|
|
108
111
|
// same response when it resolves
|
|
109
112
|
|
|
@@ -119,32 +122,68 @@ await-everything mode is per component instance, so a child's await blocks only
|
|
|
119
122
|
the child.
|
|
120
123
|
*/
|
|
121
124
|
// @documentation cache
|
|
125
|
+
/**
|
|
126
|
+
* @deprecated Streaming rpcs (jsonl/sse) aren't cacheable — a stream has no single
|
|
127
|
+
* value to retain. Consume it with `state(fn(args))` (reactive) or `for await (… of
|
|
128
|
+
* fn(args))` (imperative). This overload is selected first for a streaming rpc so the
|
|
129
|
+
* mistake is a red squiggle at the keyboard; the `decodeResponse` guard also throws.
|
|
130
|
+
*/
|
|
131
|
+
export function cache<Args>(
|
|
132
|
+
fn: RemoteFunction<Args, AsyncIterable<unknown>>,
|
|
133
|
+
...rest: never[]
|
|
134
|
+
): never
|
|
122
135
|
export function cache<Args, Return>(
|
|
123
136
|
fn: RemoteFunction<Args, Return>,
|
|
137
|
+
args?: Args,
|
|
124
138
|
options?: CacheOptions,
|
|
125
|
-
):
|
|
139
|
+
): Promise<Return>
|
|
126
140
|
export function cache<Args>(
|
|
127
141
|
fn: RawRemoteFunction<Args>,
|
|
142
|
+
args?: Args,
|
|
128
143
|
options?: CacheOptions,
|
|
129
|
-
):
|
|
144
|
+
): Promise<Response>
|
|
130
145
|
export function cache<Args, Return>(
|
|
131
146
|
fn: Producer<Args, Return>,
|
|
147
|
+
args?: Args,
|
|
132
148
|
options?: CacheOptions,
|
|
133
|
-
):
|
|
149
|
+
): Promise<Return>
|
|
134
150
|
export function cache<Args, Return>(
|
|
135
151
|
fn: AnyRemote<Args, Return> | Producer<Args, Return>,
|
|
152
|
+
args?: Args,
|
|
136
153
|
options?: CacheOptions,
|
|
137
|
-
):
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
154
|
+
): Promise<Return | Response> {
|
|
155
|
+
return readThrough(fn, args, options, false)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
The smart bare rpc call routes here (createRemoteFunction.callable → cache.read):
|
|
160
|
+
identical to cache() except a replayable read gets unconditional SWR retention —
|
|
161
|
+
its value is kept for display regardless of ttl, and ttl marks a staleness
|
|
162
|
+
deadline (the next read past it revalidates in the background) instead of driving
|
|
163
|
+
eviction. Always a decoded RemoteFunction read (the callable carries .raw), so it
|
|
164
|
+
returns Promise<Return>.
|
|
165
|
+
*/
|
|
166
|
+
function smartRead<Args, Return>(
|
|
167
|
+
fn: RemoteFunction<Args, Return>,
|
|
168
|
+
args?: Args,
|
|
169
|
+
options?: CacheOptions,
|
|
170
|
+
): Promise<Return> {
|
|
171
|
+
return readThrough(fn, args, options, true) as Promise<Return>
|
|
172
|
+
}
|
|
173
|
+
cache.read = smartRead
|
|
174
|
+
|
|
175
|
+
/*
|
|
176
|
+
The shared read-through core. `smart` marks the smart bare call, which enables
|
|
177
|
+
unconditional SWR retention for a replayable read — but only on the client (see
|
|
178
|
+
smartRead / entry.retain and the `retain` computation below); the public cache()
|
|
179
|
+
passes false, keeping its explicit drop-on-ttl / drop-on-invalidate old surface.
|
|
180
|
+
*/
|
|
181
|
+
function readThrough<Args, Return>(
|
|
182
|
+
fn: AnyRemote<Args, Return> | Producer<Args, Return>,
|
|
183
|
+
args: Args | undefined,
|
|
184
|
+
options: CacheOptions | undefined,
|
|
185
|
+
smart: boolean,
|
|
186
|
+
): Promise<Return | Response> {
|
|
148
187
|
/*
|
|
149
188
|
A remote function carries the REMOTE_FUNCTION brand (set by
|
|
150
189
|
createRemoteFunction on both variants); a plain producer never does — exact,
|
|
@@ -160,26 +199,67 @@ export function cache<Args, Return>(
|
|
|
160
199
|
: isRaw
|
|
161
200
|
? (fn as RawRemoteFunction<Args>)
|
|
162
201
|
: (fn as RemoteFunction<Args, Return>).raw
|
|
163
|
-
|
|
202
|
+
const method = isRemote ? (rawFn as RawRemoteFunction<Args>).method : undefined
|
|
203
|
+
const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
|
|
204
|
+
validatePolicy(options, method, smart)
|
|
205
|
+
/* SWR retention is a client concern (the tab store lives; revalidation is
|
|
206
|
+
visible). On the server there is no live UI to hold stale, so a smart read
|
|
207
|
+
never retains — it coalesces only. */
|
|
208
|
+
const retain = smart && replayable && options?.swr !== false && typeof window !== 'undefined'
|
|
209
|
+
/*
|
|
210
|
+
ttl defaults to 0 (coalesce-only) on the server for any remote read/write, and
|
|
211
|
+
on the client for a smart write — in both cases only when the caller stated
|
|
212
|
+
neither ttl nor swr. The server default makes retention opt-in (via an explicit
|
|
213
|
+
ttl, paired with `shared` to survive the request). An explicit swr opts out so
|
|
214
|
+
it does not trip validatePolicy's "swr + ttl:0" throw; server-side swr is a
|
|
215
|
+
client-only concept with no added server guarantee.
|
|
216
|
+
*/
|
|
217
|
+
const serverTtlZero =
|
|
218
|
+
isRemote &&
|
|
219
|
+
typeof window === 'undefined' &&
|
|
220
|
+
options?.ttl === undefined &&
|
|
221
|
+
options?.swr === undefined
|
|
222
|
+
const smartWriteTtlZero = smart && isRemote && !replayable && options?.ttl === undefined
|
|
223
|
+
const effectiveOptions = serverTtlZero || smartWriteTtlZero ? { ...options, ttl: 0 } : options
|
|
164
224
|
if (!isRemote) {
|
|
165
225
|
warnAnonymousProducer(fn as Producer<Args, Return>)
|
|
166
226
|
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
227
|
+
const store = effectiveOptions?.shared ? sharedCacheStore() : activeCacheStore()
|
|
228
|
+
if (!isRemote) {
|
|
229
|
+
return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
|
|
230
|
+
}
|
|
231
|
+
const remote = rawFn as RawRemoteFunction<Args>
|
|
232
|
+
const key = keyForRemoteCall(remote.method, remote.url, args)
|
|
233
|
+
if (
|
|
234
|
+
!effectiveOptions?.shared &&
|
|
235
|
+
effectiveOptions?.ttl !== undefined &&
|
|
236
|
+
effectiveOptions.ttl > 0 &&
|
|
237
|
+
store !== sharedCacheStore()
|
|
238
|
+
) {
|
|
239
|
+
warnEphemeralTtl(key)
|
|
240
|
+
}
|
|
241
|
+
store.subscribe(key)
|
|
242
|
+
const existing = store.entries.get(key)
|
|
243
|
+
recordRead(effectiveOptions?.shared ? activeCacheStore() : store, key, existing)
|
|
244
|
+
if (existing) {
|
|
245
|
+
tagEntry(existing, effectiveOptions?.tags)
|
|
246
|
+
attachPolicy(existing, effectiveOptions, () => remote(args as Args), retain)
|
|
247
|
+
adoptTtl(store, existing, effectiveOptions, retain)
|
|
248
|
+
/* Access-triggered staleness: reading a retained entry past its ttl deadline
|
|
249
|
+
kicks a background revalidation now (stale value stays visible below,
|
|
250
|
+
refreshing() true). Guarded on `!refreshing` so a read while one is already
|
|
251
|
+
in flight doesn't queue a redundant refetch. This is the whole staleness
|
|
252
|
+
mechanism — no timer polls an untouched entry. */
|
|
253
|
+
if (
|
|
254
|
+
existing.retain === true &&
|
|
255
|
+
existing.refreshing !== true &&
|
|
256
|
+
existing.expiresAt !== undefined &&
|
|
257
|
+
existing.expiresAt <= Date.now()
|
|
258
|
+
) {
|
|
259
|
+
scheduleInvalidationRefetch(store, existing)
|
|
181
260
|
}
|
|
182
|
-
|
|
261
|
+
}
|
|
262
|
+
/*
|
|
183
263
|
Warm path: a value pre-decoded onto the entry — by the SSR cache
|
|
184
264
|
snapshot the client seeds its store from — is served without a network
|
|
185
265
|
round-trip. It resolves on a
|
|
@@ -199,43 +279,22 @@ export function cache<Args, Return>(
|
|
|
199
279
|
reader of the key, so one mutating it would corrupt the others. A live
|
|
200
280
|
fetch hands each reader a fresh object; cloning keeps warm reads the same.
|
|
201
281
|
*/
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
recordCacheKey(warmed, key)
|
|
217
|
-
return warmed
|
|
218
|
-
}
|
|
219
|
-
const responsePromise = invokeRemote(
|
|
220
|
-
store,
|
|
221
|
-
key,
|
|
222
|
-
existing,
|
|
223
|
-
rawFn as RawRemoteFunction<Args>,
|
|
224
|
-
args,
|
|
225
|
-
options,
|
|
226
|
-
)
|
|
227
|
-
if (isRaw) {
|
|
228
|
-
return responsePromise
|
|
229
|
-
}
|
|
230
|
-
/* Tag the decoded value promise with its key so the SSR resume path can recognise a
|
|
231
|
-
cache-backed await value and defer a large one to a `{ defer, key }` marker. */
|
|
232
|
-
const decoded = responsePromise.then(decodeResponse) as Promise<Return>
|
|
233
|
-
recordCacheKey(decoded, key)
|
|
234
|
-
return decoded
|
|
282
|
+
if (!isRaw && existing !== undefined && existing.value !== undefined) {
|
|
283
|
+
return Promise.resolve(cloneWarmValue(existing.value)) as Promise<Return>
|
|
284
|
+
}
|
|
285
|
+
const responsePromise = invokeRemote(
|
|
286
|
+
store,
|
|
287
|
+
key,
|
|
288
|
+
existing,
|
|
289
|
+
rawFn as RawRemoteFunction<Args>,
|
|
290
|
+
args,
|
|
291
|
+
effectiveOptions,
|
|
292
|
+
retain,
|
|
293
|
+
)
|
|
294
|
+
if (isRaw) {
|
|
295
|
+
return responsePromise
|
|
235
296
|
}
|
|
236
|
-
|
|
237
|
-
Object.defineProperty(read, CACHE_WRAPPED, { value: fn })
|
|
238
|
-
return read
|
|
297
|
+
return responsePromise.then(decodeResponse) as Promise<Return>
|
|
239
298
|
}
|
|
240
299
|
|
|
241
300
|
/*
|
|
@@ -259,20 +318,34 @@ function cloneWarmValue(value: unknown): unknown {
|
|
|
259
318
|
}
|
|
260
319
|
|
|
261
320
|
/*
|
|
262
|
-
Normalises the
|
|
263
|
-
`
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
321
|
+
Normalises the refetch window, or undefined when off. Precedence: an explicit
|
|
322
|
+
`swr` object carries its own throttle/debounce; `swr: true` (or `defaultOn`) uses
|
|
323
|
+
the root `throttle`/`debounce` window if present, else `{}` (fire immediately).
|
|
324
|
+
`swr: false` is an explicit opt-out and wins over `defaultOn`. `defaultOn` is the
|
|
325
|
+
smart bare call's unconditional SWR for replayable reads — it turns the window on
|
|
326
|
+
without an `swr` toggle. Collapsing all of this here lets every downstream site
|
|
327
|
+
treat "is SWR on" as a single defined/undefined check.
|
|
267
328
|
*/
|
|
268
329
|
function swrWindow(
|
|
269
330
|
options: CacheOptions | undefined,
|
|
331
|
+
defaultOn: boolean,
|
|
270
332
|
): { throttle?: number; debounce?: number } | undefined {
|
|
271
333
|
const swr = options?.swr
|
|
272
|
-
if (swr ===
|
|
334
|
+
if (swr === false) {
|
|
273
335
|
return undefined
|
|
274
336
|
}
|
|
275
|
-
|
|
337
|
+
const root =
|
|
338
|
+
options?.throttle !== undefined || options?.debounce !== undefined
|
|
339
|
+
? { throttle: options?.throttle, debounce: options?.debounce }
|
|
340
|
+
: undefined
|
|
341
|
+
/* Explicit window object (swr: { throttle | debounce }). */
|
|
342
|
+
if (swr !== undefined && swr !== true) {
|
|
343
|
+
return swr
|
|
344
|
+
}
|
|
345
|
+
if (swr === true || defaultOn) {
|
|
346
|
+
return root ?? {}
|
|
347
|
+
}
|
|
348
|
+
return undefined
|
|
276
349
|
}
|
|
277
350
|
|
|
278
351
|
/*
|
|
@@ -284,20 +357,29 @@ refresh. Producers are opaque (no method to check); the same contract is on
|
|
|
284
357
|
the caller there. ttl: 0 retains nothing, so there is nothing to revalidate;
|
|
285
358
|
and the two coalescing windows are exclusive by construction.
|
|
286
359
|
*/
|
|
287
|
-
function validatePolicy(
|
|
288
|
-
|
|
360
|
+
function validatePolicy(
|
|
361
|
+
options: CacheOptions | undefined,
|
|
362
|
+
method: string | undefined,
|
|
363
|
+
smart: boolean,
|
|
364
|
+
): void {
|
|
365
|
+
const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
|
|
366
|
+
const policy = swrWindow(options, smart && replayable)
|
|
289
367
|
if (!policy) {
|
|
290
368
|
return
|
|
291
369
|
}
|
|
292
370
|
if (policy.throttle !== undefined && policy.debounce !== undefined) {
|
|
293
|
-
throw new Error('[abide] cache(): set
|
|
371
|
+
throw new Error('[abide] cache(): set throttle or debounce, not both')
|
|
294
372
|
}
|
|
295
|
-
|
|
373
|
+
/* An EXPLICIT swr with ttl: 0 is the old error — nothing retained to revalidate.
|
|
374
|
+
The smart read's implicit SWR retains unconditionally, so ttl: 0 is fine there. */
|
|
375
|
+
const explicitSwr =
|
|
376
|
+
options?.swr === true || (typeof options?.swr === 'object' && options?.swr !== null)
|
|
377
|
+
if (explicitSwr && options?.ttl === 0) {
|
|
296
378
|
throw new Error(
|
|
297
379
|
'[abide] cache(): swr requires retention — ttl: 0 keeps nothing to revalidate',
|
|
298
380
|
)
|
|
299
381
|
}
|
|
300
|
-
if (method !== undefined && !
|
|
382
|
+
if (method !== undefined && !replayable) {
|
|
301
383
|
throw new Error(
|
|
302
384
|
`[abide] cache(): swr re-runs the call unprompted — ${method.toUpperCase()} is a write and must not be replayed`,
|
|
303
385
|
)
|
|
@@ -325,6 +407,20 @@ function warnAnonymousProducer(producer: (args?: never) => unknown): void {
|
|
|
325
407
|
)
|
|
326
408
|
}
|
|
327
409
|
|
|
410
|
+
/* Warn once per key when a ttl>0 read lands in an ephemeral (request-scoped)
|
|
411
|
+
store — the ttl expires with the request. `shared` (the process store) or a
|
|
412
|
+
client tab store are the retention homes; a request-scoped ttl is dead config. */
|
|
413
|
+
const warnedEphemeralTtl = new Set<string>()
|
|
414
|
+
function warnEphemeralTtl(key: string): void {
|
|
415
|
+
if (warnedEphemeralTtl.has(key)) {
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
warnedEphemeralTtl.add(key)
|
|
419
|
+
abideLog.warn(
|
|
420
|
+
`cache(): a request-scoped ttl expires with the request — add \`shared\` to cache "${key}" across requests, or drop the ttl.`,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
328
424
|
/*
|
|
329
425
|
Producer path: key on the producer's reference + args, share the
|
|
330
426
|
in-flight/retained promise on hit, and store the value promise as-is on miss — no
|
|
@@ -339,10 +435,10 @@ function invokeProducer<Args, Return>(
|
|
|
339
435
|
const key = producerKey(producer, args)
|
|
340
436
|
store.subscribe(key)
|
|
341
437
|
const existing = store.entries.get(key)
|
|
342
|
-
recordRead(options?.
|
|
438
|
+
recordRead(options?.shared ? activeCacheStore() : store, key, existing)
|
|
343
439
|
if (existing) {
|
|
344
440
|
tagEntry(existing, options?.tags)
|
|
345
|
-
attachPolicy(existing, options, () => producer(args))
|
|
441
|
+
attachPolicy(existing, options, () => producer(args), false)
|
|
346
442
|
const shared = existing.promise as Promise<Return>
|
|
347
443
|
/* A coalesced join waits on the in-flight producer — time the block so the
|
|
348
444
|
waterfall shows it; a settled hit returns immediately, so no span. */
|
|
@@ -358,7 +454,7 @@ function invokeProducer<Args, Return>(
|
|
|
358
454
|
const promise = cacheLog.trace<Return>(`cache ${key}`, () =>
|
|
359
455
|
withCacheManaged(() => producer(args)),
|
|
360
456
|
)
|
|
361
|
-
registerEntry(store, key, promise, options, undefined, () => producer(args))
|
|
457
|
+
registerEntry(store, key, promise, options, undefined, () => producer(args), false)
|
|
362
458
|
return promise
|
|
363
459
|
}
|
|
364
460
|
|
|
@@ -369,6 +465,7 @@ function invokeRemote<Args>(
|
|
|
369
465
|
rawFn: RawRemoteFunction<Args>,
|
|
370
466
|
args: Args | undefined,
|
|
371
467
|
options: CacheOptions | undefined,
|
|
468
|
+
retain: boolean,
|
|
372
469
|
): Promise<Response> {
|
|
373
470
|
if (existing) {
|
|
374
471
|
return shareable(existing.promise as Promise<Response>)
|
|
@@ -382,7 +479,7 @@ function invokeRemote<Args>(
|
|
|
382
479
|
'[abide] cache() received a function whose call did not record metadata — was it produced by a rpc helper?',
|
|
383
480
|
)
|
|
384
481
|
}
|
|
385
|
-
registerEntry(store, key, promise, options, request, () => rawFn(args as Args))
|
|
482
|
+
registerEntry(store, key, promise, options, request, () => rawFn(args as Args), retain)
|
|
386
483
|
return shareable(promise)
|
|
387
484
|
}
|
|
388
485
|
|
|
@@ -398,10 +495,12 @@ function registerEntry(
|
|
|
398
495
|
options: CacheOptions | undefined,
|
|
399
496
|
request: Request | undefined,
|
|
400
497
|
refetch: () => Promise<unknown>,
|
|
498
|
+
retain: boolean,
|
|
401
499
|
): CacheEntry {
|
|
402
500
|
const ttl = options?.ttl
|
|
403
|
-
/* Capture the refetch thunk + window
|
|
404
|
-
|
|
501
|
+
/* Capture the refetch thunk + window when swr was asked for OR this is a smart
|
|
502
|
+
retained read (SWR unconditional). */
|
|
503
|
+
const policy = swrWindow(options, retain)
|
|
405
504
|
const invalidation = policy
|
|
406
505
|
? { refetch, throttle: policy.throttle, debounce: policy.debounce }
|
|
407
506
|
: undefined
|
|
@@ -420,6 +519,7 @@ function registerEntry(
|
|
|
420
519
|
tags: options?.tags === undefined ? undefined : toTagSet(options.tags),
|
|
421
520
|
refreshing,
|
|
422
521
|
invalidation,
|
|
522
|
+
retain: retain || undefined,
|
|
423
523
|
}
|
|
424
524
|
store.entries.set(key, entry)
|
|
425
525
|
store.markLifecycle(key)
|
|
@@ -433,11 +533,18 @@ function registerEntry(
|
|
|
433
533
|
own method filter; writes never ship). The keep never applies on the
|
|
434
534
|
client (the tab store outlives any unit — a kept write would block every
|
|
435
535
|
future re-submit, so entries evict the moment they settle), to producer
|
|
436
|
-
entries (no request), or
|
|
437
|
-
|
|
536
|
+
entries (no request), or when the resolved store is the process-level
|
|
537
|
+
`shared` store — including a non-shared read made outside an inbound
|
|
538
|
+
request, where the resolver falls back to `sharedCacheStore()` (Decision
|
|
539
|
+
1): that store is never request-scoped, so keeping it would leak forever.
|
|
540
|
+
The `store !== sharedCacheStore()` check is what tells a genuinely
|
|
541
|
+
request-scoped store apart from that fallback.
|
|
438
542
|
*/
|
|
439
543
|
const keepZeroTtlForRequest =
|
|
440
|
-
request !== undefined &&
|
|
544
|
+
request !== undefined &&
|
|
545
|
+
!options?.shared &&
|
|
546
|
+
typeof window === 'undefined' &&
|
|
547
|
+
store !== sharedCacheStore()
|
|
441
548
|
function deleteIfCurrent() {
|
|
442
549
|
evictIfCurrent(store, entry)
|
|
443
550
|
}
|
|
@@ -463,6 +570,29 @@ function registerEntry(
|
|
|
463
570
|
deleteIfCurrent()
|
|
464
571
|
return
|
|
465
572
|
}
|
|
573
|
+
/*
|
|
574
|
+
A post-invalidate reload registers with refreshing=true; an invalidation
|
|
575
|
+
firing while it was in flight parked its refetch on policy.pending
|
|
576
|
+
(fireRefetch bails when refreshing). This settle path — not fireRefetch —
|
|
577
|
+
cleared the flag, so drain the parked refetch here or it's lost and the
|
|
578
|
+
entry keeps data that predates the invalidation.
|
|
579
|
+
*/
|
|
580
|
+
if (entry.invalidation?.pending) {
|
|
581
|
+
reschedulePendingRefetch(store, entry, entry.invalidation)
|
|
582
|
+
}
|
|
583
|
+
/*
|
|
584
|
+
Smart retained read: the display value is kept unconditionally — never
|
|
585
|
+
hard-evicted on settle. ttl marks a staleness deadline (the next read past
|
|
586
|
+
it revalidates in the background, stale stays visible, refreshing() true)
|
|
587
|
+
instead of eviction; ttl 0/undefined retain with no staleness clock.
|
|
588
|
+
*/
|
|
589
|
+
if (entry.retain) {
|
|
590
|
+
materializeRetained(store, entry, result)
|
|
591
|
+
if (ttl !== undefined && ttl > 0) {
|
|
592
|
+
stampStaleDeadline(entry, ttl)
|
|
593
|
+
}
|
|
594
|
+
return
|
|
595
|
+
}
|
|
466
596
|
if (ttl === 0) {
|
|
467
597
|
if (!keepZeroTtlForRequest) {
|
|
468
598
|
deleteIfCurrent()
|
|
@@ -492,11 +622,71 @@ function evictIfCurrent(store: CacheStore, entry: CacheEntry): void {
|
|
|
492
622
|
/* Arms the ttl > 0 expiry sweep; `expiresAt` re-checks at fire time so a refreshed deadline survives. */
|
|
493
623
|
function armTtlExpiry(store: CacheStore, entry: CacheEntry, ttl: number): void {
|
|
494
624
|
entry.expiresAt = Date.now() + ttl
|
|
625
|
+
scheduleTtlSweep(store, entry)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/* setTimeout clamps a delay beyond 2^31-1 ms (~24.8 days) to ~1ms, which would
|
|
629
|
+
fire the sweep instantly, find the deadline unexpired, and never re-arm —
|
|
630
|
+
the entry would live forever. Chain capped hops until the deadline is in
|
|
631
|
+
reach; each hop bails once a newer entry owns the key. */
|
|
632
|
+
const MAX_TIMEOUT_DELAY = 2147483647
|
|
633
|
+
|
|
634
|
+
function scheduleTtlSweep(store: CacheStore, entry: CacheEntry): void {
|
|
635
|
+
if (store.entries.get(entry.key) !== entry) {
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
const remaining = (entry.expiresAt ?? 0) - Date.now()
|
|
639
|
+
if (remaining > MAX_TIMEOUT_DELAY) {
|
|
640
|
+
setTimeout(() => scheduleTtlSweep(store, entry), MAX_TIMEOUT_DELAY).unref?.()
|
|
641
|
+
return
|
|
642
|
+
}
|
|
495
643
|
setTimeout(() => {
|
|
496
644
|
if ((entry.expiresAt ?? 0) <= Date.now()) {
|
|
497
645
|
evictIfCurrent(store, entry)
|
|
498
646
|
}
|
|
499
|
-
},
|
|
647
|
+
}, remaining).unref?.()
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/*
|
|
651
|
+
Stamps the smart-read staleness deadline. Revalidation is access-triggered, NOT
|
|
652
|
+
timer-driven: once `expiresAt` has passed, the NEXT read of the entry (see the
|
|
653
|
+
staleness branch in readThrough) schedules a background revalidation — the stale
|
|
654
|
+
value stays visible, refreshing() flips true, and fireRefetch re-stamps a fresh
|
|
655
|
+
deadline on success. No `setTimeout`, so an entry no reader touches never refetches
|
|
656
|
+
on its own — no background polling accretes across a session.
|
|
657
|
+
*/
|
|
658
|
+
function stampStaleDeadline(entry: CacheEntry, ttl: number): void {
|
|
659
|
+
entry.expiresAt = Date.now() + ttl
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/*
|
|
663
|
+
Decodes a retained read's Response onto entry.value so peek() can read the current
|
|
664
|
+
value synchronously, and reads take the warm (cloned) path. Client-only: on the
|
|
665
|
+
server the SSR snapshot serializer owns value materialization (from the response
|
|
666
|
+
body it inlines), so leaving entry.value untouched there keeps the snapshot path
|
|
667
|
+
unchanged. Async decode (a microtask); markLifecycle re-runs a peek() scope once
|
|
668
|
+
the value lands. A non-Response (a producer retain — not currently reachable) is
|
|
669
|
+
stored as-is.
|
|
670
|
+
*/
|
|
671
|
+
function materializeRetained(store: CacheStore, entry: CacheEntry, result: unknown): void {
|
|
672
|
+
if (typeof window === 'undefined') {
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
if (result instanceof Response) {
|
|
676
|
+
decodeResponse(result.clone()).then(
|
|
677
|
+
(value) => {
|
|
678
|
+
/* Only if this entry still owns the key and hasn't been re-mutated meanwhile. */
|
|
679
|
+
if (store.entries.get(entry.key) === entry) {
|
|
680
|
+
entry.value = value
|
|
681
|
+
store.markLifecycle(entry.key)
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
() => undefined,
|
|
685
|
+
)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
entry.value = result
|
|
689
|
+
store.markLifecycle(entry.key)
|
|
500
690
|
}
|
|
501
691
|
|
|
502
692
|
/*
|
|
@@ -511,11 +701,27 @@ the already-painted DOM stays put) and the next read fetches live. The first
|
|
|
511
701
|
reader consumes the flag, so its declaration wins; live entries never carry
|
|
512
702
|
the flag and keep the ttl they registered with.
|
|
513
703
|
*/
|
|
514
|
-
function adoptTtl(
|
|
704
|
+
function adoptTtl(
|
|
705
|
+
store: CacheStore,
|
|
706
|
+
entry: CacheEntry,
|
|
707
|
+
options: CacheOptions | undefined,
|
|
708
|
+
retain: boolean,
|
|
709
|
+
): void {
|
|
515
710
|
if (entry.hydrated !== true) {
|
|
516
711
|
return
|
|
517
712
|
}
|
|
518
713
|
entry.hydrated = false
|
|
714
|
+
/* A smart read adopting a hydrated entry retains it and stamps the staleness
|
|
715
|
+
deadline, mirroring registerEntry's retain branch — never the hard-evict path. */
|
|
716
|
+
if (retain) {
|
|
717
|
+
entry.retain = true
|
|
718
|
+
const ttl = options?.ttl
|
|
719
|
+
entry.ttl = ttl
|
|
720
|
+
if (ttl !== undefined && ttl > 0) {
|
|
721
|
+
stampStaleDeadline(entry, ttl)
|
|
722
|
+
}
|
|
723
|
+
return
|
|
724
|
+
}
|
|
519
725
|
const ttl = options?.ttl
|
|
520
726
|
if (ttl === undefined) {
|
|
521
727
|
return
|
|
@@ -553,6 +759,11 @@ function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args
|
|
|
553
759
|
const prefix = selectorPrefix(arg, args)
|
|
554
760
|
const matches = selectorMatcher(arg, args, prefix)
|
|
555
761
|
invalidateTripwire(selectorLabel(arg, args, prefix))
|
|
762
|
+
/* Reset any recorded rpc errors for this selector too (independent of cache entries — a
|
|
763
|
+
bare call that errored never became one). Only fn selectors resolve a prefix. */
|
|
764
|
+
if (prefix !== undefined) {
|
|
765
|
+
rpcErrorRegistry.clearMatching(prefix)
|
|
766
|
+
}
|
|
556
767
|
for (const store of cacheStores()) {
|
|
557
768
|
const matched: string[] = []
|
|
558
769
|
const affected: string[] = []
|
|
@@ -603,6 +814,169 @@ function selectorLabel<Args, Return>(
|
|
|
603
814
|
|
|
604
815
|
cache.invalidate = invalidate
|
|
605
816
|
|
|
817
|
+
/*
|
|
818
|
+
The smart-call refetch: refetches every entry matching the selector, keeping the
|
|
819
|
+
stale value visible (refreshing() true) until the fresh value swaps in — never
|
|
820
|
+
blanks. This is the old invalidate reborn as *refetch*: because SWR retains the
|
|
821
|
+
value, there is no "invalidate that drops to pending" for the smart call. Follows
|
|
822
|
+
invalidate's exact selector grammar (fn / fn+args / { tags } / bare = all).
|
|
823
|
+
|
|
824
|
+
A smart-read match already carries an invalidation policy, so it routes straight
|
|
825
|
+
through scheduleInvalidationRefetch (throttle/debounce-honouring). A policy-less
|
|
826
|
+
remote match (an explicit cache() entry, or a hydrated one) gets a refetch armed
|
|
827
|
+
on the fly by replaying its stored Request. A policy-less producer match has no
|
|
828
|
+
way to re-run, so it drops (the next read reloads) — the invalidate fallback.
|
|
829
|
+
*/
|
|
830
|
+
function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
|
|
831
|
+
const prefix = selectorPrefix(arg, args)
|
|
832
|
+
const matches = selectorMatcher(arg, args, prefix)
|
|
833
|
+
invalidateTripwire(selectorLabel(arg, args, prefix))
|
|
834
|
+
if (prefix !== undefined) {
|
|
835
|
+
rpcErrorRegistry.clearMatching(prefix)
|
|
836
|
+
}
|
|
837
|
+
for (const store of cacheStores()) {
|
|
838
|
+
const matched: string[] = []
|
|
839
|
+
const affected: string[] = []
|
|
840
|
+
for (const entry of store.entries.values()) {
|
|
841
|
+
if (!matches(entry)) {
|
|
842
|
+
continue
|
|
843
|
+
}
|
|
844
|
+
matched.push(entry.key)
|
|
845
|
+
/* Arm a refetch on the fly for a policy-less remote entry by replaying its
|
|
846
|
+
stored Request — so a refresh always refetches-and-swaps, never blanks. */
|
|
847
|
+
if (entry.invalidation === undefined && entry.request !== undefined) {
|
|
848
|
+
const request = entry.request
|
|
849
|
+
entry.invalidation = { refetch: () => fetch(request.clone()) }
|
|
850
|
+
}
|
|
851
|
+
if (entry.invalidation !== undefined) {
|
|
852
|
+
scheduleInvalidationRefetch(store, entry)
|
|
853
|
+
} else {
|
|
854
|
+
/* No policy, no request (a producer never cached with swr): drop so the
|
|
855
|
+
next read reloads, flagged a reload if a reader is holding it. */
|
|
856
|
+
store.entries.delete(entry.key)
|
|
857
|
+
if (store.hasReader(entry.key)) {
|
|
858
|
+
store.pendingRefresh.add(entry.key)
|
|
859
|
+
}
|
|
860
|
+
affected.push(entry.key)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/* Mark the whole match set (probes re-derive); emit only the dropped subset — the
|
|
864
|
+
refetched entries emit when their fresh value lands (fireRefetch). */
|
|
865
|
+
notify(store, matched, affected)
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
cache.refresh = refresh
|
|
870
|
+
|
|
871
|
+
/*
|
|
872
|
+
Local value mutation: replaces the retained value of every entry matching the
|
|
873
|
+
selector via `updater(current)`, re-renders readers, and fires NO network — the
|
|
874
|
+
optimistic-update / real-time primitive (feed a socket frame straight into a
|
|
875
|
+
cached list). The next value is stored onto `entry.value`, which the read path
|
|
876
|
+
serves warm (cloned per read), so it persists across reads until a refresh /
|
|
877
|
+
invalidate replaces the entry. `current` comes from `entry.value` when already
|
|
878
|
+
materialized, else decoded from the settled/in-flight promise (async — readers
|
|
879
|
+
re-render when the patch lands). Follows the selector grammar, but only fn / args
|
|
880
|
+
/ tags select a value to mutate; a not-yet-read key has nothing to patch.
|
|
881
|
+
*/
|
|
882
|
+
function patch<Args, Return>(
|
|
883
|
+
arg: CacheSelector<Args, Return>,
|
|
884
|
+
args: Args | undefined,
|
|
885
|
+
updater: (current: Return) => Return,
|
|
886
|
+
): void {
|
|
887
|
+
const prefix = selectorPrefix(arg, args)
|
|
888
|
+
const matches = selectorMatcher(arg, args, prefix)
|
|
889
|
+
for (const store of cacheStores()) {
|
|
890
|
+
for (const entry of store.entries.values()) {
|
|
891
|
+
if (matches(entry)) {
|
|
892
|
+
applyPatch(store, entry, updater as (current: unknown) => unknown)
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
cache.patch = patch
|
|
899
|
+
|
|
900
|
+
/*
|
|
901
|
+
Synchronous, non-triggering value probe: returns the currently-retained value for
|
|
902
|
+
a call, or undefined when nothing is retained (no entry, or not yet settled). Never
|
|
903
|
+
invokes — reading it opens no fetch. Reactive: it subscribes the key AND taps the
|
|
904
|
+
key's lifecycle channel (the exact key is a one-entry prefix, same as
|
|
905
|
+
pending(fn, args)) so a state.computed / on / template scope re-runs both when the
|
|
906
|
+
value changes (invalidate, patch) and when a retained value lands —
|
|
907
|
+
materializeRetained's async decode signals only markLifecycle, no invalidate event,
|
|
908
|
+
so without the lifecycle tap a scope that read undefined would never see the value
|
|
909
|
+
arrive. Outside a tracking scope it is a one-shot snapshot. Returns a clone (like
|
|
910
|
+
the warm read path) so a caller mutating it can't corrupt the retained value.
|
|
911
|
+
`peek(sub)` for a subscribable is wired on the socket side.
|
|
912
|
+
*/
|
|
913
|
+
function peek<Args, Return>(
|
|
914
|
+
fn: RemoteFunction<Args, Return> | RawRemoteFunction<Args> | Producer<Args, Return>,
|
|
915
|
+
args?: Args,
|
|
916
|
+
): Return | undefined {
|
|
917
|
+
const isRemote = REMOTE_FUNCTION in fn
|
|
918
|
+
const rawFn = !isRemote
|
|
919
|
+
? undefined
|
|
920
|
+
: 'raw' in fn
|
|
921
|
+
? (fn as RemoteFunction<Args, Return>).raw
|
|
922
|
+
: (fn as RawRemoteFunction<Args>)
|
|
923
|
+
const key = isRemote
|
|
924
|
+
? keyForRemoteCall(rawFn!.method, rawFn!.url, args)
|
|
925
|
+
: producerKey(fn as Producer<Args, Return>, args)
|
|
926
|
+
const active = activeCacheStore()
|
|
927
|
+
active.subscribe(key)
|
|
928
|
+
active.trackLifecycle(key)
|
|
929
|
+
let entry = active.entries.get(key)
|
|
930
|
+
if (entry === undefined) {
|
|
931
|
+
const shared = sharedCacheStore()
|
|
932
|
+
shared.subscribe(key)
|
|
933
|
+
shared.trackLifecycle(key)
|
|
934
|
+
entry = shared.entries.get(key)
|
|
935
|
+
}
|
|
936
|
+
if (entry === undefined || entry.settled !== true || entry.value === undefined) {
|
|
937
|
+
return undefined
|
|
938
|
+
}
|
|
939
|
+
/* SSR congruence: the server materializes no cache value, so server-side peek is
|
|
940
|
+
uniformly undefined and the SSR render shows the fallback. Returning a snapshot-
|
|
941
|
+
seeded warm value here — mid-hydration — would diverge from that server text and
|
|
942
|
+
corrupt the claimed text node. Withhold it until the pass ends; the trackLifecycle
|
|
943
|
+
tap above lets wakeHydrationPeeks re-run this scope on the congruent value. */
|
|
944
|
+
if (hydratingSlot.active) {
|
|
945
|
+
return undefined
|
|
946
|
+
}
|
|
947
|
+
return cloneWarmValue(entry.value) as Return
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
cache.peek = peek
|
|
951
|
+
|
|
952
|
+
/*
|
|
953
|
+
Applies one entry's patch: materialize the current decoded value (warm value if
|
|
954
|
+
present, else decode the promise — a Response for a remote entry, cloned so the
|
|
955
|
+
readers' own clones still succeed), run the updater, store it warm, and emit so
|
|
956
|
+
readers re-read. Fire-and-forget on the async branch: patch() stays sync-return.
|
|
957
|
+
*/
|
|
958
|
+
function applyPatch(
|
|
959
|
+
store: CacheStore,
|
|
960
|
+
entry: CacheEntry,
|
|
961
|
+
updater: (current: unknown) => unknown,
|
|
962
|
+
): void {
|
|
963
|
+
function apply(current: unknown): void {
|
|
964
|
+
entry.value = updater(current)
|
|
965
|
+
notify(store, [entry.key], [entry.key])
|
|
966
|
+
}
|
|
967
|
+
if (entry.value !== undefined) {
|
|
968
|
+
apply(entry.value)
|
|
969
|
+
return
|
|
970
|
+
}
|
|
971
|
+
const currentValue =
|
|
972
|
+
entry.request !== undefined
|
|
973
|
+
? (entry.promise as Promise<Response>).then((response) =>
|
|
974
|
+
decodeResponse(response.clone()),
|
|
975
|
+
)
|
|
976
|
+
: (entry.promise as Promise<unknown>)
|
|
977
|
+
currentValue.then(apply, () => undefined)
|
|
978
|
+
}
|
|
979
|
+
|
|
606
980
|
/*
|
|
607
981
|
Event-driven cache maintenance: subscribes to a Subscribable (socket or rpc
|
|
608
982
|
stream) and runs `handler` once per frame — the declarative home for "this
|
|
@@ -647,6 +1021,11 @@ function on<T>(
|
|
|
647
1021
|
},
|
|
648
1022
|
signal: controller.signal,
|
|
649
1023
|
}
|
|
1024
|
+
/* Register-on-consume: consuming a named subscribable populates the probe registry so
|
|
1025
|
+
pending()/refreshing()/error()/done()(source) work — the stream-side analog of a call
|
|
1026
|
+
populating the cache store. Driven by this one loop, so watch(socket) / for-await all
|
|
1027
|
+
register through here. An unnamed source (rare) skips probe tracking. */
|
|
1028
|
+
const probe = source.name ? openStreamProbe(source.name) : undefined
|
|
650
1029
|
/* `let`: the reconnect path swaps in a fresh iterator; dispose closes the current one. */
|
|
651
1030
|
let iterator = source[Symbol.asyncIterator]()
|
|
652
1031
|
;(async () => {
|
|
@@ -659,18 +1038,25 @@ function on<T>(
|
|
|
659
1038
|
return
|
|
660
1039
|
}
|
|
661
1040
|
if (error instanceof SocketDisconnectedError) {
|
|
1041
|
+
probe?.reconnecting()
|
|
662
1042
|
coverage.forEach((replay) => {
|
|
663
1043
|
replay()
|
|
664
1044
|
})
|
|
665
1045
|
iterator = source[Symbol.asyncIterator]()
|
|
666
1046
|
continue
|
|
667
1047
|
}
|
|
1048
|
+
probe?.errored(error instanceof Error ? error : new Error(String(error)))
|
|
668
1049
|
abideLog.error(error)
|
|
669
1050
|
return
|
|
670
1051
|
}
|
|
671
|
-
if (controller.signal.aborted
|
|
1052
|
+
if (controller.signal.aborted) {
|
|
672
1053
|
return
|
|
673
1054
|
}
|
|
1055
|
+
if (next.done === true) {
|
|
1056
|
+
probe?.done()
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1059
|
+
probe?.frame()
|
|
674
1060
|
try {
|
|
675
1061
|
await handler(next.value, context)
|
|
676
1062
|
} catch (error) {
|
|
@@ -680,6 +1066,7 @@ function on<T>(
|
|
|
680
1066
|
})()
|
|
681
1067
|
return () => {
|
|
682
1068
|
controller.abort()
|
|
1069
|
+
probe?.close()
|
|
683
1070
|
iterator.return?.(undefined)?.catch(() => undefined)
|
|
684
1071
|
}
|
|
685
1072
|
}
|
|
@@ -766,11 +1153,23 @@ function fireRefetch(store: CacheStore, entry: CacheEntry): void {
|
|
|
766
1153
|
entry.promise = inflight
|
|
767
1154
|
entry.value = undefined
|
|
768
1155
|
entry.settled = true
|
|
1156
|
+
/* Re-materialize the retained value from the fresh Response so peek() stays
|
|
1157
|
+
current after a background revalidation. */
|
|
1158
|
+
if (entry.retain) {
|
|
1159
|
+
materializeRetained(store, entry, result)
|
|
1160
|
+
}
|
|
769
1161
|
/* Restart the freshness clock from the revalidation — without this the
|
|
770
|
-
entry keeps its original expiresAt and
|
|
771
|
-
despite holding fresh data. Mirrors registerEntry's
|
|
1162
|
+
entry keeps its original expiresAt and would read as stale (or be evicted
|
|
1163
|
+
at the old deadline) despite holding fresh data. Mirrors registerEntry's
|
|
1164
|
+
settle path: a retained smart read re-stamps the staleness deadline (the
|
|
1165
|
+
next read past it revalidates), an explicit-swr entry re-arms the eviction
|
|
1166
|
+
clock. */
|
|
772
1167
|
if (entry.ttl !== undefined && entry.ttl !== 0) {
|
|
773
|
-
|
|
1168
|
+
if (entry.retain) {
|
|
1169
|
+
stampStaleDeadline(entry, entry.ttl)
|
|
1170
|
+
} else {
|
|
1171
|
+
armTtlExpiry(store, entry, entry.ttl)
|
|
1172
|
+
}
|
|
774
1173
|
}
|
|
775
1174
|
/* Fresh value landed — mark and emit so readers re-read. */
|
|
776
1175
|
notify(store, [entry.key], [entry.key])
|
|
@@ -863,8 +1262,14 @@ function attachPolicy(
|
|
|
863
1262
|
entry: CacheEntry,
|
|
864
1263
|
options: CacheOptions | undefined,
|
|
865
1264
|
refetch: () => Promise<unknown>,
|
|
1265
|
+
defaultOn: boolean,
|
|
866
1266
|
): void {
|
|
867
|
-
|
|
1267
|
+
/* A smart read hitting a bare existing entry (e.g. one an explicit cache()
|
|
1268
|
+
created first) adopts unconditional retention too. */
|
|
1269
|
+
if (defaultOn) {
|
|
1270
|
+
entry.retain = true
|
|
1271
|
+
}
|
|
1272
|
+
const policy = swrWindow(options, defaultOn)
|
|
868
1273
|
if (entry.invalidation || !policy) {
|
|
869
1274
|
return
|
|
870
1275
|
}
|