@abide/abide 0.45.0 → 0.47.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 (176) hide show
  1. package/AGENTS.md +365 -320
  2. package/CHANGELOG.md +96 -0
  3. package/README.md +203 -168
  4. package/package.json +12 -8
  5. package/src/abideLsp.ts +11 -12
  6. package/src/abideResolverPlugin.ts +9 -4
  7. package/src/lib/bundle/disconnected.abide +3 -0
  8. package/src/lib/cli/printCommandHelp.ts +43 -0
  9. package/src/lib/cli/printSessionHelp.ts +2 -1
  10. package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
  11. package/src/lib/cli/runCli.ts +2 -1
  12. package/src/lib/mcp/buildPrompts.ts +25 -0
  13. package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
  14. package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
  15. package/src/lib/mcp/mcpSurface.ts +13 -252
  16. package/src/lib/mcp/mcpTools.ts +187 -0
  17. package/src/lib/mcp/renderPrompt.ts +25 -0
  18. package/src/lib/mcp/types/McpSurface.ts +15 -0
  19. package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
  20. package/src/lib/mcp/types/PromptMessage.ts +2 -0
  21. package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
  22. package/src/lib/mcp/types/ToolResult.ts +2 -0
  23. package/src/lib/server/agent.ts +1 -1
  24. package/src/lib/server/jsonl.ts +2 -2
  25. package/src/lib/server/rpc/defineRpc.ts +7 -1
  26. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  27. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  28. package/src/lib/server/runtime/createServer.ts +101 -71
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  30. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  31. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  32. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  34. package/src/lib/server/socket.ts +1 -1
  35. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  36. package/src/lib/server/sockets/defineSocket.ts +26 -7
  37. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  38. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  39. package/src/lib/server/sse.ts +2 -2
  40. package/src/lib/shared/DEFER.ts +8 -0
  41. package/src/lib/shared/activeCacheStore.ts +2 -2
  42. package/src/lib/shared/activePage.ts +2 -2
  43. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  44. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  45. package/src/lib/shared/basePath.ts +2 -2
  46. package/src/lib/shared/baseSlot.ts +6 -5
  47. package/src/lib/shared/bodyValueForKind.ts +1 -2
  48. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  49. package/src/lib/shared/cache.ts +477 -110
  50. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  51. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  52. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  53. package/src/lib/shared/createRemoteFunction.ts +58 -9
  54. package/src/lib/shared/createResolverSlot.ts +24 -31
  55. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  56. package/src/lib/shared/decodeRefJson.ts +4 -2
  57. package/src/lib/shared/decodeResponse.ts +8 -6
  58. package/src/lib/shared/done.ts +14 -0
  59. package/src/lib/shared/encodeRefJson.ts +4 -2
  60. package/src/lib/shared/globalCacheStoreSlot.ts +9 -6
  61. package/src/lib/shared/hydratingSlot.ts +12 -0
  62. package/src/lib/shared/isLayoutFile.ts +12 -0
  63. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  64. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  65. package/src/lib/shared/matchRoute.ts +175 -0
  66. package/src/lib/shared/normalizePathname.ts +11 -0
  67. package/src/lib/shared/pageSlot.ts +14 -5
  68. package/src/lib/shared/pageUrlForFile.ts +3 -3
  69. package/src/lib/shared/parseRouteSegments.ts +16 -7
  70. package/src/lib/shared/patch.ts +41 -0
  71. package/src/lib/shared/peek.ts +35 -0
  72. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  73. package/src/lib/shared/prepareRpcModule.ts +98 -16
  74. package/src/lib/shared/prepareSocketModule.ts +11 -15
  75. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  76. package/src/lib/shared/reachable.ts +102 -0
  77. package/src/lib/shared/refresh.ts +22 -0
  78. package/src/lib/shared/requestScopeSlot.ts +10 -7
  79. package/src/lib/shared/routeParamsShape.ts +9 -9
  80. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  81. package/src/lib/shared/selectorPrefix.ts +3 -10
  82. package/src/lib/shared/setOwnProperty.ts +19 -0
  83. package/src/lib/shared/snippet.ts +1 -1
  84. package/src/lib/shared/subscribableProbes.ts +111 -0
  85. package/src/lib/shared/tailProbeSlot.ts +8 -1
  86. package/src/lib/shared/types/CacheEntry.ts +9 -15
  87. package/src/lib/shared/types/CacheOptions.ts +8 -0
  88. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  89. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  90. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  91. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  92. package/src/lib/shared/types/RpcError.ts +26 -0
  93. package/src/lib/shared/types/SmartReadOptions.ts +32 -0
  94. package/src/lib/shared/types/Socket.ts +54 -0
  95. package/src/lib/shared/types/SsrBootState.ts +27 -0
  96. package/src/lib/shared/types/SsrPayload.ts +21 -0
  97. package/src/lib/shared/types/Subscribable.ts +13 -13
  98. package/src/lib/shared/types/TailHooks.ts +2 -1
  99. package/src/lib/shared/url.ts +29 -14
  100. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  101. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  102. package/src/lib/test/createScriptedSurface.ts +1 -1
  103. package/src/lib/test/createTestApp.ts +2 -2
  104. package/src/lib/test/createTestSocketChannel.ts +3 -3
  105. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  106. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  107. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  108. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  109. package/src/lib/ui/compile/compileModule.ts +137 -11
  110. package/src/lib/ui/compile/compileShadow.ts +101 -84
  111. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  112. package/src/lib/ui/compile/generateBuild.ts +47 -11
  113. package/src/lib/ui/compile/generateSSR.ts +117 -14
  114. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  115. package/src/lib/ui/compile/lowerContext.ts +10 -4
  116. package/src/lib/ui/compile/lowerScript.ts +72 -5
  117. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  118. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  119. package/src/lib/ui/compile/signalCallee.ts +24 -0
  120. package/src/lib/ui/compile/stripEffects.ts +14 -11
  121. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  122. package/src/lib/ui/dom/appendStatic.ts +9 -1
  123. package/src/lib/ui/dom/appendText.ts +15 -3
  124. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  125. package/src/lib/ui/dom/awaitBlock.ts +17 -56
  126. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  127. package/src/lib/ui/dom/each.ts +12 -1
  128. package/src/lib/ui/dom/hydrate.ts +10 -0
  129. package/src/lib/ui/effect.ts +4 -3
  130. package/src/lib/ui/installHotBridge.ts +4 -2
  131. package/src/lib/ui/remoteProxy.ts +18 -2
  132. package/src/lib/ui/renderToStream.ts +9 -13
  133. package/src/lib/ui/resumeSeedScript.ts +2 -2
  134. package/src/lib/ui/router.ts +21 -2
  135. package/src/lib/ui/runtime/RESUME.ts +0 -6
  136. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  137. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  138. package/src/lib/ui/scope.ts +6 -3
  139. package/src/lib/ui/seedBootState.ts +53 -0
  140. package/src/lib/ui/socketChannel.ts +2 -2
  141. package/src/lib/ui/socketProxy.ts +9 -3
  142. package/src/lib/ui/startClient.ts +16 -30
  143. package/src/lib/ui/state.ts +44 -13
  144. package/src/lib/ui/sync.ts +11 -5
  145. package/src/lib/ui/tryEncodeResume.ts +2 -5
  146. package/src/lib/ui/types/Scope.ts +14 -10
  147. package/src/lib/ui/watch.ts +140 -0
  148. package/src/serverEntry.ts +6 -6
  149. package/template/CLAUDE.md +1 -1
  150. package/template/package.json +1 -1
  151. package/template/src/server/rpc/getHello.ts +3 -3
  152. package/template/src/ui/pages/page.abide +2 -2
  153. package/template/test/app.test.ts +1 -1
  154. package/src/lib/server/reachable.ts +0 -45
  155. package/src/lib/server/sockets/types/Socket.ts +0 -23
  156. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  157. package/src/lib/shared/baseResolver.ts +0 -10
  158. package/src/lib/shared/cacheKeyOf.ts +0 -7
  159. package/src/lib/shared/cacheKeyStore.ts +0 -8
  160. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  161. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  162. package/src/lib/shared/pageResolver.ts +0 -16
  163. package/src/lib/shared/recordCacheKey.ts +0 -6
  164. package/src/lib/shared/requestScopeResolver.ts +0 -12
  165. package/src/lib/shared/setBaseResolver.ts +0 -4
  166. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  167. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  168. package/src/lib/shared/setPageResolver.ts +0 -4
  169. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  170. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  171. package/src/lib/shared/types/TailOptions.ts +0 -10
  172. package/src/lib/ui/deferResume.ts +0 -29
  173. package/src/lib/ui/matchRoute.ts +0 -106
  174. package/src/lib/ui/tail.ts +0 -324
  175. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  176. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -1,12 +0,0 @@
1
- import { createCacheStore } from './createCacheStore.ts'
2
- import { createResolverSlot } from './createResolverSlot.ts'
3
- import type { CacheStore } from './types/CacheStore.ts'
4
-
5
- /*
6
- The active-CacheStore slot/resolver/reader bundle. The server entry installs an
7
- ALS-backed resolver (request-scoped); the client entry a module-singleton one.
8
- With no resolver registered, a single fallback store is created lazily so
9
- isolated tests work without booting the runtime. cacheStoreSlot / activeCacheStore
10
- re-export the slot and reader; setCacheStoreResolver the setter.
11
- */
12
- export const cacheStoreResolver = createResolverSlot<CacheStore>(createCacheStore)
@@ -1,12 +0,0 @@
1
- import { createResolverSlot } from './createResolverSlot.ts'
2
- import type { CacheStore } from './types/CacheStore.ts'
3
-
4
- /*
5
- Slot + setter for the process-level cache store resolver used by cache() entries
6
- opting into `global: true`. The server entry registers a module-singleton store
7
- outliving any one request; the client entry points it at its single tab store so
8
- `global` is a no-op there. No fallback creator — when unset, globalCacheStore()
9
- falls through to the active store rather than minting an isolated one.
10
- globalCacheStoreSlot / setGlobalCacheStoreResolver re-export the slot and setter.
11
- */
12
- export const globalCacheStoreResolver = createResolverSlot<CacheStore>()
@@ -1,16 +0,0 @@
1
- import { createResolverSlot } from './createResolverSlot.ts'
2
- import type { PageSnapshot } from './types/PageSnapshot.ts'
3
-
4
- /*
5
- The active-page slot/resolver/reader bundle. The server entry installs an
6
- ALS-backed resolver (request-scoped, so concurrent and streaming renders never
7
- share state); the client entry a module-singleton one. With no resolver
8
- registered, a single empty snapshot is created lazily so isolated tests work.
9
- pageSlot / activePage re-export the slot and reader; setPageResolver the setter.
10
- */
11
- export const pageResolver = createResolverSlot<PageSnapshot>(() => ({
12
- route: '',
13
- params: {},
14
- url: new URL('http://localhost/'),
15
- navigating: false,
16
- }))
@@ -1,6 +0,0 @@
1
- import { cacheKeyStore } from './cacheKeyStore.ts'
2
-
3
- /* Tags a cache() read's returned promise with its store key (see cacheKeyStore). */
4
- export function recordCacheKey(promise: Promise<unknown>, key: string): void {
5
- cacheKeyStore.set(promise, key)
6
- }
@@ -1,12 +0,0 @@
1
- import { createResolverSlot } from './createResolverSlot.ts'
2
- import type { RequestScopeInfo } from './types/RequestScopeInfo.ts'
3
-
4
- /*
5
- Slot + setter for the request-scope resolver. The server installs an ALS-backed
6
- resolver (createServer, reading the RequestStore); the client a module-singleton
7
- seeded from __SSR__ (startClient). No fallback creator — an unset resolver (or one
8
- returning undefined outside any request) means "no scope": callers read
9
- `.resolver?.()` and treat undefined as absent. requestScopeSlot /
10
- setRequestScopeResolver re-export the slot and setter.
11
- */
12
- export const requestScopeResolver = createResolverSlot<RequestScopeInfo>()
@@ -1,4 +0,0 @@
1
- import { baseResolver } from './baseResolver.ts'
2
-
3
- // Registers the runtime's mount-base resolver. Called once per side at boot.
4
- export const setBaseResolver = baseResolver.set
@@ -1,4 +0,0 @@
1
- import { cacheStoreResolver } from './cacheStoreResolver.ts'
2
-
3
- // Registers the runtime's active-CacheStore resolver. Called once per side at boot.
4
- export const setCacheStoreResolver = cacheStoreResolver.set
@@ -1,4 +0,0 @@
1
- import { globalCacheStoreResolver } from './globalCacheStoreResolver.ts'
2
-
3
- // Registers the process-level cache store resolver. Called once per side at boot.
4
- export const setGlobalCacheStoreResolver = globalCacheStoreResolver.set
@@ -1,4 +0,0 @@
1
- import { pageResolver } from './pageResolver.ts'
2
-
3
- // Registers the runtime's page resolver. Called once per side at boot.
4
- export const setPageResolver = pageResolver.set
@@ -1,4 +0,0 @@
1
- import { requestScopeResolver } from './requestScopeResolver.ts'
2
-
3
- // Registers the runtime's request-scope resolver. Called once per side at boot.
4
- export const setRequestScopeResolver = requestScopeResolver.set
@@ -1,28 +0,0 @@
1
- import { parseRouteSegments } from './parseRouteSegments.ts'
2
-
3
- /*
4
- Translates a abide route URL (`/media/[id]/[...rest]`) into the pattern Bun
5
- needs (`/media/:id/*`) for `Bun.serve({ routes })`. Returns the catch-all
6
- segment's original name alongside so the server can rename Bun's `*` param
7
- back to that name on the way out, keeping page-prop destructuring consistent
8
- with the route file path.
9
- */
10
- export function toBunRoutePattern(routeUrl: string): {
11
- pattern: string
12
- catchAllName: string | undefined
13
- } {
14
- let catchAllName: string | undefined
15
- const pattern = parseRouteSegments(routeUrl)
16
- .map((segment) => {
17
- if (segment.kind === 'literal') {
18
- return segment.value
19
- }
20
- if (segment.catchAll) {
21
- catchAllName = segment.name
22
- return '*'
23
- }
24
- return `:${segment.name}`
25
- })
26
- .join('/')
27
- return { pattern, catchAllName }
28
- }
@@ -1,10 +0,0 @@
1
- /*
2
- Window form of `tail()`: `last` is the rolling window size — the reader holds
3
- the last ≤`last` frames, however they arrived. A source that retains a tail
4
- (a socket declared with `{ tail: n }`) seeds the window by replaying up to
5
- `last` retained frames; a source with no retention (an rpc stream, an
6
- undeclared socket) fills it from live frames only. Integer ≥ 1.
7
- */
8
- export interface TailOptions {
9
- readonly last: number
10
- }
@@ -1,29 +0,0 @@
1
- import { activeCacheStore } from '../shared/activeCacheStore.ts'
2
- import { cacheKeyOf } from '../shared/cacheKeyOf.ts'
3
- import { snapshotShippable } from '../shared/snapshotShippable.ts'
4
- import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
5
-
6
- /*
7
- The resume-manifest entry for a blocking `{#await expr then value}`. When the awaited value
8
- is a shippable cache-backed read, DEFER it: flag the store entry so its SSR snapshot seeds
9
- lazily (the body is decoded on first read, not at hydration) and return a `{ defer, key }`
10
- marker in place of the value. Hydration then adopts the server branch inert and decodes
11
- neither copy — the blocking form's contract: "render it on the server, keep the page, refetch
12
- only when I read or invalidate it." A non-cache value (a plain promise, a computation) or a
13
- cache read whose entry can't ship inlines its value as before. Server-only in practice — the
14
- client codegen never calls it. */
15
- // @documentation plumbing
16
- export function deferResume(promise: unknown, value: unknown): ResumeEntry | DeferMarker {
17
- const isPromise =
18
- promise !== null && typeof (promise as { then?: unknown })?.then === 'function'
19
- const key = isPromise ? cacheKeyOf(promise as Promise<unknown>) : undefined
20
- if (key === undefined) {
21
- return { ok: true, value }
22
- }
23
- const entry = activeCacheStore().entries.get(key)
24
- if (entry === undefined || !snapshotShippable(entry)) {
25
- return { ok: true, value }
26
- }
27
- entry.deferred = true
28
- return { defer: true, key }
29
- }
@@ -1,106 +0,0 @@
1
- import type { RouteSegment } from '../shared/parseRouteSegments.ts'
2
- import { parseRouteSegments } from '../shared/parseRouteSegments.ts'
3
-
4
- /*
5
- Per-route parse cache. The route set is stable across a session, so parsing a
6
- pattern into segments (and counting its literals, the specificity tie-breaker) is
7
- done once per route rather than twice per route on every navigation. Keyed by the
8
- pattern string; entries never need eviction since routes don't churn.
9
- */
10
- const PARSED_ROUTES = new Map<string, { segments: RouteSegment[]; literals: number }>()
11
-
12
- function parsedRoute(route: string): { segments: RouteSegment[]; literals: number } {
13
- let parsed = PARSED_ROUTES.get(route)
14
- if (parsed === undefined) {
15
- const segments = parseRouteSegments(route)
16
- const literals = segments.filter((segment) => segment.kind === 'literal').length
17
- parsed = { segments, literals }
18
- PARSED_ROUTES.set(route, parsed)
19
- }
20
- return parsed
21
- }
22
-
23
- /*
24
- Client route matcher: given the registered route patterns and the current
25
- pathname, returns the matching route + decoded params, or undefined. Mirrors the
26
- server's segment grammar via the shared `parseRouteSegments` (literal / `[name]` /
27
- `[...rest]`), so client navigation decodes params the same way SSR does. The most
28
- specific match wins — the pattern with the most literal segments — so a static
29
- route beats a param route at the same depth.
30
- */
31
- export function matchRoute(
32
- routes: string[],
33
- pathname: string,
34
- ): { route: string; params: Record<string, string> } | undefined {
35
- /* Normalize a trailing slash (except root) so `/users/` matches `/users` —
36
- otherwise the extra empty segment makes the length guard reject it (a 404 the
37
- server's own routing wouldn't give) or lets `/users/[id]` capture an empty id. */
38
- const normalized =
39
- pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
40
- const pathSegments = normalized.split('/')
41
- let best: { route: string; params: Record<string, string>; literals: number } | undefined
42
- for (const route of routes) {
43
- const parsed = parsedRoute(route)
44
- const params = matchSegments(parsed.segments, pathSegments)
45
- if (params === undefined) {
46
- continue
47
- }
48
- if (best === undefined || parsed.literals > best.literals) {
49
- best = { route, params, literals: parsed.literals }
50
- }
51
- }
52
- return best === undefined ? undefined : { route: best.route, params: best.params }
53
- }
54
-
55
- /* Percent-decodes a captured `[name]` value. Bun's `req.params` decoding is
56
- lenient (malformed sequences pass through), so mirror that by falling back to
57
- the raw value rather than throwing on a malformed `%` a page navigation would
58
- otherwise crash on. */
59
- function decodeParam(value: string): string {
60
- try {
61
- return decodeURIComponent(value)
62
- } catch {
63
- return value
64
- }
65
- }
66
-
67
- /* Matches one parsed pattern against the path's segments, capturing params;
68
- undefined on mismatch. A catch-all consumes every remaining segment. */
69
- function matchSegments(
70
- segments: RouteSegment[],
71
- pathSegments: string[],
72
- ): Record<string, string> | undefined {
73
- const params: Record<string, string> = {}
74
- for (let index = 0; index < segments.length; index += 1) {
75
- const segment = segments[index]
76
- if (segment === undefined) {
77
- return undefined
78
- }
79
- if (segment.kind === 'param' && segment.catchAll) {
80
- params[segment.name] = pathSegments.slice(index).join('/')
81
- return params
82
- }
83
- const value = pathSegments[index]
84
- if (value === undefined) {
85
- return undefined
86
- }
87
- if (segment.kind === 'literal') {
88
- if (segment.value !== value) {
89
- return undefined
90
- }
91
- } else {
92
- /* A `[name]` param never captures an empty segment (e.g. `/users//5`). */
93
- if (value === '') {
94
- return undefined
95
- }
96
- /* `url()` encodes a `[name]` value whole, and Bun decodes `req.params`
97
- server-side, so decode here to hand the page the same value SSR does
98
- (e.g. `The%20Daily%20Show` → `The Daily Show`). The catch-all above
99
- stays raw to match the server, which reconstructs it from the raw
100
- pathname. */
101
- params[segment.name] = decodeParam(value)
102
- }
103
- }
104
- /* No catch-all consumed the tail, so the path must have no extra segments. */
105
- return pathSegments.length === segments.length ? params : undefined
106
- }
@@ -1,324 +0,0 @@
1
- import { createLifecycleChannel } from '../shared/createLifecycleChannel.ts'
2
- import { createSubscriber } from '../shared/createSubscriber.ts'
3
- import { SocketDisconnectedError } from '../shared/SocketDisconnectedError.ts'
4
- import { tailProbeSlot } from '../shared/tailProbeSlot.ts'
5
- import type { Subscribable } from '../shared/types/Subscribable.ts'
6
- import type { TailOptions } from '../shared/types/TailOptions.ts'
7
-
8
- type TailStatus = 'pending' | 'open' | 'done' | 'error'
9
-
10
- /*
11
- One entry shape for both read forms: the bare latest-wins read is a window of
12
- 1 projected to its single frame, so the frame loop, reconnect handling, and
13
- probes have exactly one path. The bare form keys by name alone (so `tail(x)`
14
- and `tail(x, { last: 1 })` stay independent subscriptions); probes match on
15
- `source`.
16
- */
17
- type Entry<T> = {
18
- source: string
19
- key: string
20
- /* window cap; 1 for the bare latest-wins form */
21
- last: number
22
- window: T[]
23
- error: Error | undefined
24
- status: TailStatus
25
- refreshing: boolean
26
- /* true while the createSubscriber connect callback holds the iterator open */
27
- live: boolean
28
- tap: () => void
29
- }
30
-
31
- const registry = new Map<string, Entry<unknown>>()
32
-
33
- /*
34
- Registry-wide lifecycle channel for the pending()/refreshing() probes — the
35
- tail-side counterpart of the cache store's. Probes match entries by source
36
- name (or all) without creating them, so they tap one "membership or state
37
- changed" signal instead of opening a stream the way a tail() read would.
38
- */
39
- const lifecycle = createLifecycleChannel()
40
-
41
- /*
42
- Prober for the shared pending()/refreshing() probes. Probes report, never
43
- act: an absent entry is read as "no value yet" (pending) without opening a
44
- stream. A name spans every entry on that source — latest-wins and window
45
- forms alike; the bare form spans every registered stream.
46
- */
47
- tailProbeSlot.probe = (name) => {
48
- lifecycle.track()
49
- const entries = [...registry.values()].filter(
50
- (entry) => name === undefined || entry.source === name,
51
- )
52
- return {
53
- pending:
54
- (name !== undefined && entries.length === 0) ||
55
- entries.some((entry) => entry.status === 'pending'),
56
- refreshing: entries.some((entry) => entry.refreshing),
57
- }
58
- }
59
-
60
- /*
61
- Reactive consumer for streaming sources. Takes a Subscribable<T> — the shape
62
- both `Socket<T>` (declared under src/server/sockets/) and the result of
63
- `fn.stream(args)` satisfy:
64
-
65
- const latest = $derived(tail(chat)) // socket, latest frame
66
- const recent = $derived(tail(chat, { last: 20 })) // live window of the last ≤20
67
- const tick = $derived(tail(tickFeed.stream())) // rpc stream
68
-
69
- The bare form is latest-wins: T | undefined, pending until the first frame.
70
- The window form returns T[] — `[]` while pending, never undefined — holding
71
- the last ≤`last` frames however they arrived. Seeding rides the source's
72
- optional retention capability (`Subscribable.tail(count)`): a socket declared
73
- `{ tail: n }` replays up to `last` retained frames (1 for the bare form — a
74
- latest-wins reader only needs the newest); a source without the capability
75
- (rpc stream, undeclared socket) starts live-only. tail() never requires
76
- retention — the window semantics are identical either way, only how much
77
- past it can show differs.
78
-
79
- Lifecycle mirrors cache(): the entry's tracker is a createSubscriber,
80
- so the first derived read in a tracking scope opens the underlying iterator
81
- and the last derived to stop reading closes it. Many deriveds reading the
82
- same source share one underlying subscription — the registry dedupes by
83
- `subscribable.name`, with `last` folded into the key, so the bare form and
84
- each window size are independent subscriptions. Passing fresh `fn.stream(args)`
85
- Subscribables across re-renders is safe: same args → same key → shared
86
- subscription. tail.error / tail.status take the same options to address the
87
- same entry.
88
-
89
- Reconnect-with-retained-value: a transport loss (the typed
90
- SocketDisconnectedError, raised only by the ws channel — an rpc stream is a
91
- one-shot Response and never disconnects) does not surface as an error. The
92
- entry keeps its window, flags `refreshing` (the probe contract: value held,
93
- fresher source in flight — never merely `open`), and re-invokes the source;
94
- the channel's backoff owns the retry. The reopened source's replay commits
95
- over the window atomically at the `replayed` boundary (TailHooks) — appended
96
- it would duplicate, rebuilt frame-by-frame it would flash — and an empty
97
- replay keeps the held window, with live frames appending after. For the
98
- bare form the commit is exactly latest-wins convergence. Initial opens seed
99
- the same way, so a window's first paint is the full seed, not a staircase.
100
-
101
- tail is a no-op on the server (returns undefined / []) — SSR can't keep a
102
- stream open across the request boundary. Pages that want a seeded value in
103
- the initial HTML should fetch via cache() against an HTTP rpc handler and
104
- layer tail() on top for live updates after hydration.
105
-
106
- Errors are surfaced through tail.error(x) rather than thrown, so reading
107
- from a $derived can't crash the component. Status distinguishes "haven't
108
- received the first frame" (pending) from "stream ended cleanly" (done) and
109
- "wire layer surfaced an error" (error).
110
- */
111
- // @documentation tail
112
- export function tail<T>(subscribable: Subscribable<T>): T | undefined
113
- export function tail<T>(subscribable: Subscribable<T>, options: TailOptions): T[]
114
- export function tail<T>(subscribable: Subscribable<T>, options?: TailOptions): T | T[] | undefined {
115
- if (options) {
116
- return readField(subscribable, options, 'window') ?? []
117
- }
118
- return readField(subscribable, undefined, 'window')?.[0]
119
- }
120
-
121
- tail.error = function tailError<T>(
122
- subscribable: Subscribable<T>,
123
- options?: TailOptions,
124
- ): Error | undefined {
125
- return readField(subscribable, options, 'error')
126
- }
127
-
128
- tail.status = function tailStatus<T>(
129
- subscribable: Subscribable<T>,
130
- options?: TailOptions,
131
- ): TailStatus {
132
- return readField(subscribable, options, 'status') ?? 'pending'
133
- }
134
-
135
- function readField<T, K extends keyof Entry<T>>(
136
- subscribable: Subscribable<T>,
137
- options: TailOptions | undefined,
138
- field: K,
139
- ): Entry<T>[K] | undefined {
140
- if (options && (!Number.isInteger(options.last) || options.last < 1)) {
141
- throw new RangeError(`[abide] tail() \`last\` must be an integer ≥ 1, got ${options.last}`)
142
- }
143
- if (typeof window === 'undefined') {
144
- return undefined
145
- }
146
- const entry = getOrCreateEntry(subscribable, options)
147
- entry.tap()
148
- const value = entry[field]
149
- /*
150
- Untracked read (outside $derived/$effect): tap() never connects, so no
151
- teardown will ever evict the entry — drop it now or it sits in the
152
- registry as a permanently-pending zombie the bare probes keep seeing.
153
- */
154
- if (!entry.live) {
155
- evictIfCurrent(entry as Entry<unknown>)
156
- }
157
- return value
158
- }
159
-
160
- /* Delete only if this entry still owns its key — a fresh Subscribable with the
161
- same name may have replaced it, and a stale cleanup must not nuke the new entry. */
162
- function evictIfCurrent(entry: Entry<unknown>): boolean {
163
- if (registry.get(entry.key) === entry) {
164
- registry.delete(entry.key)
165
- return true
166
- }
167
- return false
168
- }
169
-
170
- function getOrCreateEntry<T>(
171
- subscribable: Subscribable<T>,
172
- options: TailOptions | undefined,
173
- ): Entry<T> {
174
- const key = options ? `${subscribable.name}#${options.last}` : subscribable.name
175
- const cached = registry.get(key)
176
- if (cached) {
177
- return cached as Entry<T>
178
- }
179
- const last = options?.last ?? 1
180
- const entry: Entry<T> = {
181
- source: subscribable.name,
182
- key,
183
- last,
184
- window: [],
185
- error: undefined,
186
- status: 'pending',
187
- refreshing: false,
188
- live: false,
189
- tap: () => undefined,
190
- }
191
- entry.tap = createSubscriber((update) => {
192
- entry.window = []
193
- entry.error = undefined
194
- entry.status = 'pending'
195
- entry.refreshing = false
196
- entry.live = true
197
- let cancelled = false
198
- const notify = () => {
199
- update()
200
- lifecycle.mark()
201
- }
202
- /*
203
- Seed-and-commit: frames from a retaining source accumulate silently
204
- until its in-band `replayed` boundary, then commit to the window in
205
- one update — readers never see the window rebuild frame-by-frame.
206
- An empty seed keeps the held window across a gap: nothing was
207
- replayed, so nothing can duplicate, and the live frames that follow
208
- append. Sources without the capability have no replay; their frames
209
- append directly.
210
- */
211
- let seeding = false
212
- let seed: T[] = []
213
- const commit = () => {
214
- if (cancelled || !seeding) {
215
- return
216
- }
217
- seeding = false
218
- if (seed.length > 0) {
219
- entry.window = seed
220
- seed = []
221
- }
222
- if (entry.window.length > 0) {
223
- entry.status = 'open'
224
- }
225
- entry.refreshing = false
226
- notify()
227
- }
228
- /*
229
- A retaining source bounds replay to what the reader keeps: 1 frame
230
- seeds the bare form, `last` seeds a window.
231
- */
232
- const open = () => {
233
- if (!subscribable.tail) {
234
- return subscribable[Symbol.asyncIterator]()
235
- }
236
- seeding = true
237
- seed = []
238
- return subscribable.tail(last, { replayed: commit })[Symbol.asyncIterator]()
239
- }
240
- /* `let`: the reconnect path swaps in a fresh iterator; teardown closes the current one. */
241
- let iterator = open()
242
- ;(async () => {
243
- while (!cancelled) {
244
- try {
245
- const next = await iterator.next()
246
- if (cancelled) {
247
- return
248
- }
249
- if (next.done) {
250
- /* a retaining source that ends without signalling still commits its seed */
251
- commit()
252
- if (entry.status !== 'error') {
253
- entry.status = 'done'
254
- }
255
- entry.refreshing = false
256
- notify()
257
- return
258
- }
259
- if (seeding) {
260
- seed.push(next.value)
261
- if (seed.length > last) {
262
- seed.shift()
263
- }
264
- continue
265
- }
266
- /*
267
- Probes read only status/refreshing, so the registry
268
- channel pings on transitions, not per frame — a chatty
269
- stream must not re-derive every bare pending()/refreshing()
270
- reader on each value. (A frame after a reconnect is the
271
- seed landing — current again, hence a transition.)
272
- */
273
- const transitioned = entry.status !== 'open' || entry.refreshing
274
- /* One copy per frame: take the surviving suffix, then append. */
275
- const base = entry.window
276
- const frames =
277
- base.length < last ? base.slice() : base.slice(base.length - last + 1)
278
- frames.push(next.value)
279
- entry.window = frames
280
- entry.status = 'open'
281
- entry.refreshing = false
282
- update()
283
- if (transitioned) {
284
- lifecycle.mark()
285
- }
286
- } catch (error) {
287
- if (cancelled) {
288
- return
289
- }
290
- if (error instanceof SocketDisconnectedError) {
291
- /*
292
- Recoverable transport loss: retain the window, flag the
293
- gap, reopen — open() re-arms seeding so the reopened
294
- source's replay commits atomically over the held
295
- window. The fresh sub frame queues on the channel and
296
- flushes when its backoff attempt reconnects, so this
297
- loop just awaits the next push — no spin.
298
- */
299
- entry.refreshing = entry.window.length > 0
300
- notify()
301
- iterator = open()
302
- continue
303
- }
304
- entry.error = error instanceof Error ? error : new Error(String(error))
305
- entry.status = 'error'
306
- entry.refreshing = false
307
- notify()
308
- return
309
- }
310
- }
311
- })()
312
- lifecycle.mark()
313
- return () => {
314
- cancelled = true
315
- entry.live = false
316
- iterator.return?.(undefined)?.catch(() => undefined)
317
- if (evictIfCurrent(entry as Entry<unknown>)) {
318
- lifecycle.mark()
319
- }
320
- }
321
- })
322
- registry.set(key, entry as Entry<unknown>)
323
- return entry
324
- }