@abide/abide 0.41.1 → 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 (167) hide show
  1. package/AGENTS.md +234 -294
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +51 -95
  4. package/package.json +3 -2
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +107 -19
  7. package/src/buildCli.ts +1 -1
  8. package/src/discoveryEntry.ts +3 -3
  9. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  10. package/src/lib/cli/createClient.ts +12 -12
  11. package/src/lib/cli/dispatchCommand.ts +1 -1
  12. package/src/lib/cli/printSessionStatus.ts +1 -1
  13. package/src/lib/cli/resolveCliTarget.ts +1 -1
  14. package/src/lib/cli/runCli.ts +1 -1
  15. package/src/lib/cli/types/CliManifest.ts +1 -1
  16. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  17. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  18. package/src/lib/mcp/createMcpServer.ts +3 -3
  19. package/src/lib/mcp/mcpSurface.ts +14 -14
  20. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  21. package/src/lib/server/DELETE.ts +4 -4
  22. package/src/lib/server/GET.ts +4 -4
  23. package/src/lib/server/HEAD.ts +4 -4
  24. package/src/lib/server/PATCH.ts +4 -4
  25. package/src/lib/server/POST.ts +4 -4
  26. package/src/lib/server/PUT.ts +4 -4
  27. package/src/lib/server/agent.ts +4 -4
  28. package/src/lib/server/env.ts +1 -1
  29. package/src/lib/server/error.ts +49 -7
  30. package/src/lib/server/json.ts +1 -1
  31. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  32. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  33. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  34. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  35. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  36. package/src/lib/server/rpc/parseArgs.ts +6 -6
  37. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  38. package/src/lib/server/rpc/registerRpc.ts +6 -0
  39. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  40. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  41. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  42. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  43. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  44. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  45. package/src/lib/server/rpc/unprocessed.ts +6 -7
  46. package/src/lib/server/rpc/validationError.ts +17 -0
  47. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  48. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  49. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  50. package/src/lib/server/runtime/createServer.ts +4 -4
  51. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  52. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  53. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  54. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  55. package/src/lib/server/runtime/registryManifests.ts +2 -2
  56. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  57. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  58. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  59. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  60. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  61. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  62. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  63. package/src/lib/shared/HttpError.ts +15 -1
  64. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  65. package/src/lib/shared/buildRpcRequest.ts +5 -5
  66. package/src/lib/shared/cache.ts +50 -168
  67. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  68. package/src/lib/shared/createCacheStore.ts +12 -0
  69. package/src/lib/shared/createRemoteFunction.ts +11 -6
  70. package/src/lib/shared/decodeResponse.ts +2 -2
  71. package/src/lib/shared/detectRpcMethod.ts +17 -0
  72. package/src/lib/shared/emitLogRecord.ts +3 -3
  73. package/src/lib/shared/escapeRegex.ts +9 -0
  74. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  75. package/src/lib/shared/fileName.ts +9 -0
  76. package/src/lib/shared/fileStem.ts +3 -1
  77. package/src/lib/shared/findExportCallSite.ts +30 -273
  78. package/src/lib/shared/forwardHeaders.ts +2 -2
  79. package/src/lib/shared/httpErrorFor.ts +26 -0
  80. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  81. package/src/lib/shared/isIdentPart.ts +9 -0
  82. package/src/lib/shared/isIdentStart.ts +8 -0
  83. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  84. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  85. package/src/lib/shared/log.ts +1 -1
  86. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  87. package/src/lib/shared/pending.ts +4 -1
  88. package/src/lib/shared/prepareRpcModule.ts +96 -14
  89. package/src/lib/shared/probeRegistries.ts +17 -4
  90. package/src/lib/shared/programNameForPackage.ts +3 -1
  91. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  92. package/src/lib/shared/remoteMetaStore.ts +2 -2
  93. package/src/lib/shared/resolveClientFlags.ts +1 -1
  94. package/src/lib/shared/skipNonCode.ts +230 -0
  95. package/src/lib/shared/streamResponse.ts +9 -6
  96. package/src/lib/shared/stripImport.ts +6 -4
  97. package/src/lib/shared/types/CacheEntry.ts +2 -4
  98. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  99. package/src/lib/shared/types/CacheStore.ts +13 -7
  100. package/src/lib/shared/types/ClientFlags.ts +1 -1
  101. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  102. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  103. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  104. package/src/lib/shared/types/HttpMethod.ts +1 -0
  105. package/src/lib/shared/types/Outbox.ts +9 -0
  106. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  107. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  108. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  109. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  110. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  111. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  112. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  113. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  114. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  115. package/src/lib/shared/url.ts +3 -3
  116. package/src/lib/shared/writeRpcDts.ts +7 -7
  117. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  118. package/src/lib/test/createTestApp.ts +10 -10
  119. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  120. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  121. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  122. package/src/lib/ui/compile/compileShadow.ts +117 -31
  123. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  124. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  125. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  126. package/src/lib/ui/compile/generateBuild.ts +45 -62
  127. package/src/lib/ui/compile/generateSSR.ts +90 -60
  128. package/src/lib/ui/compile/ifPlan.ts +30 -0
  129. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  130. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  131. package/src/lib/ui/compile/parseTemplate.ts +565 -115
  132. package/src/lib/ui/compile/stripEffects.ts +17 -9
  133. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  134. package/src/lib/ui/compile/switchPlan.ts +22 -0
  135. package/src/lib/ui/compile/tryPlan.ts +29 -0
  136. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  137. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  138. package/src/lib/ui/compile/types/TemplateNode.ts +15 -2
  139. package/src/lib/ui/createScope.ts +20 -2
  140. package/src/lib/ui/dom/appendText.ts +1 -5
  141. package/src/lib/ui/dom/applyResolved.ts +1 -5
  142. package/src/lib/ui/dom/disposeRange.ts +6 -10
  143. package/src/lib/ui/dom/hydrate.ts +2 -5
  144. package/src/lib/ui/dom/isComment.ts +6 -0
  145. package/src/lib/ui/dom/mount.ts +1 -2
  146. package/src/lib/ui/dom/withScope.ts +6 -9
  147. package/src/lib/ui/effect.ts +4 -2
  148. package/src/lib/ui/navigate.ts +40 -13
  149. package/src/lib/ui/outbox.ts +30 -110
  150. package/src/lib/ui/remoteProxy.ts +160 -10
  151. package/src/lib/ui/router.ts +3 -3
  152. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  153. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  154. package/src/lib/ui/runtime/createDoc.ts +3 -3
  155. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  156. package/src/lib/ui/socketChannel.ts +7 -1
  157. package/src/lib/ui/types/Scope.ts +20 -5
  158. package/template/.zed/settings.json +4 -0
  159. package/template/CLAUDE.md +1 -1
  160. package/template/src/server/rpc/getHello.ts +3 -3
  161. package/template/src/ui/pages/page.abide +4 -4
  162. package/template/test/app.test.ts +2 -2
  163. package/src/lib/server/rpc/registerVerb.ts +0 -6
  164. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  165. package/src/lib/shared/detectVerbMethod.ts +0 -17
  166. package/src/lib/shared/types/HttpVerb.ts +0 -1
  167. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -1,115 +1,35 @@
1
- import { online as systemOnline } from '../shared/online.ts'
2
- import { effect } from './effect.ts'
3
- import { persist } from './persist.ts'
4
- import { createDoc as doc } from './runtime/createDoc.ts'
5
- import type { Outbox } from './types/Outbox.ts'
6
- import type { PersistenceStore } from './types/PersistenceStore.ts'
1
+ import type { OutboxEntry } from '../shared/types/OutboxEntry.ts'
2
+ import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
3
+ import { outboxRegistry } from './rpcOutbox/outboxRegistry.ts'
7
4
 
8
- /* One queued mutation: a stable id plus the serializable payload to replay. */
9
- type OutboxEntry<T> = { id: string; payload: T }
5
+ /* One entry in the global outbox, tagged with the RPC it belongs to. */
6
+ export type GlobalOutboxEntry = OutboxEntry<unknown> & { rpc: RemoteFunction<unknown, unknown> }
7
+
8
+ /* The global outbox: callable for the flat entry list, awaitable `retry()` to drain every
9
+ queue (resolves when every replay settles). */
10
+ export type GlobalOutbox = (() => GlobalOutboxEntry[]) & { retry: () => Promise<void> }
11
+
12
+ function list(): GlobalOutboxEntry[] {
13
+ return outboxRegistry.all().flatMap(({ rpc, queue }) =>
14
+ queue.entries().map((entry) => ({
15
+ ...entry,
16
+ rpc: rpc as RemoteFunction<unknown, unknown>,
17
+ })),
18
+ )
19
+ }
10
20
 
11
21
  /*
12
- A durable, FIFO mutation queue for local-first writes. Built on `doc` + `persist`,
13
- so the queue IS a persisted document it survives a reload with no extra
14
- machinery and on the patch bus the same as everything else. `enqueue` appends a
15
- payload and tries to drain; the drain sends entries head-first while `online()`
16
- holds:
17
- - success dequeue and continue;
18
- - rejected while now offline keep the head, retry when connectivity returns
19
- (the effect below re-drains on the online edge);
20
- - rejected while still online → a real server/validation failure: drop the entry
21
- so the queue can't wedge, and report it via `onDrop` so the caller can roll back
22
- the optimistic change it applied (e.g. `history().undo()` or a `cache.patch`).
23
- Delivery is at-least-once (a crash between a successful send and its dequeue
24
- re-sends on reload), so `send` should be idempotent. Reconnect RESYNC of reads is
25
- `cache.on`'s job, not this. Client-intended: `send` performs a network mutation.
22
+ The global, reactive view of every durable RPC's outbox a flat list of undelivered
23
+ entries across all `outbox` rpcs, each tagged with its `rpc`. Reactive: reading it in a
24
+ template/effect subscribes to each registered queue, so it updates as writes park, drain,
25
+ fail, or cancel. Use it for an app-wide "N unsynced" badge or a sync panel; cancel a
26
+ pending write through `entry.controller.abort()`, retry one through `entry.retry()`, or
27
+ drain everything through `outbox.retry()`. A single rpc's slice is `rpc.outbox`. Server-
28
+ side there are no client queues, so it returns an empty list.
26
29
  */
27
30
  // @documentation ui
28
- export function outbox<T>({
29
- key,
30
- send,
31
- store,
32
- online = systemOnline,
33
- onDrop,
34
- }: {
35
- key: string
36
- send: (payload: T) => Promise<void>
37
- store?: PersistenceStore
38
- online?: () => boolean
39
- onDrop?: (payload: T, error: unknown) => void
40
- }): Outbox<T> {
41
- const queue = doc({ items: [] as OutboxEntry<T>[] })
42
- /* `debounce: 0` because the queue writes itself synchronously below — a queued
43
- mutation must be durable the instant it's recorded, not after a debounce
44
- window where a crash could lose it. */
45
- const persistence = persist(queue, key, { store, debounce: 0 })
46
- /* Drop the head and make the dequeue durable at once, so a reload never re-sends
47
- an entry already acknowledged. */
48
- const dequeue = (): void => {
49
- queue.remove('items/0')
50
- persistence.flush()
51
- }
52
-
53
- /* At most one drain runs at a time; `settled` lets a caller await the active one.
54
- A drain re-reads the queue each step, so entries enqueued mid-drain are picked
55
- up by the running loop. An empty drain finishes synchronously (it hits no
56
- `await`), so `draining` is back to false before the next `enqueue` — there is no
57
- resolved-but-still-flagged window for a new entry to fall into. */
58
- let draining = false
59
- let settled: Promise<void> = Promise.resolve()
60
- const flush = (): Promise<void> => {
61
- if (draining) {
62
- return settled
63
- }
64
- draining = true
65
- settled = (async () => {
66
- try {
67
- while (online()) {
68
- const { items } = queue.snapshot() as { items: OutboxEntry<T>[] }
69
- const head = items[0]
70
- if (head === undefined) {
71
- break
72
- }
73
- try {
74
- await send(head.payload)
75
- } catch (error) {
76
- /* Went offline mid-send → keep the head, retry on reconnect. */
77
- if (!online()) {
78
- break
79
- }
80
- /* Online but rejected → permanent: drop it, report for rollback. */
81
- dequeue()
82
- onDrop?.(head.payload, error)
83
- continue
84
- }
85
- dequeue()
86
- }
87
- } finally {
88
- draining = false
89
- }
90
- })()
91
- return settled
92
- }
93
-
94
- /* Re-drain whenever connectivity returns — reading `online()` subscribes this
95
- effect, so the offline→online edge fires it. */
96
- const stop = effect(() => {
97
- if (online()) {
98
- void flush()
99
- }
100
- })
101
-
102
- return {
103
- enqueue: (payload: T) => {
104
- queue.add('items/-', { id: crypto.randomUUID(), payload })
105
- persistence.flush() // durable before we attempt the network
106
- void flush()
107
- },
108
- pending: () => (queue.read('items') as OutboxEntry<T>[]).map((entry) => entry.payload),
109
- flush,
110
- dispose: () => {
111
- stop()
112
- persistence.dispose()
113
- },
114
- }
115
- }
31
+ export const outbox: GlobalOutbox = Object.assign(list, {
32
+ async retry(): Promise<void> {
33
+ await Promise.all(outboxRegistry.all().map(({ queue }) => queue.retry()))
34
+ },
35
+ })
@@ -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 { HttpVerb } from '../shared/types/HttpVerb.ts'
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 verb-defined handler. The bundler emits one
18
- call per verb export inside an `$rpc/**` module (GET / POST / …): server
19
- target uses defineVerb (real handler), browser target uses remoteProxy
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: HttpVerb,
57
+ method: HttpMethod,
32
58
  url: string,
59
+ durable?: DurableOptions,
33
60
  ): RemoteFunction<Args, Return> {
34
- return createRemoteFunction<Args, Return>({
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
- cache meta thunk in createRemoteFunction with the same instance,
54
- so cache() readers don't reconstruct it.
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: (_args, getRequest, opts) => fetchWithTimeout(getRequest(), opts),
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
  /*
@@ -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
+ }