@abide/abide 0.51.0 → 0.52.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 +509 -525
- package/CHANGELOG.md +29 -0
- package/README.md +128 -157
- package/package.json +4 -1
- package/src/abideResolverPlugin.ts +12 -0
- package/src/checkAbide.ts +60 -7
- package/src/lib/cli/completeCli.ts +38 -0
- package/src/lib/cli/printTopLevelHelp.ts +1 -0
- package/src/lib/cli/renderCliCompletions.ts +56 -0
- package/src/lib/cli/runCli.ts +27 -0
- package/src/lib/server/render.ts +70 -0
- package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
- package/src/lib/server/runtime/createServer.ts +43 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
- package/src/lib/server/runtime/pageRenderSlot.ts +15 -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/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/registerSocket.ts +12 -0
- package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
- package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
- package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
- package/src/lib/shared/cache.ts +32 -4
- package/src/lib/shared/cacheStalenessSlot.ts +21 -0
- package/src/lib/shared/createRpcServerProgram.ts +187 -0
- package/src/lib/shared/docSnapshotsSlot.ts +13 -0
- package/src/lib/shared/invalidate.ts +39 -0
- package/src/lib/shared/matcherFromEnvelope.ts +31 -0
- package/src/lib/shared/prepareRpcModule.ts +22 -3
- package/src/lib/shared/refresh.ts +9 -2
- package/src/lib/shared/selectorMatcher.ts +2 -15
- package/src/lib/shared/serializeSelector.ts +69 -0
- package/src/lib/shared/setsIntersect.ts +15 -0
- package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
- package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
- package/src/lib/shared/types/DocSnapshots.ts +12 -0
- package/src/lib/shared/types/RemoteFunction.ts +11 -8
- package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
- package/src/lib/shared/types/SsrPayload.ts +5 -0
- package/src/lib/test/createTestApp.ts +15 -0
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +92 -14
- package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
- package/src/lib/ui/compile/lowerScript.ts +6 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
- package/src/lib/ui/createScope.ts +42 -1
- package/src/lib/ui/dom/assertClaimedText.ts +14 -4
- package/src/lib/ui/dom/attr.ts +25 -2
- package/src/lib/ui/dom/writeCell.ts +22 -0
- package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
- package/src/lib/ui/runtime/claimExpected.ts +6 -1
- package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
- package/src/lib/ui/startClient.ts +22 -1
- package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
- package/src/serverEntry.ts +12 -0
|
@@ -109,6 +109,20 @@ export type RpcServerProgram = {
|
|
|
109
109
|
it.
|
|
110
110
|
*/
|
|
111
111
|
inputSchemaForModule(modulePath: string): Record<string, unknown> | undefined
|
|
112
|
+
/*
|
|
113
|
+
The top-level statements the CLIENT rpc module must retain — reachability rooted at the endpoint
|
|
114
|
+
`opts` argument, resolved through the binder/checker rather than by scanning source text. On the
|
|
115
|
+
client an rpc is only a `remoteProxy` fetch, so the handler and every declaration/import only it
|
|
116
|
+
reaches is dead; but `opts` (schemas/cache/stream) is a LIVE expression that may reference
|
|
117
|
+
imported values and module-level consts (e.g. a `const inputSchema = z.object(...)` under
|
|
118
|
+
`schemas.input`), so those — and what THEY reach — must survive. Returns each kept statement's
|
|
119
|
+
source text in source order (imports + declarations transitively reachable from `opts`),
|
|
120
|
+
EXCLUDING the exported rpc statement itself (it becomes the `remoteProxy` line) and, naturally,
|
|
121
|
+
the rpc-helper import (`opts` never names it). An endpoint with no `opts` returns `[]` — a bare
|
|
122
|
+
`remoteProxy` needs nothing. undefined when the module or its rpc call can't be resolved, so the
|
|
123
|
+
caller falls open to the keep-the-file char-scan rewrite (ADR-0022 D2 legacy path).
|
|
124
|
+
*/
|
|
125
|
+
clientKeepForModule(modulePath: string): string[] | undefined
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
/*
|
|
@@ -174,7 +188,180 @@ export function createRpcServerProgram(cwd: string, rpcDir: string): RpcServerPr
|
|
|
174
188
|
inputSchemaForModule(modulePath) {
|
|
175
189
|
return query(modulePath, (call) => inputArgsJsonSchema(checker, call.node))
|
|
176
190
|
},
|
|
191
|
+
clientKeepForModule(modulePath) {
|
|
192
|
+
/* Needs the SourceFile alongside the call (statement walk), so it resolves both itself
|
|
193
|
+
rather than going through `query` (which yields only the call). Same fail-open
|
|
194
|
+
contract: an unresolved module / any checker throw yields undefined. */
|
|
195
|
+
try {
|
|
196
|
+
const sourceFile = program.getSourceFile(modulePath)
|
|
197
|
+
if (sourceFile === undefined) {
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
const call = resolveRpcCall(checker, sourceFile)
|
|
201
|
+
if (call === undefined) {
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
return clientKeepStatements(checker, sourceFile, call.node)
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/*
|
|
213
|
+
The top-level statements the client rpc module retains, rooted at the endpoint `opts` argument
|
|
214
|
+
(ADR-0022 addendum). Builds a reachability graph over the module's top-level bindings — each binding
|
|
215
|
+
symbol → its declaring statement, plus which top-level symbols each statement references — then
|
|
216
|
+
marks every statement reachable from `opts` and returns the marked ones' source text in source
|
|
217
|
+
order. The exported rpc statement is excluded (it becomes the `remoteProxy` line); everything the
|
|
218
|
+
handler alone reaches is simply never marked, so it drops out.
|
|
219
|
+
*/
|
|
220
|
+
function clientKeepStatements(
|
|
221
|
+
checker: ts.TypeChecker,
|
|
222
|
+
sourceFile: ts.SourceFile,
|
|
223
|
+
call: ts.CallExpression,
|
|
224
|
+
): string[] {
|
|
225
|
+
const opts = call.arguments[1]
|
|
226
|
+
// No opts → the client module needs nothing but the bare remoteProxy call.
|
|
227
|
+
if (opts === undefined) {
|
|
228
|
+
return []
|
|
229
|
+
}
|
|
230
|
+
// The exported rpc statement becomes the remoteProxy line, so it is never a retained statement.
|
|
231
|
+
const exportStatement = enclosingTopLevelStatement(call, sourceFile)
|
|
232
|
+
// Every top-level binding symbol → the statement that declares it (the export statement's own
|
|
233
|
+
// bindings excluded so a self-reference can't re-admit it).
|
|
234
|
+
const declaringStatement = new Map<ts.Symbol, ts.Statement>()
|
|
235
|
+
for (const statement of sourceFile.statements) {
|
|
236
|
+
if (statement === exportStatement) {
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
for (const symbol of topLevelBindingSymbols(checker, statement)) {
|
|
240
|
+
declaringStatement.set(symbol, statement)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Mark from opts outward: a statement is kept once any of its bindings is referenced by opts or
|
|
244
|
+
// by an already-kept statement (transitive closure via the work queue).
|
|
245
|
+
const kept = new Set<ts.Statement>()
|
|
246
|
+
const queue: ts.Node[] = [opts]
|
|
247
|
+
while (queue.length > 0) {
|
|
248
|
+
const node = queue.pop() as ts.Node
|
|
249
|
+
for (const symbol of referencedTopLevelSymbols(checker, node, declaringStatement)) {
|
|
250
|
+
const statement = declaringStatement.get(symbol)
|
|
251
|
+
if (statement !== undefined && !kept.has(statement)) {
|
|
252
|
+
kept.add(statement)
|
|
253
|
+
queue.push(statement)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const texts: string[] = []
|
|
258
|
+
for (const statement of sourceFile.statements) {
|
|
259
|
+
if (kept.has(statement)) {
|
|
260
|
+
texts.push(statement.getText(sourceFile))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return texts
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* The top-level statement enclosing `node` — walk parents until the one whose parent is the source
|
|
267
|
+
file. */
|
|
268
|
+
function enclosingTopLevelStatement(node: ts.Node, sourceFile: ts.SourceFile): ts.Statement {
|
|
269
|
+
let current: ts.Node = node
|
|
270
|
+
while (current.parent !== undefined && current.parent !== sourceFile) {
|
|
271
|
+
current = current.parent
|
|
272
|
+
}
|
|
273
|
+
return current as ts.Statement
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/*
|
|
277
|
+
The binding symbols a top-level statement introduces — an import's clause names (default /
|
|
278
|
+
namespace / each named element), a variable statement's declaration names (destructuring patterns
|
|
279
|
+
walked to their leaves), and a function/class declaration's name. A statement that binds nothing
|
|
280
|
+
(a bare side-effect `import './x'`, an expression statement) contributes no symbol and so is never
|
|
281
|
+
reachable from opts — exactly the server-only setup the client drops.
|
|
282
|
+
*/
|
|
283
|
+
function topLevelBindingSymbols(checker: ts.TypeChecker, statement: ts.Statement): ts.Symbol[] {
|
|
284
|
+
const names: ts.Node[] = []
|
|
285
|
+
if (ts.isImportDeclaration(statement)) {
|
|
286
|
+
const clause = statement.importClause
|
|
287
|
+
if (clause?.name !== undefined) {
|
|
288
|
+
names.push(clause.name)
|
|
289
|
+
}
|
|
290
|
+
const bindings = clause?.namedBindings
|
|
291
|
+
if (bindings !== undefined) {
|
|
292
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
293
|
+
names.push(bindings.name)
|
|
294
|
+
} else {
|
|
295
|
+
for (const element of bindings.elements) {
|
|
296
|
+
names.push(element.name)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else if (ts.isVariableStatement(statement)) {
|
|
301
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
302
|
+
collectBindingNames(declaration.name, names)
|
|
303
|
+
}
|
|
304
|
+
} else if (
|
|
305
|
+
(ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
306
|
+
statement.name !== undefined
|
|
307
|
+
) {
|
|
308
|
+
names.push(statement.name)
|
|
309
|
+
}
|
|
310
|
+
const symbols: ts.Symbol[] = []
|
|
311
|
+
for (const name of names) {
|
|
312
|
+
const symbol = checker.getSymbolAtLocation(name)
|
|
313
|
+
if (symbol !== undefined) {
|
|
314
|
+
symbols.push(symbol)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return symbols
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* The leaf identifiers of a binding name — a plain identifier is itself; a destructuring pattern
|
|
321
|
+
(`{ a, b: { c } }`, `[x, ...rest]`) descends to each bound identifier. */
|
|
322
|
+
function collectBindingNames(name: ts.BindingName, out: ts.Node[]): void {
|
|
323
|
+
if (ts.isIdentifier(name)) {
|
|
324
|
+
out.push(name)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
for (const element of name.elements) {
|
|
328
|
+
if (ts.isBindingElement(element)) {
|
|
329
|
+
collectBindingNames(element.name, out)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/*
|
|
335
|
+
The top-level binding symbols referenced within `node` — every identifier in the subtree whose
|
|
336
|
+
resolved symbol is a known top-level binding. An import alias resolves to the same local symbol at
|
|
337
|
+
its use site and its declaration, so uses match declarations; a property name (`obj.map`) resolves
|
|
338
|
+
to a property symbol, not a top-level one, so it can't false-match. Over-inclusion is safe (it only
|
|
339
|
+
keeps more), so a bare identifier scan needs no scope analysis.
|
|
340
|
+
*/
|
|
341
|
+
function referencedTopLevelSymbols(
|
|
342
|
+
checker: ts.TypeChecker,
|
|
343
|
+
node: ts.Node,
|
|
344
|
+
declaringStatement: Map<ts.Symbol, ts.Statement>,
|
|
345
|
+
): ts.Symbol[] {
|
|
346
|
+
const found: ts.Symbol[] = []
|
|
347
|
+
const consider = (symbol: ts.Symbol | undefined): void => {
|
|
348
|
+
if (symbol !== undefined && declaringStatement.has(symbol)) {
|
|
349
|
+
found.push(symbol)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const visit = (current: ts.Node): void => {
|
|
353
|
+
// A shorthand `{ schemas }` resolves to the object PROPERTY symbol at its location, not the
|
|
354
|
+
// referenced binding — so read the value symbol explicitly, else the used import is missed
|
|
355
|
+
// (the one unsafe direction: a dropped-but-needed statement).
|
|
356
|
+
if (ts.isShorthandPropertyAssignment(current)) {
|
|
357
|
+
consider(checker.getShorthandAssignmentValueSymbol(current))
|
|
358
|
+
} else if (ts.isIdentifier(current)) {
|
|
359
|
+
consider(checker.getSymbolAtLocation(current))
|
|
360
|
+
}
|
|
361
|
+
ts.forEachChild(current, visit)
|
|
177
362
|
}
|
|
363
|
+
visit(node)
|
|
364
|
+
return found
|
|
178
365
|
}
|
|
179
366
|
|
|
180
367
|
type ResolvedRpcCall = {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createResolverSlot } from './createResolverSlot.ts'
|
|
2
|
+
import type { DocSnapshots } from './types/DocSnapshots.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The active doc-snapshot slot — mirrors `resolvedCellsSlot`. The server entry installs an ALS-backed
|
|
6
|
+
resolver (a fresh list per request, so concurrent SSR renders never mix their doc seeds); with no
|
|
7
|
+
resolver registered a single fallback list is created lazily so isolated tests work without booting
|
|
8
|
+
the runtime. `createScope` pushes a `{ id, take }` for each rendered scope server-side; the page
|
|
9
|
+
renderer reads it at render-return to stamp `__SSR__.docs`.
|
|
10
|
+
*/
|
|
11
|
+
export const docSnapshotsSlot = createResolverSlot<DocSnapshots>(() => ({
|
|
12
|
+
entries: [],
|
|
13
|
+
}))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cacheStalenessSlot } from './cacheStalenessSlot.ts'
|
|
2
|
+
import { isAsyncCell } from './isAsyncCell.ts'
|
|
3
|
+
import type { AsyncComputed } from './types/AsyncComputed.ts'
|
|
4
|
+
import type { CacheSelector } from './types/CacheSelector.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Drop every cached read matching the selector so the next read reloads it lazily —
|
|
8
|
+
the drop verb, distinct from refresh (which refetches now, keeping the stale value
|
|
9
|
+
visible). A mounted RETAINED reader revalidates stale-in-place under invalidate too
|
|
10
|
+
(its retain+invalidation policy keeps the value and refetches); a background /
|
|
11
|
+
non-retained entry (an explicit `cache()`, a producer, a reader-less entry) is
|
|
12
|
+
dropped and refetches only when re-read. Follows the shared selector grammar:
|
|
13
|
+
|
|
14
|
+
invalidate(getFoo, args) → that exact call
|
|
15
|
+
invalidate(getFoo) → every args-variant of that rpc
|
|
16
|
+
invalidate({ tags }) → every entry sharing a tag
|
|
17
|
+
invalidate() → every entry
|
|
18
|
+
|
|
19
|
+
Instance sugar `getFoo.invalidate(args?)` ≡ `invalidate(getFoo, args?)`.
|
|
20
|
+
|
|
21
|
+
Isomorphic (ADR-0041): applied LOCALLY on the client, and on the SERVER it
|
|
22
|
+
broadcasts to every connected client (each applies the local drop). The side-swap
|
|
23
|
+
rides the cacheStalenessSlot resolver — this source is byte-identical on both
|
|
24
|
+
sides. `invalidate(asyncCell)` aliases `cell.refresh()` (a cell has no droppable
|
|
25
|
+
entry; its staleness is re-running its seed), matching `refresh(cell)`.
|
|
26
|
+
*/
|
|
27
|
+
// @documentation cache
|
|
28
|
+
export function invalidate<Args, Return>(
|
|
29
|
+
arg?: CacheSelector<Args, Return> | AsyncComputed<unknown>,
|
|
30
|
+
args?: Args,
|
|
31
|
+
): void {
|
|
32
|
+
/* An async cell re-invokes its own seed (`invalidate(cell)` ≡ `cell.refresh()`). */
|
|
33
|
+
if (isAsyncCell(arg)) {
|
|
34
|
+
arg.refresh()
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
/* Side-swap: the client entry applies locally; the server entry broadcasts. */
|
|
38
|
+
cacheStalenessSlot.get()?.('invalidate', arg as CacheSelector<Args, Return>, args)
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { keyMatchesPrefix } from './keyMatchesPrefix.ts'
|
|
2
|
+
import { setsIntersect } from './setsIntersect.ts'
|
|
3
|
+
import { toTagSet } from './toTagSet.ts'
|
|
4
|
+
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
5
|
+
import type { CacheStalenessFrame } from './types/CacheStalenessFrame.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Rebuilds the entry predicate from a decoded staleness envelope (ADR-0041) — the
|
|
9
|
+
decode half of serializeSelector, driving the SAME store loop a local apply does
|
|
10
|
+
(cache.invalidateMatching / cache.refreshMatching). The three modes are
|
|
11
|
+
selectorMatcher's three real branches with the `args !== undefined` discriminant
|
|
12
|
+
replaced by the explicit `mode`, reusing keyMatchesPrefix/toTagSet so a decoded
|
|
13
|
+
predicate can't disagree with what a local selector would have matched:
|
|
14
|
+
|
|
15
|
+
key → exactly that call's entry (args already the canonical string the read
|
|
16
|
+
path keyed with, so it re-matches by equality — no ref-json codec crosses)
|
|
17
|
+
prefix → every args-variant of that fn (method+url prefix)
|
|
18
|
+
tags → any entry sharing one of the requested tags
|
|
19
|
+
*/
|
|
20
|
+
export function matcherFromEnvelope(frame: CacheStalenessFrame): (entry: CacheEntry) => boolean {
|
|
21
|
+
if (frame.mode === 'key') {
|
|
22
|
+
const key = frame.match
|
|
23
|
+
return (entry) => entry.key === key
|
|
24
|
+
}
|
|
25
|
+
if (frame.mode === 'prefix') {
|
|
26
|
+
const prefix = frame.match
|
|
27
|
+
return (entry) => keyMatchesPrefix(entry.key, prefix)
|
|
28
|
+
}
|
|
29
|
+
const requestedTags = toTagSet(frame.tags)
|
|
30
|
+
return (entry) => entry.tags !== undefined && setsIntersect(requestedTags, entry.tags)
|
|
31
|
+
}
|
|
@@ -118,7 +118,6 @@ export function prepareRpcModule(
|
|
|
118
118
|
remoteProxy reads only streaming/cache/stream off the opts and ignores the rest.
|
|
119
119
|
*/
|
|
120
120
|
rewriteForClient(url: string): string {
|
|
121
|
-
const callHead = `${stripped.slice(0, site.callStart)}${REMOTE_PROXY_GLOBAL}(${JSON.stringify(method)}, ${JSON.stringify(url)}`
|
|
122
121
|
const opts = argParts[1]
|
|
123
122
|
/* Build-injected CLIENT opts: `streaming` (build-derived from the elided handler body)
|
|
124
123
|
and the `outputWirePlan` (the handler's structured success fields → the client revives
|
|
@@ -142,8 +141,28 @@ export function prepareRpcModule(
|
|
|
142
141
|
? `, { ${injectedText} }`
|
|
143
142
|
: `, { ${injectedText}, ...(${opts}) }`
|
|
144
143
|
}
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
const remoteCall = `${REMOTE_PROXY_GLOBAL}(${JSON.stringify(method)}, ${JSON.stringify(url)}${argsText})`
|
|
145
|
+
/*
|
|
146
|
+
Minimal emit (ADR-0022 addendum): the warm program resolved which top-level statements
|
|
147
|
+
the live `opts` actually reaches, so emit ONLY those plus the `remoteProxy` export. The
|
|
148
|
+
handler and every declaration/import only it used are never emitted — nothing server-side
|
|
149
|
+
is loaded, tree-shaken, or flagged, matching "the client is only ever a fetch". An empty
|
|
150
|
+
plan (no opts, or opts references nothing top-level) emits the bare call alone.
|
|
151
|
+
*/
|
|
152
|
+
const clientKeep = stamps.clientKeep
|
|
153
|
+
if (clientKeep !== undefined) {
|
|
154
|
+
const exportLine = `export const ${site.exportName} = ${remoteCall}`
|
|
155
|
+
return clientKeep.length > 0
|
|
156
|
+
? `${clientKeep.join('\n')}\n${exportLine}\n`
|
|
157
|
+
: `${exportLine}\n`
|
|
158
|
+
}
|
|
159
|
+
/*
|
|
160
|
+
Fallback (no warm program): keep the real module, swap the METHOD( call for the
|
|
161
|
+
remoteProxy( call, and elide the handler argument — leaning on the bundler's DCE to drop
|
|
162
|
+
the dead server imports. `stripped.slice(0, site.callStart)` keeps the imports + the
|
|
163
|
+
`export const <name> = ` head; `stripped.slice(site.parenEnd)` keeps any trailing content.
|
|
164
|
+
*/
|
|
165
|
+
const callHead = `${stripped.slice(0, site.callStart)}${REMOTE_PROXY_GLOBAL}(${JSON.stringify(method)}, ${JSON.stringify(url)}`
|
|
147
166
|
return `${callHead}${argsText}${stripped.slice(site.parenEnd)}`
|
|
148
167
|
},
|
|
149
168
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cacheStalenessSlot } from './cacheStalenessSlot.ts'
|
|
2
2
|
import { isAsyncCell } from './isAsyncCell.ts'
|
|
3
3
|
import type { AsyncComputed } from './types/AsyncComputed.ts'
|
|
4
4
|
import type { CacheSelector } from './types/CacheSelector.ts'
|
|
@@ -17,6 +17,12 @@ pending blank. Follows the shared selector grammar:
|
|
|
17
17
|
|
|
18
18
|
Instance sugar `getFoo.refresh(args?)` ≡ `refresh(getFoo, args?)`. Reports, never
|
|
19
19
|
retains a spinner — pair with `refreshing()` to surface the in-flight reload.
|
|
20
|
+
|
|
21
|
+
Isomorphic (ADR-0041): applied LOCALLY on the client, and on the SERVER it now
|
|
22
|
+
broadcasts to every connected client (each refetches eagerly) instead of mutating a
|
|
23
|
+
throwaway request store that reached no browser. The side-swap rides the
|
|
24
|
+
cacheStalenessSlot resolver — this source is byte-identical on both sides. The
|
|
25
|
+
paired drop verb is `invalidate`.
|
|
20
26
|
*/
|
|
21
27
|
// @documentation cache
|
|
22
28
|
export function refresh<Args, Return>(
|
|
@@ -28,5 +34,6 @@ export function refresh<Args, Return>(
|
|
|
28
34
|
arg.refresh()
|
|
29
35
|
return
|
|
30
36
|
}
|
|
31
|
-
|
|
37
|
+
/* Side-swap: the client entry applies locally; the server entry broadcasts. */
|
|
38
|
+
cacheStalenessSlot.get()?.('refresh', arg as CacheSelector<Args, Return>, args)
|
|
32
39
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { keyMatchesPrefix } from './keyMatchesPrefix.ts'
|
|
2
2
|
import { selectorPrefix } from './selectorPrefix.ts'
|
|
3
|
+
import { setsIntersect } from './setsIntersect.ts'
|
|
3
4
|
import { toTagSet } from './toTagSet.ts'
|
|
4
5
|
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
5
6
|
import type { CacheSelector } from './types/CacheSelector.ts'
|
|
@@ -50,19 +51,5 @@ export function selectorMatcher<Args, Return>(
|
|
|
50
51
|
return () => false
|
|
51
52
|
}
|
|
52
53
|
const requestedTags = toTagSet(arg.tags)
|
|
53
|
-
return (entry) => entry.tags !== undefined &&
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/*
|
|
57
|
-
True when an entry's tags and the requested tags overlap on any tag. A plain
|
|
58
|
-
for-of over the Set rather than .values().some() — Iterator Helpers are too new
|
|
59
|
-
for this module's browser baseline (see producerKey).
|
|
60
|
-
*/
|
|
61
|
-
function intersects(entryTags: Set<string>, requestedTags: Set<string>): boolean {
|
|
62
|
-
for (const tag of requestedTags) {
|
|
63
|
-
if (entryTags.has(tag)) {
|
|
64
|
-
return true
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return false
|
|
54
|
+
return (entry) => entry.tags !== undefined && setsIntersect(requestedTags, entry.tags)
|
|
68
55
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { keyForRemoteCall } from './keyForRemoteCall.ts'
|
|
2
|
+
import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
|
|
3
|
+
import { REMOTE_FUNCTION } from './REMOTE_FUNCTION.ts'
|
|
4
|
+
import { toTagSet } from './toTagSet.ts'
|
|
5
|
+
import type { CacheSelector } from './types/CacheSelector.ts'
|
|
6
|
+
import type { CacheStalenessFrame } from './types/CacheStalenessFrame.ts'
|
|
7
|
+
import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Encodes a CacheSelector into the cross-client staleness envelope (ADR-0041) — the
|
|
11
|
+
single authoring site for the on-wire selector, reusing the same encoders the read
|
|
12
|
+
path keys with (keyForRemoteCall / keyPrefixForRemote) so it can never drift from
|
|
13
|
+
selectorMatcher. The three real branches map 1:1 onto the envelope's `mode`:
|
|
14
|
+
|
|
15
|
+
fn + args → { mode: 'key', match: keyForRemoteCall(method, url, args) }
|
|
16
|
+
fn → { mode: 'prefix', match: keyPrefixForRemote(method, url) }
|
|
17
|
+
{ tags } → { mode: 'tags', tags }
|
|
18
|
+
|
|
19
|
+
A producer/closure selector mints a per-process ref id (selectorPrefix) that no
|
|
20
|
+
other client shares, and a bare/undefined match-all would nuke every client's whole
|
|
21
|
+
cache — both are NOT cross-client serializable, so they THROW here rather than
|
|
22
|
+
broadcast. The caller (the server broadcaster) surfaces the throw as a programming
|
|
23
|
+
error at the mutation site.
|
|
24
|
+
*/
|
|
25
|
+
export function serializeSelector<Args, Return>(
|
|
26
|
+
op: CacheStalenessFrame['op'],
|
|
27
|
+
arg?: CacheSelector<Args, Return>,
|
|
28
|
+
args?: Args,
|
|
29
|
+
): CacheStalenessFrame {
|
|
30
|
+
if (arg === undefined) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`[abide] ${op}(): a bare match-all selector cannot broadcast across clients — it would drop every client's whole cache. Pass a remote function or { tags }.`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
if (typeof arg === 'function') {
|
|
36
|
+
/* Only a remote function carries wire identity (method+url, stable across clients);
|
|
37
|
+
a plain producer's key is a per-process ref id that no other client shares. */
|
|
38
|
+
if (!(REMOTE_FUNCTION in arg)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[abide] ${op}(): a producer/closure selector is not cross-client serializable (its key is a per-process reference id). Broadcast a remote function or { tags }.`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
const remote = arg as RawRemoteFunction<Args>
|
|
44
|
+
if (args === undefined) {
|
|
45
|
+
return {
|
|
46
|
+
op,
|
|
47
|
+
mode: 'prefix',
|
|
48
|
+
match: keyPrefixForRemote(remote.method, remote.url),
|
|
49
|
+
tags: [],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
op,
|
|
54
|
+
mode: 'key',
|
|
55
|
+
match: keyForRemoteCall(remote.method, remote.url, args),
|
|
56
|
+
tags: [],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/* Reject an empty tag set too, not just an absent `tags`: an empty-array selector
|
|
60
|
+
matches nothing locally, so broadcasting it would fan a match-nothing frame out to
|
|
61
|
+
every peer (a wasted store scan). Compute the set once and reuse it. */
|
|
62
|
+
const tags = arg.tags === undefined ? undefined : toTagSet(arg.tags)
|
|
63
|
+
if (tags === undefined || tags.size === 0) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`[abide] ${op}(): a { tags } selector must list at least one tag to broadcast.`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
return { op, mode: 'tags', match: '', tags: [...tags] }
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
True when two sets share at least one member — a plain for-of over the first set
|
|
3
|
+
rather than `.values().some()` (Iterator Helpers are too new for this module's
|
|
4
|
+
browser baseline; see producerKey). Pass the smaller set first. Shared by
|
|
5
|
+
selectorMatcher (local selectors) and matcherFromEnvelope (wire-decoded frames) so
|
|
6
|
+
the two tag predicates can't drift.
|
|
7
|
+
*/
|
|
8
|
+
export function setsIntersect<T>(a: Set<T>, b: Set<T>): boolean {
|
|
9
|
+
for (const value of a) {
|
|
10
|
+
if (b.has(value)) {
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CacheSelector } from './CacheSelector.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The side-swappable staleness applier stored in cacheStalenessSlot (ADR-0041): given
|
|
5
|
+
a verb and a (non-async-cell) selector, either apply it locally (client / fallback)
|
|
6
|
+
or broadcast it (server). The async-cell short-circuit is handled in invalidate() /
|
|
7
|
+
refresh() before the slot, so this only ever sees a real CacheSelector.
|
|
8
|
+
*/
|
|
9
|
+
export type CacheStalenessApply = <Args, Return>(
|
|
10
|
+
op: 'invalidate' | 'refresh',
|
|
11
|
+
selector: CacheSelector<Args, Return>,
|
|
12
|
+
args?: Args,
|
|
13
|
+
) => void
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The on-wire envelope for a cross-client staleness signal (ADR-0041). A single
|
|
3
|
+
monomorphic shape — every field always present so the JIT keeps it a stable
|
|
4
|
+
hidden class — carried as the `message` of a standard socket frame on the
|
|
5
|
+
reserved `__abide/cache` topic.
|
|
6
|
+
|
|
7
|
+
op — which local verb each subscriber runs: drop (`invalidate`) or refetch
|
|
8
|
+
(`refresh`).
|
|
9
|
+
mode — how `match`/`tags` select entries, mapping 1:1 onto selectorMatcher's
|
|
10
|
+
three real branches: an exact call key, a fn key prefix, or a tag set.
|
|
11
|
+
match — the exact cache key (`mode: 'key'`), the key prefix (`mode: 'prefix'`),
|
|
12
|
+
or `''` (`mode: 'tags'`, unused).
|
|
13
|
+
tags — the requested tag list (`mode: 'tags'`), or `[]` otherwise.
|
|
14
|
+
|
|
15
|
+
Producer/closure selectors and the bare match-all form are NOT expressible here
|
|
16
|
+
— they are rejected at encode time (serializeSelector), so this envelope only
|
|
17
|
+
ever carries a cross-client-stable selector.
|
|
18
|
+
*/
|
|
19
|
+
export interface CacheStalenessFrame {
|
|
20
|
+
op: 'invalidate' | 'refresh'
|
|
21
|
+
mode: 'key' | 'prefix' | 'tags'
|
|
22
|
+
match: string
|
|
23
|
+
tags: string[]
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The per-request list of reactive-document snapshots captured during an SSR render, each keyed by
|
|
3
|
+
its scope's serialization-stable render-path id. A rendered scope registers a lazy `take` at
|
|
4
|
+
creation; the page renderer calls it at render-return (after the synchronous `state` inits have
|
|
5
|
+
run), encodes the non-empty result (ref-json) into `__SSR__.docs`, and the client seeds it so a
|
|
6
|
+
plain `state(initial)` — a uuid, a timestamp — hydrates to the SERVER's value instead of
|
|
7
|
+
recomputing a divergent one. Sibling of `ResolvedCells` (async-cell values); this holds the
|
|
8
|
+
synchronous document state, taken lazily rather than at settle.
|
|
9
|
+
*/
|
|
10
|
+
export type DocSnapshots = {
|
|
11
|
+
entries: { id: string; take: () => unknown }[]
|
|
12
|
+
}
|
|
@@ -63,17 +63,20 @@ export type RemoteFunction<
|
|
|
63
63
|
the rpc's own spec. */
|
|
64
64
|
readonly isError: RpcErrorGuard<Errors>
|
|
65
65
|
/* Pre-bound selector sugar: `fn.pending(args?)` ≡ `pending(fn, args?)`, and likewise for
|
|
66
|
-
refreshing / refresh / peek — the rpc is the leading selector, bound in. The
|
|
67
|
-
this rpc's typed `Args` (the by-args refinement); tags / cross-cutting selection
|
|
68
|
-
the globals. The bare call `fn(args)` IS the cached read (was `fn.cache`) — all cache
|
|
69
|
-
policy lives on the definition, so there is no second options argument
|
|
70
|
-
`
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
refreshing / refresh / invalidate / peek — the rpc is the leading selector, bound in. The
|
|
67
|
+
argument is this rpc's typed `Args` (the by-args refinement); tags / cross-cutting selection
|
|
68
|
+
stay on the globals. The bare call `fn(args)` IS the cached read (was `fn.cache`) — all cache
|
|
69
|
+
policy lives on the definition, so there is no second options argument. The two staleness
|
|
70
|
+
verbs are distinct again (ADR-0041): `invalidate(args?)` DROPS the entry so the next read
|
|
71
|
+
reloads lazily, while `refresh(args?)` REFETCHES now keeping the stale value visible — both
|
|
72
|
+
apply locally on the client and broadcast to every connected client from the server.
|
|
73
|
+
`peek(args?)` reads the retained value synchronously (a streaming rpc peeks its latest
|
|
74
|
+
frame). `patch` is fetch-only, so it is omitted for a streaming rpc (a stream isn't a
|
|
75
|
+
memoized value) via the intersection below. */
|
|
74
76
|
pending(args?: Args): boolean
|
|
75
77
|
refreshing(args?: Args): boolean
|
|
76
78
|
refresh(args?: Args): void
|
|
79
|
+
invalidate(args?: Args): void
|
|
77
80
|
peek(args?: Args): ([Return] extends [AsyncIterable<infer Frame>] ? Frame : Return) | undefined
|
|
78
81
|
/* This rpc's last error, typed off `Errors` (a discriminated union already narrowed on
|
|
79
82
|
`.kind`/`.data`). `args` scopes to one call; omitted aggregates the most-recent across
|
|
@@ -12,6 +12,14 @@ unresolvable node), and the rewrite falls open to its char-scan / today's behavi
|
|
|
12
12
|
before. `streaming` overrides the char-scan streaming verdict; the rest are stamped as server opts
|
|
13
13
|
(`coercion`→`coerce`, `inputSchema`→`inputJsonSchema`, `outputSchema`→`outputJsonSchema`,
|
|
14
14
|
`errorSchemas`→`errorJsonSchemas`) or the client opt (`outputWirePlan`).
|
|
15
|
+
|
|
16
|
+
`clientKeep` is the reachability plan for the CLIENT rewrite: the source text of the top-level
|
|
17
|
+
statements the emitted client module must retain (imports + declarations transitively reachable from
|
|
18
|
+
the endpoint `opts`), computed through the binder/checker. Present → `rewriteForClient` emits a
|
|
19
|
+
MINIMAL module (`remoteProxy(opts)` plus only those statements), so the handler and every
|
|
20
|
+
declaration/import only it reaches is never emitted — nothing server-side is loaded, tree-shaken, or
|
|
21
|
+
flagged (superseding ADR-0022 D2/D3's "keep the file, trust DCE"). Absent (no warm program /
|
|
22
|
+
unresolvable rpc call) falls open to the keep-the-file char-scan rewrite.
|
|
15
23
|
*/
|
|
16
24
|
export type RpcBuildStamps = {
|
|
17
25
|
streaming?: boolean
|
|
@@ -20,4 +28,5 @@ export type RpcBuildStamps = {
|
|
|
20
28
|
outputSchema?: Record<string, unknown>
|
|
21
29
|
errorSchemas?: ErrorJsonSchemas
|
|
22
30
|
outputWirePlan?: OutputWirePlan
|
|
31
|
+
clientKeep?: string[]
|
|
23
32
|
}
|
|
@@ -22,4 +22,9 @@ export type SsrPayload = SsrBootState & {
|
|
|
22
22
|
/* Async-cell values that resolved during SSR, keyed by render-path id → ref-json string.
|
|
23
23
|
`startClient` seeds these into `CELL_SEED`; a hydrating cell reads its key to warm-hydrate. */
|
|
24
24
|
cells?: Record<string, string>
|
|
25
|
+
/* Reactive-document snapshots that carried synchronous `state` during SSR, keyed by render-path
|
|
26
|
+
id → ref-json string. `startClient` seeds these into `DOC_SEED`; a hydrating scope's first
|
|
27
|
+
write to each slot adopts the server value, so a plain `state(uuid())`/`state(Date.now())`
|
|
28
|
+
keeps the SSR value instead of recomputing a divergent one. */
|
|
29
|
+
docs?: Record<string, string>
|
|
25
30
|
}
|
|
@@ -26,7 +26,9 @@ import { shell } from '../../_virtual/shell.ts'
|
|
|
26
26
|
// @ts-expect-error virtual module resolved by abideResolverPlugin
|
|
27
27
|
import { sockets } from '../../_virtual/sockets.ts'
|
|
28
28
|
import { rpcRegistry } from '../server/rpc/rpcRegistry.ts'
|
|
29
|
+
import { broadcastCacheStaleness } from '../server/runtime/cacheStalenessBroadcaster.ts'
|
|
29
30
|
import { createServer } from '../server/runtime/createServer.ts'
|
|
31
|
+
import { pageRenderSlot } from '../server/runtime/pageRenderSlot.ts'
|
|
30
32
|
import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
|
|
31
33
|
import { requestContext } from '../server/runtime/requestContext.ts'
|
|
32
34
|
import { resolvePageSnapshot } from '../server/runtime/resolvePageSnapshot.ts'
|
|
@@ -34,9 +36,11 @@ import { serverSlot } from '../server/runtime/serverSlot.ts'
|
|
|
34
36
|
import { baseSlot } from '../shared/baseSlot.ts'
|
|
35
37
|
import { buildRpcProxy } from '../shared/buildRpcProxy.ts'
|
|
36
38
|
import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
|
|
39
|
+
import { cacheStalenessSlot } from '../shared/cacheStalenessSlot.ts'
|
|
37
40
|
import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
|
|
38
41
|
import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
|
|
39
42
|
import { createCacheStore } from '../shared/createCacheStore.ts'
|
|
43
|
+
import { docSnapshotsSlot } from '../shared/docSnapshotsSlot.ts'
|
|
40
44
|
import { HEALTH_PATH } from '../shared/HEALTH_PATH.ts'
|
|
41
45
|
import { pageSlot } from '../shared/pageSlot.ts'
|
|
42
46
|
import { pendingAsyncCellsSlot } from '../shared/pendingAsyncCellsSlot.ts'
|
|
@@ -107,7 +111,10 @@ export async function createTestApp(): Promise<TestApp> {
|
|
|
107
111
|
pendingAsyncCellsResolver: pendingAsyncCellsSlot.resolver,
|
|
108
112
|
resolvedCellsResolver: resolvedCellsSlot.resolver,
|
|
109
113
|
streamedCellsResolver: streamedCellsSlot.resolver,
|
|
114
|
+
docSnapshotsResolver: docSnapshotsSlot.resolver,
|
|
110
115
|
activeServer: serverSlot.active,
|
|
116
|
+
pageRender: pageRenderSlot.render,
|
|
117
|
+
stalenessResolver: cacheStalenessSlot.resolver,
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
const sharedStore = createCacheStore()
|
|
@@ -122,7 +129,12 @@ export async function createTestApp(): Promise<TestApp> {
|
|
|
122
129
|
const sharedStreamedCells = { entries: [] }
|
|
123
130
|
streamedCellsSlot.resolver = () =>
|
|
124
131
|
requestContext.getStore()?.streamedCells ?? sharedStreamedCells
|
|
132
|
+
const sharedDocSnapshots = { entries: [] }
|
|
133
|
+
docSnapshotsSlot.resolver = () => requestContext.getStore()?.docSnapshots ?? sharedDocSnapshots
|
|
125
134
|
pageSlot.resolver = resolvePageSnapshot
|
|
135
|
+
/* Server-side invalidate()/refresh() broadcast over the reserved socket (ADR-0041),
|
|
136
|
+
exactly as serverEntry installs it. */
|
|
137
|
+
cacheStalenessSlot.resolver = () => broadcastCacheStaleness
|
|
126
138
|
|
|
127
139
|
/* Eager env validation, exactly as serverEntry: a top-level env(schema) in
|
|
128
140
|
src/server/config.ts fails the boot loudly here rather than lazily. No-op
|
|
@@ -206,7 +218,10 @@ export async function createTestApp(): Promise<TestApp> {
|
|
|
206
218
|
pendingAsyncCellsSlot.resolver = previous.pendingAsyncCellsResolver
|
|
207
219
|
resolvedCellsSlot.resolver = previous.resolvedCellsResolver
|
|
208
220
|
streamedCellsSlot.resolver = previous.streamedCellsResolver
|
|
221
|
+
docSnapshotsSlot.resolver = previous.docSnapshotsResolver
|
|
209
222
|
serverSlot.active = previous.activeServer
|
|
223
|
+
pageRenderSlot.render = previous.pageRender
|
|
224
|
+
cacheStalenessSlot.resolver = previous.stalenessResolver
|
|
210
225
|
}
|
|
211
226
|
|
|
212
227
|
return {
|