@abide/abide 0.42.0 → 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/AGENTS.md +234 -300
  2. package/CHANGELOG.md +41 -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 +89 -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 +45 -15
  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
@@ -2,12 +2,14 @@ import ts from 'typescript'
2
2
  import { TS_PRINTER } from './TS_PRINTER.ts'
3
3
 
4
4
  /*
5
- Removes `effect(...)` calls from a script for the SSR back-end. Effects are client
5
+ Removes effect calls from a script for the SSR back-end. Effects are client
6
6
  lifecycle — they touch the DOM / run side effects and emit no HTML, so the server
7
7
  render (a snapshot of the pre-effect markup, like every framework) must not run
8
- them. Every `effect(<args>)` call is replaced by `undefined`, dropping its body:
9
- an `effect(() => …)` statement becomes a no-op, and a `const stop = effect(…)`
10
- binding keeps a defined (unused) name. Client compilation keeps effects untouched.
8
+ them. Both surfaces are stripped: the generated/runtime bare `effect(<args>)` and
9
+ the authored scope form `scope().effect(<args>)` (and a captured/destructured handle
10
+ `c.effect(<args>)`), each replaced by `undefined` an `effect(() => …)` statement
11
+ becomes a no-op, and a `const stop = effect(…)` binding keeps a defined (unused) name.
12
+ Client compilation keeps effects untouched.
11
13
  */
12
14
  export function stripEffects(code: string): string {
13
15
  const source = ts.createSourceFile('script.ts', code, ts.ScriptTarget.Latest, true)
@@ -23,11 +25,7 @@ export function stripEffects(code: string): string {
23
25
  export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile> {
24
26
  return (context) => (root) => {
25
27
  const visit = (node: ts.Node): ts.Node => {
26
- if (
27
- ts.isCallExpression(node) &&
28
- ts.isIdentifier(node.expression) &&
29
- node.expression.text === 'effect'
30
- ) {
28
+ if (ts.isCallExpression(node) && isEffectCallee(node.expression)) {
31
29
  return ts.factory.createIdentifier('undefined')
32
30
  }
33
31
  return ts.visitEachChild(node, visit, context)
@@ -35,3 +33,13 @@ export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile>
35
33
  return ts.visitNode(root, visit) as ts.SourceFile
36
34
  }
37
35
  }
36
+
37
+ /* An effect callee: the bare runtime helper (`effect`) or the scope-method form
38
+ (`scope().effect`, `c.effect`) the author writes. Receiver-agnostic on the `.effect`
39
+ member name, matching `effect` being reserved as a scope primitive. */
40
+ function isEffectCallee(expression: ts.Expression): boolean {
41
+ if (ts.isIdentifier(expression)) {
42
+ return expression.text === 'effect'
43
+ }
44
+ return ts.isPropertyAccessExpression(expression) && expression.name.text === 'effect'
45
+ }
@@ -0,0 +1,22 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ type Case = Extract<TemplateNode, { kind: 'case' }>
4
+
5
+ /* The structural shape of a `switch`, resolved once so the build and SSR back-ends share one
6
+ reading of its cases and only own emission. Both previously recomputed the `case` filter;
7
+ SSR additionally located the match-less default. */
8
+ export type SwitchPlan = {
9
+ /* The `case` branches in source order (`match` set; the default leaves it unset). */
10
+ cases: Case[]
11
+ /* The match-less default branch, if present. */
12
+ fallback: Case | undefined
13
+ }
14
+
15
+ /* Resolves a `switch` node's cases into the shared structural plan. */
16
+ export function switchPlan(node: Extract<TemplateNode, { kind: 'switch' }>): SwitchPlan {
17
+ const cases = node.children.filter((child): child is Case => child.kind === 'case')
18
+ return {
19
+ cases,
20
+ fallback: cases.find((branch) => branch.match === undefined),
21
+ }
22
+ }
@@ -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
+ })