@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
|
@@ -6,17 +6,34 @@ import { HttpError } from '../shared/HttpError.ts'
|
|
|
6
6
|
import { OFFLINE_HEADER } from '../shared/OFFLINE_HEADER.ts'
|
|
7
7
|
import { rpcTimeoutSlot } from '../shared/rpcTimeoutSlot.ts'
|
|
8
8
|
import { trace } from '../shared/trace.ts'
|
|
9
|
-
import type {
|
|
9
|
+
import type { HttpMethod } from '../shared/types/HttpMethod.ts'
|
|
10
|
+
import type { Outbox } from '../shared/types/Outbox.ts'
|
|
10
11
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
11
12
|
import type { RpcOptions } from '../shared/types/RpcOptions.ts'
|
|
13
|
+
import { UNREACHABLE_STATUSES } from '../shared/UNREACHABLE_STATUSES.ts'
|
|
12
14
|
import { withBase } from '../shared/withBase.ts'
|
|
15
|
+
import { createOutboxQueue, type OutboxQueue } from './rpcOutbox/createOutboxQueue.ts'
|
|
16
|
+
import { outboxRegistry } from './rpcOutbox/outboxRegistry.ts'
|
|
13
17
|
import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
|
|
14
18
|
import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
|
|
19
|
+
import type { PersistenceStore } from './types/PersistenceStore.ts'
|
|
20
|
+
|
|
21
|
+
/* The framework-reserved `HttpError.kind` for a request the durable outbox parked because
|
|
22
|
+
the server was unreachable — distinct from a handler-declared error name. Lets a caller
|
|
23
|
+
branch with `error instanceof HttpError && error.kind === 'queued'`. */
|
|
24
|
+
const QUEUED = 'queued'
|
|
25
|
+
|
|
26
|
+
/* A durable RPC's per-call options. `outbox: true` parks an unreachable call for replay;
|
|
27
|
+
`store` exists for testing — production uses the default persistence store. */
|
|
28
|
+
export type DurableOptions = {
|
|
29
|
+
outbox?: boolean
|
|
30
|
+
store?: PersistenceStore
|
|
31
|
+
}
|
|
15
32
|
|
|
16
33
|
/*
|
|
17
|
-
Client-side substitute for a
|
|
18
|
-
call per
|
|
19
|
-
target uses
|
|
34
|
+
Client-side substitute for a rpc-defined handler. The bundler emits one
|
|
35
|
+
call per rpc export inside an `$rpc/**` module (GET / POST / …): server
|
|
36
|
+
target uses defineRpc (real handler), browser target uses remoteProxy
|
|
20
37
|
(fetch over the network). Both paths produce identical RemoteFunction
|
|
21
38
|
shapes and identical WeakMap metadata so cache() works the same on either
|
|
22
39
|
side.
|
|
@@ -25,13 +42,26 @@ side.
|
|
|
25
42
|
the query string (GET/DELETE/HEAD). Plain `fn(args)` decodes the Response
|
|
26
43
|
by Content-Type and throws HttpError on non-2xx; `.raw(args)` is the
|
|
27
44
|
escape hatch that returns the Response untouched.
|
|
45
|
+
|
|
46
|
+
A durable (`outbox: true`) rpc is still a normal RemoteFunction — it fetches and
|
|
47
|
+
throws exactly the same. The differences: when the server can't be reached
|
|
48
|
+
(a transport failure, or a 502/503/504/52x), the request is `park`ed onto the
|
|
49
|
+
RPC's app-owned outbox as a SIDE-EFFECT and the throw is a `kind: 'queued'`
|
|
50
|
+
HttpError; and once a backlog exists, a fresh call parks straight to the TAIL
|
|
51
|
+
(no fetch) so writes can't land out of order. The parked write waits for
|
|
52
|
+
`rpc.outbox.retry()` (or the global `outbox.retry()`) — there is no auto-drain;
|
|
53
|
+
the app owns when to replay. `rpc.outbox()` exposes the queue.
|
|
28
54
|
*/
|
|
29
55
|
// @documentation plumbing
|
|
30
56
|
export function remoteProxy<Args, Return>(
|
|
31
|
-
method:
|
|
57
|
+
method: HttpMethod,
|
|
32
58
|
url: string,
|
|
59
|
+
durable?: DurableOptions,
|
|
33
60
|
): RemoteFunction<Args, Return> {
|
|
34
|
-
|
|
61
|
+
/* Assigned after `createRemoteFunction` so the invoke closure (which runs later, per
|
|
62
|
+
call) parks through the shared queue; undefined leaves the plain fetch path. */
|
|
63
|
+
let queue: OutboxQueue<Args> | undefined
|
|
64
|
+
const fn = createRemoteFunction<Args, Return>({
|
|
35
65
|
method,
|
|
36
66
|
url,
|
|
37
67
|
clients: browserClientFlags,
|
|
@@ -49,12 +79,132 @@ export function remoteProxy<Args, Return>(
|
|
|
49
79
|
headers: rpcHeaders(opts?.headers),
|
|
50
80
|
}),
|
|
51
81
|
/*
|
|
52
|
-
Forcing `getRequest()` once builds the Request and seeds the
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
Forcing `getRequest()` once builds the Request and seeds the cache meta thunk in
|
|
83
|
+
createRemoteFunction with the same instance, so cache() readers don't reconstruct
|
|
84
|
+
it. On a durable rpc, an unreachable result parks a pristine CLONE and throws a
|
|
85
|
+
`queued`-tagged HttpError — `fetch` consumes the original (its body stream is read
|
|
86
|
+
and locked), so parking that same instance would leave the queue a request a resend
|
|
87
|
+
can't reconstruct and a capture can't read. The clone is parked, the original is
|
|
88
|
+
sent. The throw lets the caller branch on `error.kind === 'queued'` (parked, will
|
|
89
|
+
retry) vs. a real server rejection; `error.data` is the parked entry, so a caller
|
|
90
|
+
can `await (error.data as OutboxEntry).settled` for the eventual outcome.
|
|
55
91
|
*/
|
|
56
|
-
invoke: (
|
|
92
|
+
invoke: (args, getRequest, opts) => {
|
|
93
|
+
if (queue === undefined) {
|
|
94
|
+
return fetchWithTimeout(getRequest(), opts)
|
|
95
|
+
}
|
|
96
|
+
/* A non-empty queue means an undelivered backlog: park this call at the TAIL
|
|
97
|
+
and throw, rather than let a live fetch leapfrog the older writes and land
|
|
98
|
+
out of order. `retry()` then flushes the whole queue FIFO. */
|
|
99
|
+
if (queue.size() > 0) {
|
|
100
|
+
return Promise.reject(
|
|
101
|
+
queuedThrow(
|
|
102
|
+
queue,
|
|
103
|
+
args as Args,
|
|
104
|
+
getRequest().clone(),
|
|
105
|
+
unreachableResponse(),
|
|
106
|
+
undefined,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const request = getRequest()
|
|
111
|
+
const parkable = request.clone()
|
|
112
|
+
return fetchWithTimeout(request, opts).then(
|
|
113
|
+
(response) => {
|
|
114
|
+
if (UNREACHABLE_STATUSES.has(response.status)) {
|
|
115
|
+
throw queuedThrow(
|
|
116
|
+
queue,
|
|
117
|
+
args as Args,
|
|
118
|
+
parkable,
|
|
119
|
+
response,
|
|
120
|
+
new HttpError(response.clone()),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return response
|
|
124
|
+
},
|
|
125
|
+
(error: unknown) => {
|
|
126
|
+
if (shouldParkRejection(error)) {
|
|
127
|
+
const response =
|
|
128
|
+
error instanceof HttpError ? error.response : unreachableResponse()
|
|
129
|
+
throw queuedThrow(queue, args as Args, parkable, response, error)
|
|
130
|
+
}
|
|
131
|
+
throw error
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
},
|
|
57
135
|
})
|
|
136
|
+
if (durable?.outbox === true) {
|
|
137
|
+
queue = getOrCreateOutboxQueue<Args, Return>(url, fn, durable)
|
|
138
|
+
Object.assign(fn, { outbox: outboxFace(queue) })
|
|
139
|
+
}
|
|
140
|
+
return fn
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* The synthetic "unreachable" Response a park reuses when there is no real one — a
|
|
144
|
+
transport failure (fetch rejected) or a backlog park that never fetched. */
|
|
145
|
+
function unreachableResponse(): Response {
|
|
146
|
+
return new Response('queued', { status: 503, statusText: 'Service Unavailable' })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Park the unreachable request (`cause` becomes the entry's parked reason, `entry.error`)
|
|
150
|
+
and return the `kind: 'queued'` HttpError to throw — its `.data` is the parked entry, so
|
|
151
|
+
a caller can `await (error.data as OutboxEntry).settled` for the eventual delivered
|
|
152
|
+
result or server refusal. */
|
|
153
|
+
function queuedThrow<Args>(
|
|
154
|
+
queue: OutboxQueue<Args> | undefined,
|
|
155
|
+
args: Args,
|
|
156
|
+
request: Request,
|
|
157
|
+
response: Response,
|
|
158
|
+
cause: unknown,
|
|
159
|
+
): HttpError {
|
|
160
|
+
const entry = queue?.park(args, request, cause)
|
|
161
|
+
return new HttpError(response, QUEUED, entry)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* A fetch REJECTION (no Response) the durable rpc should park: a transport failure or
|
|
165
|
+
the synthesized client-timeout 504. NOT a caller/scope abort — that's a deliberate
|
|
166
|
+
cancel, not the server being unreachable. (HTTP error STATUSES never reject — `fetch`
|
|
167
|
+
resolves with them — so 4xx/500 are classified on the response, not here.) */
|
|
168
|
+
function shouldParkRejection(error: unknown): boolean {
|
|
169
|
+
if (error === REQUEST_SUPERSEDED) {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
if (error instanceof HttpError) {
|
|
176
|
+
return UNREACHABLE_STATUSES.has(error.response.status)
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The single app-owned queue for a durable RPC url — created + registered on first use
|
|
182
|
+
so every call site (and the global `outbox()`) shares one queue. The send is a plain
|
|
183
|
+
`fetch`; createOutboxQueue rides the entry's abort signal on the resent Request and
|
|
184
|
+
keeps scope-abort + the client timeout out. */
|
|
185
|
+
function getOrCreateOutboxQueue<Args, Return>(
|
|
186
|
+
url: string,
|
|
187
|
+
rpc: RemoteFunction<Args, Return>,
|
|
188
|
+
durable: DurableOptions,
|
|
189
|
+
): OutboxQueue<Args> {
|
|
190
|
+
const existing = outboxRegistry.get(url)
|
|
191
|
+
if (existing !== undefined) {
|
|
192
|
+
return existing as OutboxQueue<Args>
|
|
193
|
+
}
|
|
194
|
+
const queue = createOutboxQueue<Args>({
|
|
195
|
+
url,
|
|
196
|
+
send: (request) => fetch(request),
|
|
197
|
+
store: durable.store,
|
|
198
|
+
})
|
|
199
|
+
outboxRegistry.register(url, queue as OutboxQueue<unknown>, rpc)
|
|
200
|
+
return queue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* The `.outbox` face: callable for the live entries, `retry()` to drain on demand. */
|
|
204
|
+
function outboxFace<Args>(queue: OutboxQueue<Args>): Outbox<Args> {
|
|
205
|
+
const face = (() => queue.entries()) as Outbox<Args>
|
|
206
|
+
face.retry = () => queue.retry()
|
|
207
|
+
return face
|
|
58
208
|
}
|
|
59
209
|
|
|
60
210
|
/*
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { fillBoundary } from './dom/fillBoundary.ts'
|
|
|
3
3
|
import { outlet } from './dom/outlet.ts'
|
|
4
4
|
import { effect } from './effect.ts'
|
|
5
5
|
import { matchRoute } from './matchRoute.ts'
|
|
6
|
-
import {
|
|
6
|
+
import { navigatePath } from './navigate.ts'
|
|
7
7
|
import { clientPage } from './runtime/clientPage.ts'
|
|
8
8
|
import { enterRenderPass } from './runtime/enterRenderPass.ts'
|
|
9
9
|
import { exitRenderPass } from './runtime/exitRenderPass.ts'
|
|
@@ -253,9 +253,16 @@ export function router(
|
|
|
253
253
|
}
|
|
254
254
|
const onPageHide = (): void => {
|
|
255
255
|
/* Mirror the live scroll into the active entry's state before it unloads, so a
|
|
256
|
-
reload
|
|
257
|
-
|
|
256
|
+
non-hydratable reload (which tears the SSR DOM down) can recover it from the
|
|
257
|
+
manual bucket. */
|
|
258
258
|
historyEntries.persist()
|
|
259
|
+
/* Re-enable native scroll restoration for the NEXT document load (a reload): the
|
|
260
|
+
browser restores the SSR DOM's offset before paint, flash-free — where the
|
|
261
|
+
manual bucket, gated behind the async chunk import, would re-apply post-paint
|
|
262
|
+
(a visible scroll). The first paint flips back to `manual` for in-session nav. */
|
|
263
|
+
if ('scrollRestoration' in history) {
|
|
264
|
+
history.scrollRestoration = 'auto'
|
|
265
|
+
}
|
|
259
266
|
}
|
|
260
267
|
const onClick = (event: MouseEvent): void => {
|
|
261
268
|
/* Let the browser own anything that isn't a plain primary-button click:
|
|
@@ -294,15 +301,21 @@ export function router(
|
|
|
294
301
|
event.preventDefault()
|
|
295
302
|
/* Carry the query and hash, not just the pathname, so the destination page's
|
|
296
303
|
page.url sees them and an SPA navigation matches a fresh server load. */
|
|
297
|
-
|
|
304
|
+
navigatePath(destination.pathname + destination.search + destination.hash)
|
|
298
305
|
}
|
|
299
306
|
if (typeof window !== 'undefined') {
|
|
300
|
-
/*
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
307
|
+
/* Scroll restoration is the browser's job on a document load (reload / first
|
|
308
|
+
paint): native restoration runs BEFORE paint, so a hydrating in-place adopt
|
|
309
|
+
returns to the reload offset flash-free (`auto` here also self-heals an entry a
|
|
310
|
+
prior session left `manual`). The first paint then flips to `manual` (below) so
|
|
311
|
+
an in-session back/forward — which tears the page down and rebuilds — restores
|
|
312
|
+
against the new DOM instead of letting the browser restore against the
|
|
313
|
+
torn-down one; `onPageHide` flips back to `auto` for the next load. Still adopt
|
|
314
|
+
the initial entry's id (survives a reload) and stamp it onto the landing entry —
|
|
315
|
+
merging so any `scroll` a prior unload persisted stays put for a non-hydratable
|
|
316
|
+
first paint's manual `restore` to recover. */
|
|
304
317
|
if ('scrollRestoration' in history) {
|
|
305
|
-
history.scrollRestoration = '
|
|
318
|
+
history.scrollRestoration = 'auto'
|
|
306
319
|
}
|
|
307
320
|
historyEntries.adopt(entryOf())
|
|
308
321
|
const landingState = (history.state as AbideHistoryState | null) ?? {}
|
|
@@ -399,7 +412,7 @@ export function router(
|
|
|
399
412
|
/* handle() redirected: go where it pointed, replacing the blocked
|
|
400
413
|
URL so back doesn't trap on it. The router re-probes the target. */
|
|
401
414
|
if (decision.kind === 'redirect') {
|
|
402
|
-
|
|
415
|
+
navigatePath(decision.path, { replace: true })
|
|
403
416
|
return
|
|
404
417
|
}
|
|
405
418
|
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
@@ -421,6 +434,7 @@ export function router(
|
|
|
421
434
|
) {
|
|
422
435
|
divergence += 1
|
|
423
436
|
}
|
|
437
|
+
const firstPaint = first
|
|
424
438
|
const hydrating = first && pageView?.hydratable === true
|
|
425
439
|
first = false
|
|
426
440
|
/* Same page, same layout chain — only params/url differ (e.g. stepping
|
|
@@ -483,11 +497,27 @@ export function router(
|
|
|
483
497
|
)
|
|
484
498
|
/* Reapply the destination entry's scroll once its DOM exists — a
|
|
485
499
|
back/forward restores its offset, a fresh nav scrolls to the `#hash`
|
|
486
|
-
anchor (now built) or the top.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
500
|
+
anchor (now built) or the top. SKIPPED on a hydrating first paint:
|
|
501
|
+
the SSR DOM is adopted in place, so the browser's native restoration
|
|
502
|
+
already returned the entry to its reload offset before paint — the
|
|
503
|
+
manual bucket, gated behind the async chunk import above, would only
|
|
504
|
+
re-apply post-paint (a visible scroll = the flash). A non-hydratable
|
|
505
|
+
first paint tore the SSR DOM down and rebuilt, so it still needs the
|
|
506
|
+
manual restore (recovered from the persisted `history.state`). */
|
|
507
|
+
if (!hydrating) {
|
|
508
|
+
historyEntries.restore(url.hash)
|
|
509
|
+
}
|
|
510
|
+
/* Take over scroll restoration once abide owns the DOM: a later
|
|
511
|
+
same-document back/forward must restore against the rebuilt page, so
|
|
512
|
+
the browser must not. `onPageHide` flips back to `auto` so the next
|
|
513
|
+
document load restores natively (flash-free) again. */
|
|
514
|
+
if (
|
|
515
|
+
firstPaint &&
|
|
516
|
+
typeof history !== 'undefined' &&
|
|
517
|
+
'scrollRestoration' in history
|
|
518
|
+
) {
|
|
519
|
+
history.scrollRestoration = 'manual'
|
|
520
|
+
}
|
|
491
521
|
}
|
|
492
522
|
/* Build / hydrate is the deterministic surface — a codegen defect or a
|
|
493
523
|
throw in user render code fails the SAME way every load, so reloading
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { decodeResponse } from '../../shared/decodeResponse.ts'
|
|
2
|
+
import type { OutboxEntry, OutboxStatus } from '../../shared/types/OutboxEntry.ts'
|
|
3
|
+
import { UNREACHABLE_STATUSES } from '../../shared/UNREACHABLE_STATUSES.ts'
|
|
4
|
+
import { persist } from '../persist.ts'
|
|
5
|
+
import { createDoc as doc } from '../runtime/createDoc.ts'
|
|
6
|
+
import type { PersistenceStore } from '../types/PersistenceStore.ts'
|
|
7
|
+
|
|
8
|
+
/* Persisted form of an entry — the Request reduced to its replayable parts (a live
|
|
9
|
+
Request + AbortController don't serialize). `body`/`contentType` are captured
|
|
10
|
+
asynchronously just after park (Request.text() is async); they seed empty and fill
|
|
11
|
+
in before any reload, so a restored entry replays with its original body. */
|
|
12
|
+
type StoredEntry<Args> = {
|
|
13
|
+
id: string
|
|
14
|
+
args: Args
|
|
15
|
+
method: string
|
|
16
|
+
url: string
|
|
17
|
+
body: string
|
|
18
|
+
contentType: string
|
|
19
|
+
status: OutboxStatus
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Non-serializable identity for a live entry: its abort handle, the in-session Request
|
|
23
|
+
(body intact), and the per-entry retry trigger. Rebuilt from the persisted item on a
|
|
24
|
+
cold read after reload. */
|
|
25
|
+
type Identity = { controller: AbortController; request: Request; retry: () => Promise<void> }
|
|
26
|
+
|
|
27
|
+
export type OutboxQueue<Args> = {
|
|
28
|
+
park: (args: Args, request: Request, reason?: unknown) => OutboxEntry<Args>
|
|
29
|
+
entries: () => OutboxEntry<Args>[]
|
|
30
|
+
/* Undelivered-entry count — the same reactive read as `entries()`, without building
|
|
31
|
+
the entry objects. A caller parks straight to the tail when this is non-empty. */
|
|
32
|
+
size: () => number
|
|
33
|
+
retry: () => Promise<void>
|
|
34
|
+
drain: () => Promise<void>
|
|
35
|
+
dispose: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Persistence key per RPC url — the queue's durable identity. */
|
|
39
|
+
const keyFor = (url: string): string => `abide:outbox:${url}`
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
A durable, app-owned FIFO retry queue for one RPC. A call fetches directly and throws as
|
|
43
|
+
before while the queue is empty; when the server can't be reached — a transport failure or
|
|
44
|
+
a 502/503/504/52x — the caller `park`s the request here (a side-effect; the call still
|
|
45
|
+
throws), and once a backlog exists the caller parks every later call at the tail too, so
|
|
46
|
+
FIFO order is preserved on replay. Built on `doc` + `persist`, so the queue IS a persisted
|
|
47
|
+
document that survives a reload. Draining is manual — the app calls `retry()` (per-entry or
|
|
48
|
+
whole-queue) when it decides to replay; nothing drains automatically. The drain re-sends
|
|
49
|
+
head-first: any server response removes the entry — a 2xx (delivered) AND a real 4xx/500
|
|
50
|
+
(the server handled+rejected it; retrying won't help, so it leaves the queue) — while a
|
|
51
|
+
still-unreachable result keeps it `queued` for the next `retry()`, and an abort removes it.
|
|
52
|
+
Resends run under the entry's OWN abort signal alone — scope-abort and the client timeout
|
|
53
|
+
are deliberately not composed in, so a parked write survives an unmount and waits out the
|
|
54
|
+
outage.
|
|
55
|
+
*/
|
|
56
|
+
export function createOutboxQueue<Args>(opts: {
|
|
57
|
+
url: string
|
|
58
|
+
send: (request: Request) => Promise<Response>
|
|
59
|
+
store?: PersistenceStore
|
|
60
|
+
}): OutboxQueue<Args> {
|
|
61
|
+
const stored = doc({ items: [] as StoredEntry<Args>[] })
|
|
62
|
+
const persistence = persist(stored, keyFor(opts.url), { store: opts.store, debounce: 0 })
|
|
63
|
+
/* Live identity (controller + Request + retry) by id, rebuilt from persisted items on
|
|
64
|
+
a cold read; `errors` is transient last-failure state (never persisted). */
|
|
65
|
+
const live = new Map<string, Identity>()
|
|
66
|
+
const errors = new Map<string, unknown>()
|
|
67
|
+
/* Per-entry `settled` deferreds, created lazily on first read of `entry.settled` (so a
|
|
68
|
+
never-awaited refusal can't become an unhandled rejection) and resolved/rejected once
|
|
69
|
+
at the drain exit. Transient — a reload starts fresh. */
|
|
70
|
+
type Settler = {
|
|
71
|
+
promise: Promise<unknown>
|
|
72
|
+
resolve: (value: unknown) => void
|
|
73
|
+
reject: (error: unknown) => void
|
|
74
|
+
}
|
|
75
|
+
const settlers = new Map<string, Settler>()
|
|
76
|
+
|
|
77
|
+
/* The lazily-armed `settled` promise for an id: the outcome of this write as if the
|
|
78
|
+
original call had reached the server. */
|
|
79
|
+
const settledFor = (id: string): Promise<unknown> => {
|
|
80
|
+
const existing = settlers.get(id)
|
|
81
|
+
if (existing !== undefined) {
|
|
82
|
+
return existing.promise
|
|
83
|
+
}
|
|
84
|
+
let resolve!: (value: unknown) => void
|
|
85
|
+
let reject!: (error: unknown) => void
|
|
86
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
87
|
+
resolve = res
|
|
88
|
+
reject = rej
|
|
89
|
+
})
|
|
90
|
+
settlers.set(id, { promise, resolve, reject })
|
|
91
|
+
return promise
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Settle an id's deferred (if armed) and drop it — single-shot. */
|
|
95
|
+
const settle = (id: string, run: (settler: Settler) => void): void => {
|
|
96
|
+
const settler = settlers.get(id)
|
|
97
|
+
if (settler !== undefined) {
|
|
98
|
+
run(settler)
|
|
99
|
+
settlers.delete(id)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const items = (): StoredEntry<Args>[] => stored.read('items') as StoredEntry<Args>[]
|
|
104
|
+
|
|
105
|
+
const remove = (id: string): void => {
|
|
106
|
+
const index = items().findIndex((item) => item.id === id)
|
|
107
|
+
if (index !== -1) {
|
|
108
|
+
stored.remove(`items/${index}`)
|
|
109
|
+
persistence.flush()
|
|
110
|
+
}
|
|
111
|
+
live.delete(id)
|
|
112
|
+
errors.delete(id)
|
|
113
|
+
/* A cancel/supersede removal with nobody having delivered first → reject `settled`
|
|
114
|
+
as a canceled call would. Delivery/refusal settle before calling remove, so this
|
|
115
|
+
is a no-op for them (single-shot). */
|
|
116
|
+
settle(id, (s) => s.reject(new DOMException('The outbox entry was canceled', 'AbortError')))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const setStatus = (id: string, status: OutboxStatus): void => {
|
|
120
|
+
const index = items().findIndex((item) => item.id === id)
|
|
121
|
+
if (index !== -1) {
|
|
122
|
+
stored.replace(`items/${index}/status`, status)
|
|
123
|
+
persistence.flush()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* The non-serializable identity for an id: reuse the live one (Request body intact in
|
|
128
|
+
session), else rebuild from the persisted parts (fresh controller, cold reload). */
|
|
129
|
+
const identityFor = (item: StoredEntry<Args>): Identity => {
|
|
130
|
+
const existing = live.get(item.id)
|
|
131
|
+
if (existing !== undefined) {
|
|
132
|
+
return existing
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
controller.signal.addEventListener('abort', () => remove(item.id), { once: true })
|
|
136
|
+
/* `body || undefined` so a bodyless method rebuilds without one (a Request with a
|
|
137
|
+
body-carrying method + no body is fine; GET/DELETE with a body throws). */
|
|
138
|
+
const request = new Request(item.url, {
|
|
139
|
+
method: item.method,
|
|
140
|
+
body: item.body || undefined,
|
|
141
|
+
headers: item.contentType ? { 'content-type': item.contentType } : undefined,
|
|
142
|
+
})
|
|
143
|
+
/* Per-entry `retry()` kicks a FIFO drain — every entry is `queued`, so it resends
|
|
144
|
+
head-first and reaches this one in order. */
|
|
145
|
+
const identity: Identity = { controller, request, retry: () => drain() }
|
|
146
|
+
live.set(item.id, identity)
|
|
147
|
+
return identity
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const toLive = (item: StoredEntry<Args>): OutboxEntry<Args> => {
|
|
151
|
+
const { controller, request, retry } = identityFor(item)
|
|
152
|
+
return {
|
|
153
|
+
id: item.id,
|
|
154
|
+
controller,
|
|
155
|
+
request,
|
|
156
|
+
args: item.args,
|
|
157
|
+
status: item.status,
|
|
158
|
+
error: errors.get(item.id),
|
|
159
|
+
retry,
|
|
160
|
+
/* Lazy: the deferred is only armed when someone reads `settled`, so an
|
|
161
|
+
unawaited refusal can't surface as an unhandled rejection. */
|
|
162
|
+
get settled() {
|
|
163
|
+
return settledFor(item.id)
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Persist the request body + content type just after park (Request.text() is async).
|
|
169
|
+
The in-session drain replays the LIVE request (body intact) regardless; this fills
|
|
170
|
+
the persisted form before any reload. A bodyless method leaves the empty seed. */
|
|
171
|
+
const captureRequest = async (id: string, request: Request): Promise<void> => {
|
|
172
|
+
const body = request.body === null ? '' : await request.clone().text()
|
|
173
|
+
const contentType = request.headers.get('content-type') ?? ''
|
|
174
|
+
const index = items().findIndex((item) => item.id === id)
|
|
175
|
+
if (index === -1) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
stored.replace(`items/${index}/body`, body)
|
|
179
|
+
stored.replace(`items/${index}/contentType`, contentType)
|
|
180
|
+
persistence.flush()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Resends run under the entry's OWN signal only — a parked write survives unmount +
|
|
184
|
+
waits out the outage, so scope-abort + client-timeout are NOT composed in. */
|
|
185
|
+
const sendable = (entry: OutboxEntry<Args>): Request =>
|
|
186
|
+
new Request(entry.request, { signal: entry.controller.signal })
|
|
187
|
+
|
|
188
|
+
let draining = false
|
|
189
|
+
let settled: Promise<void> = Promise.resolve()
|
|
190
|
+
const drain = (): Promise<void> => {
|
|
191
|
+
if (draining) {
|
|
192
|
+
return settled
|
|
193
|
+
}
|
|
194
|
+
draining = true
|
|
195
|
+
settled = (async () => {
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
const head = items()[0]
|
|
199
|
+
if (head === undefined) {
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
const entry = toLive(head)
|
|
203
|
+
if (entry.controller.signal.aborted) {
|
|
204
|
+
remove(head.id) // canceled while queued
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
setStatus(head.id, 'sending')
|
|
208
|
+
try {
|
|
209
|
+
const response = await opts.send(sendable(entry))
|
|
210
|
+
if (UNREACHABLE_STATUSES.has(response.status)) {
|
|
211
|
+
setStatus(head.id, 'queued') // still down → wait for the next trigger
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
/* The server responded — 2xx OR a real 4xx/500. Either way it
|
|
215
|
+
HANDLED the request (the outbox only holds the unreachable), so
|
|
216
|
+
the entry leaves the queue and the next proceeds in order. Decode
|
|
217
|
+
it exactly as a live call would so `settled` resolves with the
|
|
218
|
+
result, or rejects with the real HttpError on a refusal. */
|
|
219
|
+
try {
|
|
220
|
+
const value = await decodeResponse(response)
|
|
221
|
+
settle(head.id, (s) => s.resolve(value))
|
|
222
|
+
} catch (responseError) {
|
|
223
|
+
settle(head.id, (s) => s.reject(responseError))
|
|
224
|
+
}
|
|
225
|
+
remove(head.id)
|
|
226
|
+
continue
|
|
227
|
+
} catch {
|
|
228
|
+
if (entry.controller.signal.aborted) {
|
|
229
|
+
remove(head.id) // canceled mid-send
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
/* Transport failure on resend — still unreachable, keep queued. */
|
|
233
|
+
setStatus(head.id, 'queued')
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
draining = false
|
|
239
|
+
}
|
|
240
|
+
})()
|
|
241
|
+
return settled
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
park(args, request, reason) {
|
|
246
|
+
const id = crypto.randomUUID()
|
|
247
|
+
const controller = new AbortController()
|
|
248
|
+
controller.signal.addEventListener('abort', () => remove(id), { once: true })
|
|
249
|
+
live.set(id, { controller, request, retry: () => drain() })
|
|
250
|
+
if (reason !== undefined) {
|
|
251
|
+
errors.set(id, reason)
|
|
252
|
+
}
|
|
253
|
+
stored.add('items/-', {
|
|
254
|
+
id,
|
|
255
|
+
args,
|
|
256
|
+
method: request.method,
|
|
257
|
+
url: request.url,
|
|
258
|
+
body: '',
|
|
259
|
+
contentType: '',
|
|
260
|
+
status: 'queued',
|
|
261
|
+
})
|
|
262
|
+
persistence.flush()
|
|
263
|
+
void captureRequest(id, request)
|
|
264
|
+
const item = items().find((entry) => entry.id === id) as StoredEntry<Args>
|
|
265
|
+
return toLive(item)
|
|
266
|
+
},
|
|
267
|
+
entries() {
|
|
268
|
+
return items().map(toLive)
|
|
269
|
+
},
|
|
270
|
+
size() {
|
|
271
|
+
return items().length
|
|
272
|
+
},
|
|
273
|
+
retry() {
|
|
274
|
+
return drain()
|
|
275
|
+
},
|
|
276
|
+
drain,
|
|
277
|
+
dispose() {
|
|
278
|
+
persistence.dispose()
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { canonicalJson } from '../../shared/canonicalJson.ts'
|
|
2
|
+
import { outboxProbeSlot } from '../../shared/outboxProbeSlot.ts'
|
|
3
|
+
import type { OutboxQueue } from './createOutboxQueue.ts'
|
|
4
|
+
|
|
5
|
+
/* One registered durable RPC: its url, the RemoteFunction it belongs to (tag for the
|
|
6
|
+
global aggregate), and its live queue. */
|
|
7
|
+
type RegisteredOutbox = { url: string; rpc: unknown; queue: OutboxQueue<unknown> }
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Client registry of every durable RPC's outbox queue, keyed by url. A durable RPC
|
|
11
|
+
(`outbox: true`) registers on first use so the global `outbox()` aggregate can
|
|
12
|
+
flatten every queue and `pending()` can see queued entries. App-scoped, not component-
|
|
13
|
+
scoped — the queues outlive any mount.
|
|
14
|
+
*/
|
|
15
|
+
const registered = new Map<string, RegisteredOutbox>()
|
|
16
|
+
|
|
17
|
+
export const outboxRegistry = {
|
|
18
|
+
register(url: string, queue: OutboxQueue<unknown>, rpc: unknown): void {
|
|
19
|
+
registered.set(url, { url, rpc, queue })
|
|
20
|
+
},
|
|
21
|
+
get(url: string): OutboxQueue<unknown> | undefined {
|
|
22
|
+
return registered.get(url)?.queue
|
|
23
|
+
},
|
|
24
|
+
all(): RegisteredOutbox[] {
|
|
25
|
+
return [...registered.values()]
|
|
26
|
+
},
|
|
27
|
+
/* Drop every registered queue. App-scoped state outlives any mount, so tests that
|
|
28
|
+
park writes share one process-global registry; reset() gives each its own clean
|
|
29
|
+
slate. Only the in-memory registry is dropped — `queue.dispose()` keeps the
|
|
30
|
+
persisted snapshot (a reload rehydrates it), so a sign-out that must abandon
|
|
31
|
+
unsynced work has to clear persistence separately. */
|
|
32
|
+
reset(): void {
|
|
33
|
+
for (const { queue } of registered.values()) {
|
|
34
|
+
queue.dispose?.()
|
|
35
|
+
}
|
|
36
|
+
registered.clear()
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* The queues a pending() selector spans: a durable rpc selector carries the queue's
|
|
41
|
+
`url`, so it narrows to that one; the bare form spans every registered queue; any
|
|
42
|
+
other selector (producer fn, tags, subscribable) carries no url and matches none. */
|
|
43
|
+
const queuesFor = (selector: unknown): OutboxQueue<unknown>[] => {
|
|
44
|
+
if (selector === undefined) {
|
|
45
|
+
return outboxRegistry.all().map(({ queue }) => queue)
|
|
46
|
+
}
|
|
47
|
+
const url = (selector as { url?: unknown }).url
|
|
48
|
+
if (typeof url !== 'string') {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
const queue = outboxRegistry.get(url)
|
|
52
|
+
return queue === undefined ? [] : [queue]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Prober for the shared pending() probe (see outboxProbeSlot). A parked write has no
|
|
56
|
+
value yet, so it counts as pending. Reads queue.entries() (doc-backed), so the probe
|
|
57
|
+
re-runs as writes park, drain, fail, or cancel; optional args narrow to one parked
|
|
58
|
+
call by structural compare — the double-submit guard for a form. The compare uses
|
|
59
|
+
canonicalJson (key-sorted, the encoder keyForRemoteCall keys body args with), not
|
|
60
|
+
JSON.stringify, so a selector's args match a parked entry regardless of key order —
|
|
61
|
+
the same equality the cache-side selector grammar already uses. */
|
|
62
|
+
outboxProbeSlot.probe = (selector, args): boolean => {
|
|
63
|
+
const entries = queuesFor(selector).flatMap((queue) => queue.entries())
|
|
64
|
+
if (args === undefined) {
|
|
65
|
+
return entries.length > 0
|
|
66
|
+
}
|
|
67
|
+
const key = canonicalJson(args)
|
|
68
|
+
return entries.some((entry) => canonicalJson(entry.args) === key)
|
|
69
|
+
}
|