@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.
Files changed (146) hide show
  1. package/AGENTS.md +234 -300
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +50 -93
  4. package/package.json +3 -2
  5. package/src/abideResolverPlugin.ts +104 -14
  6. package/src/buildCli.ts +1 -1
  7. package/src/discoveryEntry.ts +3 -3
  8. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  9. package/src/lib/cli/createClient.ts +12 -12
  10. package/src/lib/cli/dispatchCommand.ts +1 -1
  11. package/src/lib/cli/printSessionStatus.ts +1 -1
  12. package/src/lib/cli/resolveCliTarget.ts +1 -1
  13. package/src/lib/cli/runCli.ts +1 -1
  14. package/src/lib/cli/types/CliManifest.ts +1 -1
  15. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  16. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  17. package/src/lib/mcp/createMcpServer.ts +3 -3
  18. package/src/lib/mcp/mcpSurface.ts +14 -14
  19. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  20. package/src/lib/server/DELETE.ts +4 -4
  21. package/src/lib/server/GET.ts +4 -4
  22. package/src/lib/server/HEAD.ts +4 -4
  23. package/src/lib/server/PATCH.ts +4 -4
  24. package/src/lib/server/POST.ts +4 -4
  25. package/src/lib/server/PUT.ts +4 -4
  26. package/src/lib/server/agent.ts +4 -4
  27. package/src/lib/server/env.ts +1 -1
  28. package/src/lib/server/error.ts +49 -7
  29. package/src/lib/server/json.ts +1 -1
  30. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  31. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  32. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  33. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  34. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  35. package/src/lib/server/rpc/parseArgs.ts +6 -6
  36. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  37. package/src/lib/server/rpc/registerRpc.ts +6 -0
  38. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  39. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  40. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  41. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  42. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  43. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  44. package/src/lib/server/rpc/unprocessed.ts +6 -7
  45. package/src/lib/server/rpc/validationError.ts +17 -0
  46. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  47. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  48. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  49. package/src/lib/server/runtime/createServer.ts +4 -4
  50. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  51. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  52. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  53. package/src/lib/server/runtime/registryManifests.ts +2 -2
  54. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  55. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  56. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  57. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  58. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  59. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  60. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  61. package/src/lib/shared/HttpError.ts +15 -1
  62. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  63. package/src/lib/shared/buildRpcRequest.ts +5 -5
  64. package/src/lib/shared/cache.ts +50 -168
  65. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  66. package/src/lib/shared/createCacheStore.ts +12 -0
  67. package/src/lib/shared/createRemoteFunction.ts +11 -6
  68. package/src/lib/shared/decodeResponse.ts +2 -2
  69. package/src/lib/shared/detectRpcMethod.ts +17 -0
  70. package/src/lib/shared/emitLogRecord.ts +3 -3
  71. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  72. package/src/lib/shared/findExportCallSite.ts +30 -273
  73. package/src/lib/shared/forwardHeaders.ts +2 -2
  74. package/src/lib/shared/httpErrorFor.ts +26 -0
  75. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  76. package/src/lib/shared/isIdentPart.ts +9 -0
  77. package/src/lib/shared/isIdentStart.ts +8 -0
  78. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  79. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  80. package/src/lib/shared/log.ts +1 -1
  81. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  82. package/src/lib/shared/pending.ts +4 -1
  83. package/src/lib/shared/prepareRpcModule.ts +96 -14
  84. package/src/lib/shared/probeRegistries.ts +17 -4
  85. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  86. package/src/lib/shared/remoteMetaStore.ts +2 -2
  87. package/src/lib/shared/resolveClientFlags.ts +1 -1
  88. package/src/lib/shared/skipNonCode.ts +230 -0
  89. package/src/lib/shared/streamResponse.ts +9 -6
  90. package/src/lib/shared/stripImport.ts +3 -3
  91. package/src/lib/shared/types/CacheEntry.ts +2 -4
  92. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  93. package/src/lib/shared/types/CacheStore.ts +13 -7
  94. package/src/lib/shared/types/ClientFlags.ts +1 -1
  95. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  96. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  97. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  98. package/src/lib/shared/types/HttpMethod.ts +1 -0
  99. package/src/lib/shared/types/Outbox.ts +9 -0
  100. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  101. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  102. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  103. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  104. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  105. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  106. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  107. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  108. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  109. package/src/lib/shared/url.ts +3 -3
  110. package/src/lib/shared/writeRpcDts.ts +7 -7
  111. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  112. package/src/lib/test/createTestApp.ts +10 -10
  113. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  114. package/src/lib/ui/compile/compileShadow.ts +75 -21
  115. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  116. package/src/lib/ui/compile/generateBuild.ts +43 -60
  117. package/src/lib/ui/compile/generateSSR.ts +84 -54
  118. package/src/lib/ui/compile/ifPlan.ts +30 -0
  119. package/src/lib/ui/compile/parseTemplate.ts +41 -11
  120. package/src/lib/ui/compile/stripEffects.ts +17 -9
  121. package/src/lib/ui/compile/switchPlan.ts +22 -0
  122. package/src/lib/ui/compile/tryPlan.ts +29 -0
  123. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  124. package/src/lib/ui/compile/types/TemplateNode.ts +6 -2
  125. package/src/lib/ui/createScope.ts +20 -2
  126. package/src/lib/ui/dom/disposeRange.ts +6 -10
  127. package/src/lib/ui/dom/hydrate.ts +2 -5
  128. package/src/lib/ui/dom/mount.ts +1 -2
  129. package/src/lib/ui/dom/withScope.ts +6 -9
  130. package/src/lib/ui/effect.ts +4 -2
  131. package/src/lib/ui/navigate.ts +40 -13
  132. package/src/lib/ui/outbox.ts +30 -110
  133. package/src/lib/ui/remoteProxy.ts +160 -10
  134. package/src/lib/ui/router.ts +3 -3
  135. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  136. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  137. package/src/lib/ui/socketChannel.ts +7 -1
  138. package/src/lib/ui/types/Scope.ts +20 -5
  139. package/template/CLAUDE.md +1 -1
  140. package/template/src/server/rpc/getHello.ts +3 -3
  141. package/template/test/app.test.ts +2 -2
  142. package/src/lib/server/rpc/registerVerb.ts +0 -6
  143. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  144. package/src/lib/shared/detectVerbMethod.ts +0 -17
  145. package/src/lib/shared/types/HttpVerb.ts +0 -1
  146. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -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 { navigate } from './navigate.ts'
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
- navigate(destination.pathname + destination.search + destination.hash)
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
- navigate(decision.path, { replace: true })
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
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
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
- /* the `.value`-cell signal forms, reachable only through a scope (the standalone
39
- `state`/`linked`/`computed` are no longer exported): a writable `state(x, transform)`
40
- gate, a reseeding `linked`, and the read-only `computed(compute)`. A writable
41
- computed does not exist that write is expressed at the binding (`bind:value={{
42
- get, set }}`). The serializable computed doc slot is `derive` above. */
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
@@ -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
- - RPC verbs live in `src/server/rpc/<name>.ts`; sockets in `src/server/sockets/`;
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 verb-bound remote
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 verb (GET / POST / PUT / PATCH / DELETE / HEAD) picks the
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.<verb>(args) — verbs over the real pipeline (CSRF, cookies, base),
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 verbs and sockets — generated
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,6 +0,0 @@
1
- import type { VerbRegistryEntry } from './types/VerbRegistryEntry.ts'
2
- import { verbRegistry } from './verbRegistry.ts'
3
-
4
- export function registerVerb(entry: VerbRegistryEntry): void {
5
- verbRegistry.set(entry.remote.url, entry)
6
- }
@@ -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
- }