@abide/abide 0.42.0 → 0.43.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 +234 -300
- package/CHANGELOG.md +34 -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 +75 -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 +3 -3
- 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
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'
|
|
@@ -294,7 +294,7 @@ export function router(
|
|
|
294
294
|
event.preventDefault()
|
|
295
295
|
/* Carry the query and hash, not just the pathname, so the destination page's
|
|
296
296
|
page.url sees them and an SPA navigation matches a fresh server load. */
|
|
297
|
-
|
|
297
|
+
navigatePath(destination.pathname + destination.search + destination.hash)
|
|
298
298
|
}
|
|
299
299
|
if (typeof window !== 'undefined') {
|
|
300
300
|
/* Own scroll restoration: the browser would restore against the pre-teardown
|
|
@@ -399,7 +399,7 @@ export function router(
|
|
|
399
399
|
/* handle() redirected: go where it pointed, replacing the blocked
|
|
400
400
|
URL so back doesn't trap on it. The router re-probes the target. */
|
|
401
401
|
if (decision.kind === 'redirect') {
|
|
402
|
-
|
|
402
|
+
navigatePath(decision.path, { replace: true })
|
|
403
403
|
return
|
|
404
404
|
}
|
|
405
405
|
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
@@ -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
|
+
}
|
|
@@ -80,7 +80,13 @@ export function getSocketChannel(): SocketChannel {
|
|
|
80
80
|
if (document.hidden) {
|
|
81
81
|
return
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
/* A socket reference in ANY non-closed state means one is already in play — bail.
|
|
84
|
+
CONNECTING/OPEN are healthy; CLOSING means a close event is still pending and the
|
|
85
|
+
close handler (which alone nulls `ws` and re-arms the backoff) hasn't run yet.
|
|
86
|
+
Spawning here would orphan that socket: its deferred close fires against the shared
|
|
87
|
+
`ws`, draining the fresh connection's subs and nulling it. The close handler always
|
|
88
|
+
clears `ws`, so reconnection is never stranded — it just sequences after the close. */
|
|
89
|
+
if (ws) {
|
|
84
90
|
return
|
|
85
91
|
}
|
|
86
92
|
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { computed } from '../computed.ts'
|
|
2
|
+
import type { effect } from '../effect.ts'
|
|
2
3
|
import type { linked } from '../linked.ts'
|
|
3
4
|
import type { Cell } from '../runtime/types/Cell.ts'
|
|
4
5
|
import type { Patch } from '../runtime/types/Patch.ts'
|
|
@@ -35,14 +36,28 @@ export type Scope = {
|
|
|
35
36
|
cell: <T>(path: string) => Cell<T>
|
|
36
37
|
derive: <T>(path: string, compute: () => T) => () => T
|
|
37
38
|
snapshot: () => unknown
|
|
38
|
-
/*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
/* The reactive primitives — namespaced under the scope, but AMBIENT-bound, not
|
|
40
|
+
receiver-bound (unlike the data methods above, which act on THIS scope's doc).
|
|
41
|
+
`state`/`linked`/`computed`/`effect` create their cell in whatever scope is currently
|
|
42
|
+
rendering and own their teardown to the finest ambient BUILD WINDOW — a control-flow
|
|
43
|
+
branch or list row, not the whole component. So `someScope.computed(fn)` does NOT
|
|
44
|
+
create state in someScope; the receiver is namespacing only, the cells bind the
|
|
45
|
+
ambient window. Binding them to the receiver instead would leak a branch-local cell
|
|
46
|
+
past a branch flip (the build window is finer-grained than the lexical scope — see
|
|
47
|
+
ADR-0012). Forms: a writable `state(x, transform)` gate, a reseeding `linked`, the
|
|
48
|
+
read-only `computed(compute)` (a writable computed does not exist — that write is
|
|
49
|
+
expressed at the binding `bind:value={{ get, set }}`; the serializable computed doc
|
|
50
|
+
slot is `derive` above), and `effect`, the reaction re-run on dep change (the SSR
|
|
51
|
+
back-end strips it — effects are client lifecycle). */
|
|
43
52
|
state: typeof state
|
|
44
53
|
linked: typeof linked
|
|
45
54
|
computed: typeof computed
|
|
55
|
+
effect: typeof effect
|
|
56
|
+
/* Adopts a teardown into this scope's lifetime — the build's reactivity stopper
|
|
57
|
+
(effects/listeners), run first on `dispose` (before children, before capabilities).
|
|
58
|
+
Internal: the mount core registers the build's disposer here so a component has ONE
|
|
59
|
+
teardown (`dispose`) rather than a separate stop + dispose composed at every site. */
|
|
60
|
+
own: (dispose: () => void) => void
|
|
46
61
|
/* tree */
|
|
47
62
|
child: (initial?: unknown) => Scope
|
|
48
63
|
root: () => Scope
|
package/template/CLAUDE.md
CHANGED
|
@@ -15,7 +15,7 @@ at `node_modules/@abide/abide/README.md`.
|
|
|
15
15
|
|
|
16
16
|
- One export per file, named after the file. No barrels — import each name by its
|
|
17
17
|
own path (`@abide/abide/server/GET`, `@abide/abide/shared/cache`, …).
|
|
18
|
-
-
|
|
18
|
+
- RPCs live in `src/server/rpc/<name>.ts`; sockets in `src/server/sockets/`;
|
|
19
19
|
pages are `**/page.abide`, layouts `**/layout.abide`.
|
|
20
20
|
- Generated types land in `src/.abide/` — do not hand-edit them; run `abide dev`/
|
|
21
21
|
`abide check` to regenerate.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
RPC module — every file under src/server/rpc/ exposes exactly one
|
|
2
|
+
RPC module — every file under src/server/rpc/ exposes exactly one RPC: a remote
|
|
3
3
|
function. The filename is the export name and the URL path (under `/rpc/`),
|
|
4
|
-
and the imported
|
|
5
|
-
HTTP method. The bundler swaps the runtime per build target: direct call on
|
|
4
|
+
and the imported method helper (GET / POST / PUT / PATCH / DELETE / HEAD) picks
|
|
5
|
+
the HTTP method. The bundler swaps the runtime per build target: direct call on
|
|
6
6
|
the server, fetch over the network on the client.
|
|
7
7
|
|
|
8
8
|
Args (what the caller passes in) come from the handler's parameter type —
|
|
@@ -3,12 +3,12 @@ Test module — `createTestApp()` boots your real app on an ephemeral port (the
|
|
|
3
3
|
same wiring as `abide start`, no fixtures) and hands back the whole surface:
|
|
4
4
|
|
|
5
5
|
app.fetch(path) — pages and raw HTTP, origin + mount base resolved
|
|
6
|
-
app.rpc.<
|
|
6
|
+
app.rpc.<name>(args) — RPCs over the real pipeline (CSRF, cookies, base),
|
|
7
7
|
typed and decoded; .raw(args) for the Response
|
|
8
8
|
app.sockets.<name> — a live Socket: iterate it, .tail(n), .publish(m)
|
|
9
9
|
app.health() — the /__abide/health payload
|
|
10
10
|
|
|
11
|
-
`app.rpc` / `app.sockets` are typed from your own
|
|
11
|
+
`app.rpc` / `app.sockets` are typed from your own RPCs and sockets — generated
|
|
12
12
|
into src/.abide during a build, so the keys exist with no imports. The abide
|
|
13
13
|
preload (see bunfig.toml) makes the project's routes resolvable under bun test.
|
|
14
14
|
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
One RPC verb projected for the inspector: the registry entry reduced to the
|
|
3
|
-
serializable facts the UI renders — where it mounts, its method, which
|
|
4
|
-
non-browser surfaces advertise it, and its argument/result shapes as JSON
|
|
5
|
-
Schema. Schemas project through jsonSchemaForSchema (same as MCP/OpenAPI), so
|
|
6
|
-
a verb whose library can't render a schema still lists with an opaque shape.
|
|
7
|
-
*/
|
|
8
|
-
export type InspectorVerb = {
|
|
9
|
-
/* Registry key — the rpc URL the verb mounts at (e.g. /rpc/users/create). */
|
|
10
|
-
url: string
|
|
11
|
-
/* HTTP method bound to the verb. */
|
|
12
|
-
method: string
|
|
13
|
-
/* Which surfaces advertise it (browser/mcp/cli), from the verb's ClientFlags. */
|
|
14
|
-
clients: Record<string, boolean>
|
|
15
|
-
/* Argument-bag shape as JSON Schema; undefined when the verb declares none. */
|
|
16
|
-
inputSchema: Record<string, unknown> | undefined
|
|
17
|
-
/* Success-body shape as JSON Schema; undefined when the verb declares none. */
|
|
18
|
-
outputSchema: Record<string, unknown> | undefined
|
|
19
|
-
/* True when the verb declares a filesSchema — it accepts multipart File parts. */
|
|
20
|
-
files: boolean
|
|
21
|
-
/* Per-verb handler deadline in ms; undefined = no deadline. */
|
|
22
|
-
timeout: number | undefined
|
|
23
|
-
/* Per-verb received-body cap in bytes; undefined = Bun's server-wide ceiling. */
|
|
24
|
-
maxBodySize: number | undefined
|
|
25
|
-
/* True when the verb opts out of the same-origin CSRF gate. */
|
|
26
|
-
crossOrigin: boolean | undefined
|
|
27
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { HttpVerb } from './types/HttpVerb.ts'
|
|
2
|
-
|
|
3
|
-
/*
|
|
4
|
-
Reads the HTTP verb of an $rpc module from its source. Every file under
|
|
5
|
-
src/server/rpc/ follows the convention `export const <name> = GET(fn)` (the
|
|
6
|
-
verb helper picks the method, possibly with an explicit generic
|
|
7
|
-
`GET<{…}>(fn)`), so the helper name at the export is the method. Returns
|
|
8
|
-
undefined when no verb export matches — the caller skips the file rather
|
|
9
|
-
than guessing. Used by the rpc.d.ts codegen to type url() against
|
|
10
|
-
query-carrying verbs; matching the same convention the bundler rewrites
|
|
11
|
-
keeps the two from drifting.
|
|
12
|
-
*/
|
|
13
|
-
const VERB_EXPORT = /export\s+const\s+\w+\s*=\s*(GET|POST|PUT|PATCH|DELETE|HEAD)\s*[<(]/
|
|
14
|
-
|
|
15
|
-
export function detectVerbMethod(source: string): HttpVerb | undefined {
|
|
16
|
-
return (source.match(VERB_EXPORT)?.[1] as HttpVerb | undefined) ?? undefined
|
|
17
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export type HttpVerb = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
A durable, FIFO queue of pending mutations that drains when the client is online —
|
|
3
|
-
the local-first half of "act now, sync later". `enqueue` records a serializable
|
|
4
|
-
payload (persisted, so it survives a reload) and tries to drain; `pending` is a
|
|
5
|
-
reactive read of the payloads still in flight (for a "3 unsynced" indicator);
|
|
6
|
-
`flush` drains on demand (and is what the reconnect effect calls); `dispose` stops
|
|
7
|
-
draining and detaches persistence.
|
|
8
|
-
*/
|
|
9
|
-
export type Outbox<T> = {
|
|
10
|
-
enqueue: (payload: T) => void
|
|
11
|
-
pending: () => T[]
|
|
12
|
-
flush: () => Promise<void>
|
|
13
|
-
dispose: () => void
|
|
14
|
-
}
|