@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
@@ -0,0 +1,29 @@
1
+ import { resolveBranches } from './resolveBranches.ts'
2
+ import type { TemplateNode } from './types/TemplateNode.ts'
3
+
4
+ /* The structural shape of a `try` error boundary, resolved once so the build and SSR
5
+ back-ends share one reading and only own emission. Both previously recomputed branch
6
+ resolution, the `finallyChildren` default, the guarded (children-minus-branch) split, and
7
+ the catch binding / presence. */
8
+ export type TryPlan = {
9
+ /* The guarded content — every child except the `catch`/`finally` branch nodes. */
10
+ guarded: TemplateNode[]
11
+ /* The catch content + its bound name (`_error` default); empty when no catch branch. */
12
+ catchChildren: TemplateNode[]
13
+ catchAs: string
14
+ finallyChildren: TemplateNode[]
15
+ /* No catch → a throw propagates to the enclosing boundary (re-throw / `undefined` thunk). */
16
+ hasCatch: boolean
17
+ }
18
+
19
+ /* Resolves a `try` node's branches into the shared structural plan. */
20
+ export function tryPlan(node: Extract<TemplateNode, { kind: 'try' }>): TryPlan {
21
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
22
+ return {
23
+ guarded: node.children.filter((child) => child.kind !== 'branch'),
24
+ catchChildren: catchBranch?.children ?? [],
25
+ catchAs: catchBranch?.as ?? '_error',
26
+ finallyChildren: finallyBranch?.children ?? [],
27
+ hasCatch: catchBranch !== undefined,
28
+ }
29
+ }
@@ -13,9 +13,15 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
13
13
  optional, set only when the parser tracks positions for the type-checking shadow.
14
14
  */
15
15
  export type TemplateAttr =
16
- | { kind: 'static'; name: string; value: string; bare?: true }
17
- | { kind: 'expression'; name: string; code: string; loc?: number }
16
+ | { kind: 'static'; name: string; value: string; bare?: true; nameLoc?: number }
17
+ | { kind: 'expression'; name: string; code: string; loc?: number; nameLoc?: number }
18
18
  | { kind: 'event'; event: string; code: string; loc?: number }
19
19
  | { kind: 'bind'; property: string; code: string; loc?: number }
20
+ /* `class:<name>={code}` — toggles the class on/off by `code`'s truthiness, written
21
+ surgically (classList.toggle) without re-rendering the element. */
22
+ | { kind: 'class'; name: string; code: string; loc?: number }
23
+ /* `style:<property>={code}` — writes one inline style/custom property (style.setProperty)
24
+ reactively, again without a re-render. `property` may be a CSS custom prop (`--pct`). */
25
+ | { kind: 'style'; property: string; code: string; loc?: number }
20
26
  | { kind: 'attach'; code: string; loc?: number }
21
27
  | { kind: 'spread'; code: string; loc?: number }
@@ -72,9 +72,13 @@ export type TemplateNode =
72
72
  | {
73
73
  kind: 'component'
74
74
  name: string
75
+ /* Source offset of the tag name — the anchor for a whole-mount diagnostic
76
+ (a missing required prop, which has no supplied expression to point at). */
77
+ loc?: number
75
78
  /* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
76
- `name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). */
77
- props: { name: string; code: string; loc?: number; spread?: boolean }[]
79
+ `name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). `nameLoc` is
80
+ the prop name's source offset the anchor for an excess-prop diagnostic. */
81
+ props: { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[]
78
82
  children: TemplateNode[]
79
83
  }
80
84
  | { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
@@ -1,4 +1,5 @@
1
1
  import { computed } from './computed.ts'
2
+ import { effect } from './effect.ts'
2
3
  import { history } from './history.ts'
3
4
  import { linked } from './linked.ts'
4
5
  import { persist as persistDoc } from './persist.ts'
@@ -37,6 +38,10 @@ export function createScope(
37
38
  const data = (): Doc => (document ??= createDoc({}))
38
39
  const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
39
40
  const children: Scope[] = []
41
+ /* Adopted build teardowns (the reactivity stopper from the mount core). Disposed
42
+ first and in reverse on teardown, before children and capabilities — so the one
43
+ `dispose` runs the order the call sites hand-composed as `stop(); lexical.dispose()`. */
44
+ const owned: Array<() => void> = []
40
45
  /* Context values shared down the tree, held apart from the reactive doc (which
41
46
  a child does not inherit): keyed by name, read by the closest ancestor walk. */
42
47
  const shared = new Map<string, unknown>()
@@ -56,11 +61,17 @@ export function createScope(
56
61
  cell: (path) => data().cell(path),
57
62
  derive: (path, compute) => data().derive(path, compute),
58
63
  snapshot: () => data().snapshot(),
59
- /* The `.value`-cell signal forms, namespaced under the scope standalone
60
- non-serializing cells (owned by the render scope), reached only here. */
64
+ /* The reactive primitives namespaced under the scope but AMBIENT-bound, not
65
+ receiver-bound: each binds whatever scope is rendering and the finest ambient
66
+ build window (branch/row), so the handle is namespacing, not a binding target.
67
+ Binding to the receiver would leak branch-local cells (see ADR-0012). */
61
68
  state,
62
69
  linked,
63
70
  computed,
71
+ effect,
72
+ own: (dispose) => {
73
+ owned.push(dispose)
74
+ },
64
75
  child: (childInitial = {}) => {
65
76
  const created = createScope(childInitial, self)
66
77
  children.push(created)
@@ -91,6 +102,13 @@ export function createScope(
91
102
  canUndo: () => past?.canUndo() ?? false,
92
103
  canRedo: () => past?.canRedo() ?? false,
93
104
  dispose: () => {
105
+ /* Stop the build's reactivity first (reverse order), before tearing down nested
106
+ children and the boundary-crossing capabilities — the order the call sites
107
+ hand-composed as `stop(); lexical.dispose()`. */
108
+ for (let index = owned.length - 1; index >= 0; index -= 1) {
109
+ owned[index]?.()
110
+ }
111
+ owned.length = 0
94
112
  for (const created of children) {
95
113
  created.dispose()
96
114
  }
@@ -3,18 +3,14 @@ import { clearBetween } from './clearBetween.ts'
3
3
 
4
4
  /*
5
5
  The standard teardown for a marker-bounded range (component, layout/page boundary,
6
- slot): stop the content's reactivity, dispose its lexical scope, and clear the nodes
7
- between the markers leaving the markers in place so a hot swap rebuilds the range.
8
- Shared by every range mount (`fillRange`, `mountRange`, `fillBoundary`) so the one
9
- disposer contract lives in a single place.
6
+ slot): dispose its lexical scope (which stops the content's reactivity first, then its
7
+ nested children and capabilities), and clear the nodes between the markers leaving the
8
+ markers in place so a hot swap rebuilds the range. Shared by every range mount
9
+ (`fillRange`, `mountRange`, `fillBoundary`) so the one disposer contract lives in a
10
+ single place.
10
11
  */
11
- export function disposeRange(
12
- scoped: { stop: () => void; lexical: Scope },
13
- start: Comment,
14
- end: Comment,
15
- ): () => void {
12
+ export function disposeRange(scoped: { lexical: Scope }, start: Comment, end: Comment): () => void {
16
13
  return () => {
17
- scoped.stop()
18
14
  scoped.lexical.dispose()
19
15
  clearBetween(start, end)
20
16
  }
@@ -29,11 +29,8 @@ export function hydrate(
29
29
  try {
30
30
  /* Same shared mount core as `mount` (see `withScope`) — a hydrated component owns a
31
31
  scope too, adopting the model its build adopts — run with the claim cursor active. */
32
- const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
33
- return () => {
34
- stop()
35
- lexical.dispose()
36
- }
32
+ const { lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
33
+ return () => lexical.dispose()
37
34
  } finally {
38
35
  RENDER.hydration = previous
39
36
  }
@@ -23,9 +23,8 @@ export function mount(
23
23
  /* Establish this component's lexical scope (nested, `awaiting` so it adopts the model
24
24
  doc the build's first `doc()` creates) and render pass, run the build under it, and
25
25
  restore the previous scope — the shared mount core (see `withScope`). */
26
- const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
26
+ const { lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
27
27
  return () => {
28
- stop()
29
28
  lexical.dispose()
30
29
  host.textContent = ''
31
30
  }
@@ -11,23 +11,20 @@ under the current one in `awaiting` mode — so it adopts the model doc its firs
11
11
  creates — brackets a render pass — so its `await`/`try` block ids draw from the shared
12
12
  counter in SSR-stream order — runs `build`, then restores the previous scope (synchronous
13
13
  build, so the restore is exact). `build` returns its reactivity stopper (from `scope` or
14
- `fillBefore`); the caller composes its own DOM teardown (clear host vs clear range) around
15
- the returned `stop`/`lexical`, keeping the one scope/render-pass contract in a single place.
14
+ `fillBefore`), which the lexical scope ADOPTS (`own`) so the scope has a single teardown:
15
+ the caller wraps its own DOM teardown (clear host vs clear range) around one
16
+ `lexical.dispose()`, no longer composing a separate `stop()` at every site.
16
17
  */
17
- export function withScope(
18
- label: string | undefined,
19
- build: () => () => void,
20
- ): { stop: () => void; lexical: Scope } {
18
+ export function withScope(label: string | undefined, build: () => () => void): { lexical: Scope } {
21
19
  const parentScope = CURRENT_SCOPE.current
22
20
  const lexical = createScope({}, parentScope, true, label)
23
21
  enterRenderPass()
24
22
  CURRENT_SCOPE.current = lexical
25
- let stop: () => void = () => undefined
26
23
  try {
27
- stop = build()
24
+ lexical.own(build())
28
25
  } finally {
29
26
  exitRenderPass()
30
27
  CURRENT_SCOPE.current = parentScope
31
28
  }
32
- return { stop, lexical }
29
+ return { lexical }
33
30
  }
@@ -10,9 +10,11 @@ dispose — and may be async (its teardown then runs once the promise settles, b
10
10
  only the reads before its first `await` are tracked). Returns a dispose that runs
11
11
  the final teardown and detaches it from the graph. This is abide's from-scratch
12
12
  effect primitive: the open-on-first-read / close-on-last-reader lifecycle,
13
- grounded in abide's own reactive core.
13
+ grounded in abide's own reactive core. Authored as `scope().effect(...)` (the bare
14
+ name is reserved); this module export stays for the generated binding code that
15
+ calls it directly (and for the SSR strip), so it carries no standalone demo.
14
16
  */
15
- // @documentation effect
17
+ // @documentation plumbing
16
18
  export function effect(fn: () => EffectResult): () => void {
17
19
  /* Re-runs fire after the build, so pin the scope this effect was created under
18
20
  (the dep-tracking observer is managed separately, so this can't disturb it). */
@@ -1,3 +1,6 @@
1
+ import { parseRouteSegments } from '../shared/parseRouteSegments.ts'
2
+ import type { PageRoutes, PathParams } from '../shared/url.ts'
3
+ import { url } from '../shared/url.ts'
1
4
  import { historyEntries } from './runtime/historyEntries.ts'
2
5
  import { runtimePath } from './runtime/runtimePath.ts'
3
6
 
@@ -8,21 +11,19 @@ export type NavigateOptions = {
8
11
  keepScroll?: boolean
9
12
  }
10
13
 
11
- /* Navigates to `path`: writes a history entry (when available) and updates the
12
- reactive route, which re-mounts the matching page via `router`. `replace` swaps
13
- the current entry instead of pushing used when honouring a server redirect, so
14
- the blocked URL isn't left behind in history. Each entry carries a monotonic
15
- `abideEntry` id so the router can bucket/restore its scroll offset across the page
16
- teardown the rebuild does. A push leaves the current entry behind its scroll is
17
- bucketed so back restores it and mints a fresh id. A replace destroys the current
18
- entry and lands fresh content (a redirect), so its saved scroll no longer applies:
14
+ /* Writes an ALREADY-RESOLVED path into history + the reactive route. The router uses this
15
+ for paths it built from a real URL (a clicked link, a popstate, a server redirect) — they
16
+ already carry the mount base, so they must NOT pass through `url()` again. Each entry
17
+ carries a monotonic `abideEntry` id so the router can bucket/restore its scroll offset
18
+ across the page teardown the rebuild does. A push leaves the current entry behind — its
19
+ scroll is bucketed so back restores it and mints a fresh id. A replace destroys the
20
+ current entry and lands fresh content (a redirect), so its saved scroll no longer applies:
19
21
  the bucket is discarded and the id kept, so the new page restores to top/anchor.
20
22
  `keepScroll` opts the destination out of that reset — for an in-page URL swap (e.g.
21
- selecting another episode on the same detail page) where a top jump is jarring: the
22
- live offset is bucketed under the destination entry id, so the post-rebuild `restore`
23
- reapplies it instead of scrolling to top. */
24
- // @documentation navigate
25
- export function navigate(
23
+ selecting another episode on the same detail page) where a top jump is jarring: the live
24
+ offset is bucketed under the destination entry id, so the post-rebuild `restore` reapplies
25
+ it instead of scrolling to top. */
26
+ export function navigatePath(
26
27
  path: string,
27
28
  { replace = false, keepScroll = false }: NavigateOptions = {},
28
29
  ): void {
@@ -48,3 +49,29 @@ export function navigate(
48
49
  }
49
50
  runtimePath.value = path
50
51
  }
52
+
53
+ /* Navigates to a typed in-app path: a route literal with `[name]` segments takes its params
54
+ first (`navigate('/p/[id]', { id })`), then options; a paramless or already-interpolated
55
+ path takes options directly (`navigate('/p', { replace })`). The path is built through
56
+ `url()` — base-correct, params interpolated — exactly as a link href would be, so a
57
+ dynamic `/p/${x}` falls through url()'s paramless branch and is base-prefixed too. */
58
+ // @documentation navigate
59
+ export function navigate<P extends keyof PageRoutes | (string & {})>(
60
+ path: P,
61
+ ...rest: keyof PathParams<P> extends never
62
+ ? [options?: NavigateOptions]
63
+ : [params: PathParams<P>, options?: NavigateOptions]
64
+ ): void {
65
+ /* A path declaring `[name]` segments takes params first, options second — mirroring
66
+ url()'s own arg discrimination; otherwise the first arg is options. */
67
+ const hasParams = parseRouteSegments(path).some((segment) => segment.kind === 'param')
68
+ const [first, second] = rest as [unknown, NavigateOptions?]
69
+ /* Params ride url()'s first vararg slot — a path with `[name]` segments interpolates
70
+ them, a paramless one ignores undefined and just base-prefixes. Widen `path` to
71
+ string so url() takes its plain-path overload; its runtime keys off the segments. */
72
+ const params = hasParams
73
+ ? (first as Record<string, string | number | boolean | undefined>)
74
+ : undefined
75
+ const options = (hasParams ? second : (first as NavigateOptions | undefined)) ?? {}
76
+ navigatePath(url(path as string, params), options)
77
+ }
@@ -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
  /*