@abide/abide 0.42.0 → 0.43.1
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 +234 -300
- package/CHANGELOG.md +41 -0
- package/README.md +50 -93
- package/package.json +3 -2
- package/src/abideResolverPlugin.ts +104 -14
- package/src/buildCli.ts +1 -1
- package/src/discoveryEntry.ts +3 -3
- package/src/lib/bundle/BundleMenuItem.ts +1 -1
- package/src/lib/cli/createClient.ts +12 -12
- package/src/lib/cli/dispatchCommand.ts +1 -1
- package/src/lib/cli/printSessionStatus.ts +1 -1
- package/src/lib/cli/resolveCliTarget.ts +1 -1
- package/src/lib/cli/runCli.ts +1 -1
- package/src/lib/cli/types/CliManifest.ts +1 -1
- package/src/lib/cli/types/CliManifestEntry.ts +2 -2
- package/src/lib/mcp/annotationsForMethod.ts +5 -5
- package/src/lib/mcp/createMcpServer.ts +3 -3
- package/src/lib/mcp/mcpSurface.ts +14 -14
- package/src/lib/mcp/types/McpServerOptions.ts +1 -1
- package/src/lib/server/DELETE.ts +4 -4
- package/src/lib/server/GET.ts +4 -4
- package/src/lib/server/HEAD.ts +4 -4
- package/src/lib/server/PATCH.ts +4 -4
- package/src/lib/server/POST.ts +4 -4
- package/src/lib/server/PUT.ts +4 -4
- package/src/lib/server/agent.ts +4 -4
- package/src/lib/server/env.ts +1 -1
- package/src/lib/server/error.ts +49 -7
- package/src/lib/server/json.ts +1 -1
- package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
- package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
- package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
- package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
- package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
- package/src/lib/server/rpc/parseArgs.ts +6 -6
- package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
- package/src/lib/server/rpc/registerRpc.ts +6 -0
- package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
- package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
- package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
- package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
- package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
- package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
- package/src/lib/server/rpc/unprocessed.ts +6 -7
- package/src/lib/server/rpc/validationError.ts +17 -0
- package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
- package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
- package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
- package/src/lib/server/runtime/createServer.ts +4 -4
- package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
- package/src/lib/server/runtime/crossOriginGate.ts +4 -4
- package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
- package/src/lib/server/runtime/registryManifests.ts +2 -2
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
- package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
- package/src/lib/server/runtime/types/RequestStore.ts +1 -1
- package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
- package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
- package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
- package/src/lib/shared/HttpError.ts +15 -1
- package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
- package/src/lib/shared/buildRpcRequest.ts +5 -5
- package/src/lib/shared/cache.ts +50 -168
- package/src/lib/shared/carriesBodyArgs.ts +4 -4
- package/src/lib/shared/createCacheStore.ts +12 -0
- package/src/lib/shared/createRemoteFunction.ts +11 -6
- package/src/lib/shared/decodeResponse.ts +2 -2
- package/src/lib/shared/detectRpcMethod.ts +17 -0
- package/src/lib/shared/emitLogRecord.ts +3 -3
- package/src/lib/shared/extraForwardHeaders.ts +1 -1
- package/src/lib/shared/findExportCallSite.ts +30 -273
- package/src/lib/shared/forwardHeaders.ts +2 -2
- package/src/lib/shared/httpErrorFor.ts +26 -0
- package/src/lib/shared/isAsciiWhitespace.ts +5 -0
- package/src/lib/shared/isIdentPart.ts +9 -0
- package/src/lib/shared/isIdentStart.ts +8 -0
- package/src/lib/shared/isReadOnlyMethod.ts +4 -4
- package/src/lib/shared/keyForRemoteCall.ts +4 -4
- package/src/lib/shared/log.ts +1 -1
- package/src/lib/shared/outboxProbeSlot.ts +20 -0
- package/src/lib/shared/pending.ts +4 -1
- package/src/lib/shared/prepareRpcModule.ts +96 -14
- package/src/lib/shared/probeRegistries.ts +17 -4
- package/src/lib/shared/queryStringFromArgs.ts +1 -1
- package/src/lib/shared/remoteMetaStore.ts +2 -2
- package/src/lib/shared/resolveClientFlags.ts +1 -1
- package/src/lib/shared/skipNonCode.ts +230 -0
- package/src/lib/shared/streamResponse.ts +9 -6
- package/src/lib/shared/stripImport.ts +3 -3
- package/src/lib/shared/types/CacheEntry.ts +2 -4
- package/src/lib/shared/types/CacheOnContext.ts +1 -11
- package/src/lib/shared/types/CacheStore.ts +13 -7
- package/src/lib/shared/types/ClientFlags.ts +1 -1
- package/src/lib/shared/types/ErrorConstructors.ts +17 -0
- package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
- package/src/lib/shared/types/ErrorSpec.ts +11 -0
- package/src/lib/shared/types/HttpMethod.ts +1 -0
- package/src/lib/shared/types/Outbox.ts +9 -0
- package/src/lib/shared/types/OutboxEntry.ts +27 -0
- package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
- package/src/lib/shared/types/RemoteCallable.ts +2 -2
- package/src/lib/shared/types/RemoteFunction.ts +21 -6
- package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
- package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
- package/src/lib/shared/types/RpcInvoker.ts +1 -1
- package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
- package/src/lib/shared/types/ValidationErrorData.ts +20 -0
- package/src/lib/shared/url.ts +3 -3
- package/src/lib/shared/writeRpcDts.ts +7 -7
- package/src/lib/shared/writeTestRpcDts.ts +3 -3
- package/src/lib/test/createTestApp.ts +10 -10
- package/src/lib/ui/compile/awaitPlan.ts +48 -0
- package/src/lib/ui/compile/compileShadow.ts +89 -21
- package/src/lib/ui/compile/desugarSignals.ts +42 -6
- package/src/lib/ui/compile/generateBuild.ts +43 -60
- package/src/lib/ui/compile/generateSSR.ts +84 -54
- package/src/lib/ui/compile/ifPlan.ts +30 -0
- package/src/lib/ui/compile/parseTemplate.ts +41 -11
- package/src/lib/ui/compile/stripEffects.ts +17 -9
- package/src/lib/ui/compile/switchPlan.ts +22 -0
- package/src/lib/ui/compile/tryPlan.ts +29 -0
- package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
- package/src/lib/ui/compile/types/TemplateNode.ts +6 -2
- package/src/lib/ui/createScope.ts +20 -2
- package/src/lib/ui/dom/disposeRange.ts +6 -10
- package/src/lib/ui/dom/hydrate.ts +2 -5
- package/src/lib/ui/dom/mount.ts +1 -2
- package/src/lib/ui/dom/withScope.ts +6 -9
- package/src/lib/ui/effect.ts +4 -2
- package/src/lib/ui/navigate.ts +40 -13
- package/src/lib/ui/outbox.ts +30 -110
- package/src/lib/ui/remoteProxy.ts +160 -10
- package/src/lib/ui/router.ts +45 -15
- package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
- package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
- package/src/lib/ui/socketChannel.ts +7 -1
- package/src/lib/ui/types/Scope.ts +20 -5
- package/template/CLAUDE.md +1 -1
- package/template/src/server/rpc/getHello.ts +3 -3
- package/template/test/app.test.ts +2 -2
- package/src/lib/server/rpc/registerVerb.ts +0 -6
- package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
- package/src/lib/shared/detectVerbMethod.ts +0 -17
- package/src/lib/shared/types/HttpVerb.ts +0 -1
- package/src/lib/ui/types/Outbox.ts +0 -14
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Internal slot the browser outbox registry registers its prober into, so the
|
|
3
|
+
shared pending() probe can count parked durable-rpc writes without shared/
|
|
4
|
+
importing browser/. Mirrors tailProbeSlot. The prober reads the doc-backed
|
|
5
|
+
queue entries (reactive inside $derived / $effect) and reports whether any
|
|
6
|
+
undelivered entry matches the selector: a durable rpc selector (carries a
|
|
7
|
+
`url`) narrows to its own queue, the bare form spans every registered queue,
|
|
8
|
+
optional args narrow to one parked call by structural compare. Outbox state is
|
|
9
|
+
pending-only — a parked write has no value, so it never contributes to
|
|
10
|
+
refreshing(); probeRegistries reads this slot only on its `field === 'pending'`
|
|
11
|
+
branch, which is why the slot mirrors tailProbeSlot but reports a bare boolean
|
|
12
|
+
rather than a { pending, refreshing } pair. When no prober is registered (server
|
|
13
|
+
render, or no durable rpc was ever used) pending() sees no parked writes,
|
|
14
|
+
exactly as on the server where there are no client queues.
|
|
15
|
+
*/
|
|
16
|
+
export const outboxProbeSlot: {
|
|
17
|
+
probe: ((selector: unknown, args: unknown) => boolean) | undefined
|
|
18
|
+
} = {
|
|
19
|
+
probe: undefined,
|
|
20
|
+
}
|
|
@@ -15,9 +15,12 @@ tail registry (streams). Pending means "no value yet":
|
|
|
15
15
|
pending(subscribable) → that stream awaiting its first frame
|
|
16
16
|
(tail.status === 'pending'; true when nothing
|
|
17
17
|
is reading yet — there is no value either way)
|
|
18
|
+
pending(durableRpc) → that durable rpc's parked writes (a parked write
|
|
19
|
+
has no value yet); the bare form counts them too
|
|
18
20
|
Probes report, never act: reading one opens no fetch and no stream. SSR
|
|
19
21
|
loading state is driven by {#await}, not this. Scan semantics (tap order,
|
|
20
|
-
selector grammar, registry spans) live in
|
|
22
|
+
selector grammar, registry spans, the parked-write term) live in
|
|
23
|
+
probeRegistries.
|
|
21
24
|
*/
|
|
22
25
|
// @documentation probes
|
|
23
26
|
export function pending<Args, Return>(
|
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
import { findExportCallSite } from './findExportCallSite.ts'
|
|
2
2
|
import { importNamesToStrip } from './importNamesToStrip.ts'
|
|
3
|
+
import { isReadOnlyMethod } from './isReadOnlyMethod.ts'
|
|
4
|
+
import { skipNonCode } from './skipNonCode.ts'
|
|
3
5
|
import { stripImport } from './stripImport.ts'
|
|
4
|
-
import type {
|
|
6
|
+
import type { HttpMethod } from './types/HttpMethod.ts'
|
|
5
7
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
+
const RPC_NAMES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const
|
|
9
|
+
const RPC_SET = new Set<string>(RPC_NAMES)
|
|
8
10
|
|
|
9
11
|
const SINGLE_EXPORT_ERROR =
|
|
10
|
-
'[abide] $rpc module contains more than one `<
|
|
12
|
+
'[abide] $rpc module contains more than one `<METHOD>(...)` export — each file must declare exactly one remote function'
|
|
11
13
|
|
|
12
14
|
export type PreparedRpcModule = {
|
|
13
|
-
|
|
15
|
+
method: HttpMethod
|
|
16
|
+
/* `outbox: true` in the opts — the client proxy is emitted durable. */
|
|
17
|
+
durable: boolean
|
|
14
18
|
exportName: string
|
|
15
19
|
rewriteForServer: (url: string) => string
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
/* The `outbox` opts key plus its value's leading token (up to the next comma / whitespace /
|
|
23
|
+
closing brace). `outbox` is a BUILD-TIME flag: the client bundle is rewritten durable or
|
|
24
|
+
not from this scan, before any handler runs — so the value must be a literal the scan can
|
|
25
|
+
read, not a computed expression. */
|
|
26
|
+
const OUTBOX_OPT = /\boutbox\s*:\s*([^,\s}]+)/
|
|
27
|
+
|
|
18
28
|
/*
|
|
19
|
-
Scans an `$rpc/**` module once and returns its declared
|
|
29
|
+
Scans an `$rpc/**` module once and returns its declared method + export
|
|
20
30
|
name plus a closure that, given the route URL, emits the server-side
|
|
21
|
-
rewrite (`
|
|
31
|
+
rewrite (`__abideDefineRpc__("METHOD", "<url>", … )` spliced into the
|
|
22
32
|
original source). The single scan replaces the prior separate
|
|
23
33
|
extract + rewrite passes, so the resolver plugin only walks each source
|
|
24
34
|
character-by-character once.
|
|
@@ -26,34 +36,106 @@ character-by-character once.
|
|
|
26
36
|
A regex pass would be tidier but it can't tell a `GET` mention inside a
|
|
27
37
|
docstring or template literal from the real call, and it can't follow
|
|
28
38
|
nested generics like `GET<Map<K, V>>(`.
|
|
39
|
+
|
|
40
|
+
The two build-time `outbox` invariants live here, not at runtime, because the
|
|
41
|
+
client bundle's durability is decided here — making this the single source of
|
|
42
|
+
truth (the server-side defineRpc guard stays as a cheap backstop):
|
|
43
|
+
- `outbox` must be a literal `true`/`false`; a computed value can't be read at
|
|
44
|
+
bundle time, so it's rejected loudly instead of silently shipping a
|
|
45
|
+
non-durable client proxy.
|
|
46
|
+
- `outbox: true` is mutating-only — a read RPC (GET/HEAD) has nothing to
|
|
47
|
+
durably deliver.
|
|
29
48
|
*/
|
|
30
49
|
export function prepareRpcModule(
|
|
31
50
|
source: string,
|
|
32
51
|
importName: string,
|
|
33
52
|
): PreparedRpcModule | undefined {
|
|
34
53
|
/*
|
|
35
|
-
The "no barrels" surface places each
|
|
54
|
+
The "no barrels" surface places each method at its own path
|
|
36
55
|
(`abide/server/GET`, `abide/server/POST`, …) — strip every one so
|
|
37
|
-
the user's
|
|
56
|
+
the user's method import doesn't linger and side-effect-load the
|
|
38
57
|
stub module into the server bundle. The user may import under the
|
|
39
58
|
project's chosen name or the canonical package name, so strip both.
|
|
40
59
|
*/
|
|
41
60
|
const stripped = importNamesToStrip(importName).reduce(
|
|
42
61
|
(current, name) =>
|
|
43
|
-
|
|
62
|
+
RPC_NAMES.reduce(
|
|
63
|
+
(acc, method) => stripImport(acc, `${name}/server/${method}`),
|
|
64
|
+
current,
|
|
65
|
+
),
|
|
44
66
|
source,
|
|
45
67
|
)
|
|
46
|
-
const site = findExportCallSite(stripped, (ident) =>
|
|
68
|
+
const site = findExportCallSite(stripped, (ident) => RPC_SET.has(ident), SINGLE_EXPORT_ERROR)
|
|
47
69
|
if (!site) {
|
|
48
70
|
return undefined
|
|
49
71
|
}
|
|
50
|
-
const
|
|
72
|
+
const method = site.ident as HttpMethod
|
|
73
|
+
const durable = detectDurable(stripped, site.parenStart, site.parenEnd, method)
|
|
51
74
|
return {
|
|
52
|
-
|
|
75
|
+
method,
|
|
76
|
+
durable,
|
|
53
77
|
exportName: site.exportName,
|
|
54
78
|
rewriteForServer(url: string): string {
|
|
55
|
-
const binding = `
|
|
79
|
+
const binding = `__abideDefineRpc__(${JSON.stringify(method)}, ${JSON.stringify(url)}, `
|
|
56
80
|
return stripped.slice(0, site.callStart) + binding + stripped.slice(site.parenStart + 1)
|
|
57
81
|
},
|
|
58
82
|
}
|
|
59
83
|
}
|
|
84
|
+
|
|
85
|
+
/* Reads the `outbox` flag off the call's opts object (the trailing argument), enforcing the
|
|
86
|
+
two build-time invariants. Scoping to the opts object keeps the scan off the handler body,
|
|
87
|
+
so a handler that mentions `outbox:` doesn't misfire. */
|
|
88
|
+
function detectDurable(
|
|
89
|
+
source: string,
|
|
90
|
+
parenStart: number,
|
|
91
|
+
parenEnd: number,
|
|
92
|
+
method: HttpMethod,
|
|
93
|
+
): boolean {
|
|
94
|
+
const opts = lastArgText(source, parenStart, parenEnd)
|
|
95
|
+
const match = opts === undefined ? null : OUTBOX_OPT.exec(opts)
|
|
96
|
+
if (match === null) {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
const value = match[1]
|
|
100
|
+
if (value !== 'true' && value !== 'false') {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`[abide] \`outbox\` must be a literal \`true\` or \`false\` (got \`${value}\`) — it's a build-time flag the client bundle reads, so it can't be a computed expression`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
const durable = value === 'true'
|
|
106
|
+
if (durable && isReadOnlyMethod(method)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[abide] outbox: true is only valid on mutating RPCs (POST/PUT/PATCH/DELETE), not ${method}`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return durable
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
The text of the call's final argument — the opts object for a `METHOD(handler, opts)` call.
|
|
116
|
+
Walks the arg list depth-aware, skipping strings / templates / comments / regex (skipNonCode)
|
|
117
|
+
so their commas and braces don't miscount, and returns the slice after the last top-level
|
|
118
|
+
comma. undefined when the call has a single argument (a bare handler, no opts).
|
|
119
|
+
*/
|
|
120
|
+
function lastArgText(source: string, parenStart: number, parenEnd: number): string | undefined {
|
|
121
|
+
let depth = 0
|
|
122
|
+
let lastComma = -1
|
|
123
|
+
let i = parenStart + 1
|
|
124
|
+
while (i < parenEnd) {
|
|
125
|
+
const skipped = skipNonCode(source, i)
|
|
126
|
+
if (skipped !== undefined) {
|
|
127
|
+
i = skipped
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const c = source[i]
|
|
131
|
+
if (c === '(' || c === '{' || c === '[') {
|
|
132
|
+
depth++
|
|
133
|
+
} else if (c === ')' || c === '}' || c === ']') {
|
|
134
|
+
depth--
|
|
135
|
+
} else if (c === ',' && depth === 0) {
|
|
136
|
+
lastComma = i
|
|
137
|
+
}
|
|
138
|
+
i++
|
|
139
|
+
}
|
|
140
|
+
return lastComma === -1 ? undefined : source.slice(lastComma + 1, parenEnd)
|
|
141
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cacheStores } from './cacheStores.ts'
|
|
2
2
|
import { isSubscribable } from './isSubscribable.ts'
|
|
3
|
+
import { outboxProbeSlot } from './outboxProbeSlot.ts'
|
|
3
4
|
import { selectorMatcher } from './selectorMatcher.ts'
|
|
4
5
|
import { selectorPrefix } from './selectorPrefix.ts'
|
|
5
6
|
import { tailProbeSlot } from './tailProbeSlot.ts'
|
|
@@ -22,9 +23,20 @@ cache() marks that channel, the re-derive resolves the prefix, and the scope
|
|
|
22
23
|
re-tracks narrowed. `matchesEntry` is the probe's cache-side question;
|
|
23
24
|
`field` selects the stream-side answer; `unprobed` is the answer for a
|
|
24
25
|
stream no prober can see (server, or tail never imported) — matching what
|
|
25
|
-
tail() itself reports there. The bare form (no selector) spans
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
tail() itself reports there. The bare form (no selector) spans every
|
|
27
|
+
registry; fn/tag selectors are cache identities only (plus the parked-write
|
|
28
|
+
queue a durable rpc selector narrows to). Outside a tracking scope the taps
|
|
29
|
+
are no-ops and the current value returns.
|
|
30
|
+
|
|
31
|
+
A parked durable (`outbox`) write has no value yet, so it counts as pending —
|
|
32
|
+
but never refreshing (it's holding nothing to supersede), hence the term is
|
|
33
|
+
guarded to `field === 'pending'`. The outbox prober (registered into
|
|
34
|
+
outboxProbeSlot by browser/, empty on the server) reads its doc-backed queue,
|
|
35
|
+
which is the term's own reactive tap. It sits last under `||`: when a matching
|
|
36
|
+
cache call or stream already answers true the queue isn't read, but that's
|
|
37
|
+
monotonic-safe — the parked write can't flip a true result, and the moment the
|
|
38
|
+
cache/stream term falls back to false its lifecycle tap re-derives and the
|
|
39
|
+
queue is read then.
|
|
28
40
|
*/
|
|
29
41
|
export function probeRegistries<Args, Return>(
|
|
30
42
|
arg: CacheSelector<Args, Return> | Subscribable<unknown> | undefined,
|
|
@@ -46,7 +58,8 @@ export function probeRegistries<Args, Return>(
|
|
|
46
58
|
const matches = selectorMatcher(arg, args, prefix)
|
|
47
59
|
return (
|
|
48
60
|
stores.some((store) => anyEntryMatches(store, matchesEntry, matches)) ||
|
|
49
|
-
streams?.[field] === true
|
|
61
|
+
streams?.[field] === true ||
|
|
62
|
+
(field === 'pending' && (outboxProbeSlot.probe?.(arg, args) ?? false))
|
|
50
63
|
)
|
|
51
64
|
}
|
|
52
65
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Canonical query-string encoding for a query-carrying
|
|
2
|
+
Canonical query-string encoding for a query-carrying rpc's args bag. The
|
|
3
3
|
single definition both the synthesized Request (buildRpcRequest) and its
|
|
4
4
|
cache key (keyForRemoteCall) build their query from, so the request URL and
|
|
5
5
|
the key encode every value identically and can't drift. `undefined` values
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/*
|
|
2
|
-
WeakMap that records how to obtain the synthesized Request for a
|
|
2
|
+
WeakMap that records how to obtain the synthesized Request for a rpc
|
|
3
3
|
call. The cache layer reads it to populate an entry's stored request
|
|
4
4
|
without rebuilding from scratch.
|
|
5
5
|
|
|
6
6
|
Stored as a thunk rather than the Request itself so SSR pages that fire
|
|
7
|
-
dozens of in-process
|
|
7
|
+
dozens of in-process rpc calls without ever reaching cache() don't pay
|
|
8
8
|
the URL + Headers + Request allocation per call. The thunk memoises its
|
|
9
9
|
own first call inside createRemoteFunction, so cache() and any future
|
|
10
10
|
meta reader see the same Request instance.
|
|
@@ -4,7 +4,7 @@ import type { ClientFlags } from './types/ClientFlags.ts'
|
|
|
4
4
|
Fills in the missing keys of a user-supplied `clients` option. Browser
|
|
5
5
|
always defaults to true (the historical surface). The mcp/cli auto-on
|
|
6
6
|
defaults are decided by the caller and passed in, since the safe default
|
|
7
|
-
differs per declaration: a read-only
|
|
7
|
+
differs per declaration: a read-only rpc may auto-expose to MCP while a
|
|
8
8
|
mutating one must not, and sockets gate differently again. Explicit
|
|
9
9
|
values in `flags` always win over the computed defaults.
|
|
10
10
|
*/
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { isAsciiWhitespace } from './isAsciiWhitespace.ts'
|
|
2
|
+
import { isIdentPart } from './isIdentPart.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The shared "skip a non-code construct" primitive for the source scanners
|
|
6
|
+
(findExportCallSite, prepareRpcModule's arg walker, …). When `i` sits on the
|
|
7
|
+
start of a string, template literal, line/block comment, or regex literal, it
|
|
8
|
+
returns the index immediately after that construct; undefined when `source[i]`
|
|
9
|
+
begins ordinary code — including a `/` that's division rather than a regex.
|
|
10
|
+
|
|
11
|
+
Centralising the string / template / regex / comment rules here is what lets
|
|
12
|
+
every scanner walk source depth-aware without re-deriving them and getting the
|
|
13
|
+
regex-vs-division or `${…}` edge cases subtly wrong (the bug that made a naive
|
|
14
|
+
arg walker miscount a `{ ratio: a / b }` or a regex literal in the opts).
|
|
15
|
+
*/
|
|
16
|
+
export function skipNonCode(source: string, i: number): number | undefined {
|
|
17
|
+
const c = source[i]
|
|
18
|
+
if (c === '"' || c === "'") {
|
|
19
|
+
return skipString(source, i + 1, c)
|
|
20
|
+
}
|
|
21
|
+
if (c === '`') {
|
|
22
|
+
return skipTemplate(source, i + 1)
|
|
23
|
+
}
|
|
24
|
+
if (c === '/') {
|
|
25
|
+
return skipSlashConstruct(source, i)
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function skipString(source: string, start: number, quote: string): number {
|
|
31
|
+
let i = start
|
|
32
|
+
while (i < source.length) {
|
|
33
|
+
const c = source[i]
|
|
34
|
+
if (c === '\\') {
|
|
35
|
+
i += 2
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (c === quote) {
|
|
39
|
+
return i + 1
|
|
40
|
+
}
|
|
41
|
+
if (c === '\n') {
|
|
42
|
+
return i
|
|
43
|
+
}
|
|
44
|
+
i++
|
|
45
|
+
}
|
|
46
|
+
return source.length
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function skipTemplate(source: string, start: number): number {
|
|
50
|
+
let i = start
|
|
51
|
+
while (i < source.length) {
|
|
52
|
+
const c = source[i]
|
|
53
|
+
if (c === '\\') {
|
|
54
|
+
i += 2
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (c === '`') {
|
|
58
|
+
return i + 1
|
|
59
|
+
}
|
|
60
|
+
if (c === '$' && source[i + 1] === '{') {
|
|
61
|
+
i = skipTemplateExpression(source, i + 2)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
i++
|
|
65
|
+
}
|
|
66
|
+
return source.length
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function skipTemplateExpression(source: string, start: number): number {
|
|
70
|
+
let depth = 1
|
|
71
|
+
let i = start
|
|
72
|
+
while (i < source.length && depth > 0) {
|
|
73
|
+
const c = source[i]
|
|
74
|
+
if (c === '{') {
|
|
75
|
+
depth++
|
|
76
|
+
i++
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (c === '}') {
|
|
80
|
+
depth--
|
|
81
|
+
i++
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const skipped = skipNonCode(source, i)
|
|
85
|
+
if (skipped !== undefined) {
|
|
86
|
+
i = skipped
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
i++
|
|
90
|
+
}
|
|
91
|
+
return i
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
When `i` sits on a `/` introducing a line comment, block comment, or regex
|
|
96
|
+
literal, returns the index immediately after the construct; undefined when the
|
|
97
|
+
`/` is division (or `i` isn't a `/`). One statement of the skip rules shared by
|
|
98
|
+
every scanner loop so they can't drift.
|
|
99
|
+
*/
|
|
100
|
+
function skipSlashConstruct(source: string, i: number): number | undefined {
|
|
101
|
+
if (source[i] !== '/') {
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
const next = source[i + 1]
|
|
105
|
+
if (next === '/') {
|
|
106
|
+
const newline = source.indexOf('\n', i + 2)
|
|
107
|
+
return newline === -1 ? source.length : newline + 1
|
|
108
|
+
}
|
|
109
|
+
if (next === '*') {
|
|
110
|
+
const end = source.indexOf('*/', i + 2)
|
|
111
|
+
return end === -1 ? source.length : end + 2
|
|
112
|
+
}
|
|
113
|
+
if (isRegexContext(source, i)) {
|
|
114
|
+
return skipRegex(source, i + 1)
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
A `/` starts a regex literal when the prior expression context expects an
|
|
121
|
+
expression rather than a value — after an open delimiter, operator, or
|
|
122
|
+
expression-prefix keyword (return, typeof, instanceof, in, of, delete, void,
|
|
123
|
+
await, yield, new, throw, case, do). Otherwise `/` is division. Without this
|
|
124
|
+
disambiguation a regex like `/^\//` reads as `/` (division), then `^`, `\`,
|
|
125
|
+
`/`, `/` — and the final `//` pair fakes a line comment that swallows the rest
|
|
126
|
+
of the line, eating any `)` that closes the enclosing call.
|
|
127
|
+
*/
|
|
128
|
+
const REGEX_PREFIX_KEYWORDS = new Set([
|
|
129
|
+
'return',
|
|
130
|
+
'typeof',
|
|
131
|
+
'instanceof',
|
|
132
|
+
'in',
|
|
133
|
+
'of',
|
|
134
|
+
'delete',
|
|
135
|
+
'void',
|
|
136
|
+
'await',
|
|
137
|
+
'yield',
|
|
138
|
+
'new',
|
|
139
|
+
'throw',
|
|
140
|
+
'case',
|
|
141
|
+
'do',
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
const REGEX_PUNCTUATION = new Set([
|
|
145
|
+
'(',
|
|
146
|
+
'[',
|
|
147
|
+
'{',
|
|
148
|
+
',',
|
|
149
|
+
';',
|
|
150
|
+
':',
|
|
151
|
+
'?',
|
|
152
|
+
'!',
|
|
153
|
+
'&',
|
|
154
|
+
'|',
|
|
155
|
+
'^',
|
|
156
|
+
'~',
|
|
157
|
+
'+',
|
|
158
|
+
'-',
|
|
159
|
+
'*',
|
|
160
|
+
'%',
|
|
161
|
+
'<',
|
|
162
|
+
'>',
|
|
163
|
+
'=',
|
|
164
|
+
'/',
|
|
165
|
+
])
|
|
166
|
+
|
|
167
|
+
function isRegexContext(source: string, slashIndex: number): boolean {
|
|
168
|
+
let i = slashIndex - 1
|
|
169
|
+
while (i >= 0 && isAsciiWhitespace(source[i])) {
|
|
170
|
+
i--
|
|
171
|
+
}
|
|
172
|
+
if (i < 0) {
|
|
173
|
+
return true
|
|
174
|
+
}
|
|
175
|
+
const prev = source[i] as string
|
|
176
|
+
if (REGEX_PUNCTUATION.has(prev)) {
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
if (isIdentPart(prev)) {
|
|
180
|
+
let start = i
|
|
181
|
+
while (start > 0 && isIdentPart(source[start - 1])) {
|
|
182
|
+
start--
|
|
183
|
+
}
|
|
184
|
+
return REGEX_PREFIX_KEYWORDS.has(source.slice(start, i + 1))
|
|
185
|
+
}
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
Walks past a regex literal body, respecting character classes (`[...]` where
|
|
191
|
+
`/` is literal) and backslash escapes, then consumes trailing flag identifiers.
|
|
192
|
+
Returns the index immediately after the regex. An unterminated regex (newline
|
|
193
|
+
before the closing `/`) returns the newline position so the outer scanner can
|
|
194
|
+
resume normally on the next line.
|
|
195
|
+
*/
|
|
196
|
+
function skipRegex(source: string, start: number): number {
|
|
197
|
+
let i = start
|
|
198
|
+
let inClass = false
|
|
199
|
+
while (i < source.length) {
|
|
200
|
+
const c = source[i]
|
|
201
|
+
if (c === '\\') {
|
|
202
|
+
i += 2
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (c === '\n') {
|
|
206
|
+
return i
|
|
207
|
+
}
|
|
208
|
+
if (inClass) {
|
|
209
|
+
if (c === ']') {
|
|
210
|
+
inClass = false
|
|
211
|
+
}
|
|
212
|
+
i++
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (c === '[') {
|
|
216
|
+
inClass = true
|
|
217
|
+
i++
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (c === '/') {
|
|
221
|
+
let j = i + 1
|
|
222
|
+
while (j < source.length && isIdentPart(source[j])) {
|
|
223
|
+
j++
|
|
224
|
+
}
|
|
225
|
+
return j
|
|
226
|
+
}
|
|
227
|
+
i++
|
|
228
|
+
}
|
|
229
|
+
return source.length
|
|
230
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { contentTypeOf } from './contentTypeOf.ts'
|
|
2
2
|
import { decodeResponse } from './decodeResponse.ts'
|
|
3
|
-
import {
|
|
3
|
+
import { httpErrorFor } from './httpErrorFor.ts'
|
|
4
4
|
import { jsonlErrorFrame } from './jsonlErrorFrame.ts'
|
|
5
5
|
import { STREAMING_CONTENT_TYPES } from './STREAMING_CONTENT_TYPES.ts'
|
|
6
6
|
import { sseErrorFrame } from './sseErrorFrame.ts'
|
|
@@ -24,11 +24,12 @@ identically. Three shapes are handled:
|
|
|
24
24
|
handler, not just the streaming ones.
|
|
25
25
|
|
|
26
26
|
Non-2xx responses surface as a thrown HttpError on the first pull,
|
|
27
|
-
mirroring the plain `fn(args)` decode path
|
|
27
|
+
mirroring the plain `fn(args)` decode path — typed-error body included
|
|
28
|
+
(`.kind` / `.data`), parsed by httpErrorFor when the generator is pulled.
|
|
28
29
|
*/
|
|
29
30
|
export function streamResponse<T>(response: Response): AsyncIterable<T> {
|
|
30
31
|
if (!response.ok) {
|
|
31
|
-
return errorIterable<T>(
|
|
32
|
+
return errorIterable<T>(response)
|
|
32
33
|
}
|
|
33
34
|
const contentType = contentTypeOf(response.headers)
|
|
34
35
|
if (contentType.startsWith('text/event-stream')) {
|
|
@@ -40,10 +41,12 @@ export function streamResponse<T>(response: Response): AsyncIterable<T> {
|
|
|
40
41
|
return oneShot<T>(response)
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/* Surfaces a non-2xx response
|
|
44
|
+
/* Surfaces a non-2xx response as a thrown HttpError on the first pull — awaiting the
|
|
45
|
+
typed-error body parse here (not in the sync streamResponse body) is what lets the
|
|
46
|
+
stream path carry `.kind` / `.data` like the plain decode path. */
|
|
44
47
|
// biome-ignore lint/correctness/useYield: throws on first pull; the generator shape is intentional so callers iterate it uniformly
|
|
45
|
-
async function* errorIterable<T>(
|
|
46
|
-
throw
|
|
48
|
+
async function* errorIterable<T>(response: Response): AsyncGenerator<T> {
|
|
49
|
+
throw await httpErrorFor(response)
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/*
|
|
@@ -3,9 +3,9 @@ import { escapeRegex } from './escapeRegex.ts'
|
|
|
3
3
|
/*
|
|
4
4
|
Strips the user's `import { … } from '<moduleName>'` declaration from a
|
|
5
5
|
module source. Used by the $rpc / $sockets rewriters to remove the
|
|
6
|
-
|
|
7
|
-
runtime-injected binding (
|
|
8
|
-
strip the dead import would still side-effect-load the
|
|
6
|
+
rpc / `socket` import after its call site has been replaced by the
|
|
7
|
+
runtime-injected binding (defineRpc / defineSocket). Without this
|
|
8
|
+
strip the dead import would still side-effect-load the rpc/socket
|
|
9
9
|
helper module into the server bundle for every $rpc / $sockets file.
|
|
10
10
|
|
|
11
11
|
The braced body is `[^}]*` rather than `[\s\S]*?` so the lazy match
|
|
@@ -16,10 +16,8 @@ settles).
|
|
|
16
16
|
`value` is the decoded warm value served synchronously by the read path
|
|
17
17
|
(cloned per read). It is set at hydration — the SSR snapshot body is
|
|
18
18
|
pre-decoded so the first client render reads it without a microtask hop and
|
|
19
|
-
byte-matches the SSR DOM
|
|
20
|
-
|
|
21
|
-
Live fetches leave it undefined and take the async decode path until a patch
|
|
22
|
-
populates it.
|
|
19
|
+
byte-matches the SSR DOM. Live fetches leave it undefined and take the async
|
|
20
|
+
decode path.
|
|
23
21
|
|
|
24
22
|
`hydrated` marks an entry built from the SSR snapshot, which ships no wrap
|
|
25
23
|
options — the first read consumes the flag and adopts its call site's `ttl`
|
|
@@ -6,20 +6,10 @@ inside a binding. `invalidate` is the binding-scoped copy of cache.invalidate
|
|
|
6
6
|
(same selector grammar, same effect): calls through it are recorded in the
|
|
7
7
|
binding's coverage set by function identity, so attribution survives awaits
|
|
8
8
|
and the coverage replays after a reconnect gap; the global cache.invalidate
|
|
9
|
-
still works inside a handler but is not covered. `
|
|
10
|
-
authoritative delta carried by the frame into the matching cached entries
|
|
11
|
-
without a refetch (ADR-0007) — `updater` maps the current decoded value to the
|
|
12
|
-
next; it resolves to the touched keys and is covered for reconnect resync the
|
|
13
|
-
same way (the gap re-invalidates, since a delta can't be replayed). Await it to
|
|
14
|
-
keep deltas ordered under sequential delivery. `signal` aborts when the binding
|
|
9
|
+
still works inside a handler but is not covered. `signal` aborts when the binding
|
|
15
10
|
is disposed, so an async handler mid-flight can bail or cancel its own fetches.
|
|
16
11
|
*/
|
|
17
12
|
export type CacheOnContext = {
|
|
18
13
|
invalidate: <Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args) => void
|
|
19
|
-
patch: <Args, Return>(
|
|
20
|
-
arg: CacheSelector<Args, Return>,
|
|
21
|
-
updater: (current: Return) => Return,
|
|
22
|
-
args?: Args,
|
|
23
|
-
) => Promise<string[]>
|
|
24
14
|
signal: AbortSignal
|
|
25
15
|
}
|
|
@@ -22,16 +22,22 @@ export type CacheStore = {
|
|
|
22
22
|
entries: Map<string, CacheEntry>
|
|
23
23
|
events: EventTarget
|
|
24
24
|
subscribe: (key: string) => void
|
|
25
|
+
/* True while a reactive scope is reading `key` (a value held on screen).
|
|
26
|
+
invalidate() gates its reload marker on this — see pendingRefresh. */
|
|
27
|
+
hasReader: (key: string) => boolean
|
|
25
28
|
trackLifecycle: (keyPrefix?: string) => void
|
|
26
29
|
markLifecycle: (key?: string) => void
|
|
27
30
|
/*
|
|
28
|
-
Keys dropped by a (policy-less) invalidate
|
|
29
|
-
drop erases the entry, so the next
|
|
30
|
-
|
|
31
|
-
replacement entry is flagged a reload
|
|
32
|
-
first-ever load. Consumed when that entry is
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
Keys dropped by a (policy-less) invalidate while a reader was holding the
|
|
32
|
+
value, awaiting their next read. The drop erases the entry, so the next
|
|
33
|
+
cache() call is a cold miss with no memory it followed an invalidate; this set
|
|
34
|
+
carries that signal across the gap so the replacement entry is flagged a reload
|
|
35
|
+
(refreshing() true) rather than a first-ever load. Consumed when that entry is
|
|
36
|
+
created. Bounded on the long-lived tab store from both ends: a marker is only
|
|
37
|
+
added when `hasReader(key)` (no on-screen value → the next read is a first
|
|
38
|
+
load, not a reload, so no marker), and a marker whose reader tears down before
|
|
39
|
+
re-reading is pruned by the subscriber's cleanup. The server's request-scoped
|
|
40
|
+
store discards the whole set with the response.
|
|
35
41
|
*/
|
|
36
42
|
pendingRefresh: Set<string>
|
|
37
43
|
/* Read tallies for the closing log record + Server-Timing; see CacheStats. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Which client surfaces a
|
|
2
|
+
Which client surfaces a rpc or socket is exposed to. Browser is the
|
|
3
3
|
historical default; MCP and CLI flip on automatically when the
|
|
4
4
|
declaration carries a Standard Schema (the schema is what makes the
|
|
5
5
|
non-browser surfaces safe to advertise). Explicit values always win.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ErrorDescriptor } from './ErrorDescriptor.ts'
|
|
2
|
+
import type { ErrorSpec } from './ErrorSpec.ts'
|
|
3
|
+
import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The constructors handed to the handler via its second arg (`(args, { errors })`),
|
|
7
|
+
derived from the rpc's `ErrorSpec`. An entry with a `data` schema makes its
|
|
8
|
+
constructor require that schema's inferred input; an entry without one is nullary.
|
|
9
|
+
Each returns a typed `ErrorDescriptor` to pass to `error()`.
|
|
10
|
+
*/
|
|
11
|
+
export type ErrorConstructors<Spec extends ErrorSpec> = {
|
|
12
|
+
[Name in keyof Spec & string]: Spec[Name]['data'] extends StandardSchemaV1
|
|
13
|
+
? (
|
|
14
|
+
data: StandardSchemaV1.InferInput<Spec[Name]['data']>,
|
|
15
|
+
) => ErrorDescriptor<Name, StandardSchemaV1.InferInput<Spec[Name]['data']>>
|
|
16
|
+
: () => ErrorDescriptor<Name, undefined>
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
What a rpc error constructor returns: a plain descriptor (NOT a Response), so it
|
|
3
|
+
flows through the single `error()` funnel — `return error(errors.invalidCoupon({…}))`.
|
|
4
|
+
`error()` reads `status` off it and serializes `{ $abideError: name, data }` as the body.
|
|
5
|
+
*/
|
|
6
|
+
export type ErrorDescriptor<Name extends string = string, Data = unknown> = {
|
|
7
|
+
readonly $abideError: Name
|
|
8
|
+
readonly status: number
|
|
9
|
+
readonly data: Data
|
|
10
|
+
}
|