@abide/abide 0.50.1 → 0.52.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 (58) hide show
  1. package/AGENTS.md +486 -490
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +130 -149
  4. package/package.json +4 -1
  5. package/src/abideResolverPlugin.ts +12 -0
  6. package/src/checkAbide.ts +60 -7
  7. package/src/lib/cli/completeCli.ts +38 -0
  8. package/src/lib/cli/printTopLevelHelp.ts +1 -0
  9. package/src/lib/cli/renderCliCompletions.ts +56 -0
  10. package/src/lib/cli/runCli.ts +27 -0
  11. package/src/lib/server/render.ts +70 -0
  12. package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
  13. package/src/lib/server/runtime/createServer.ts +43 -0
  14. package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
  15. package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
  16. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  17. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  18. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  19. package/src/lib/server/sockets/registerSocket.ts +12 -0
  20. package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
  21. package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
  22. package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
  23. package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
  24. package/src/lib/shared/cache.ts +32 -4
  25. package/src/lib/shared/cacheStalenessSlot.ts +21 -0
  26. package/src/lib/shared/createRpcServerProgram.ts +187 -0
  27. package/src/lib/shared/docSnapshotsSlot.ts +13 -0
  28. package/src/lib/shared/invalidate.ts +39 -0
  29. package/src/lib/shared/matcherFromEnvelope.ts +31 -0
  30. package/src/lib/shared/prepareRpcModule.ts +22 -3
  31. package/src/lib/shared/refresh.ts +9 -2
  32. package/src/lib/shared/selectorMatcher.ts +2 -15
  33. package/src/lib/shared/serializeSelector.ts +69 -0
  34. package/src/lib/shared/setsIntersect.ts +15 -0
  35. package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
  36. package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
  37. package/src/lib/shared/types/DocSnapshots.ts +12 -0
  38. package/src/lib/shared/types/RemoteFunction.ts +11 -8
  39. package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
  40. package/src/lib/shared/types/SsrPayload.ts +5 -0
  41. package/src/lib/test/createTestApp.ts +15 -0
  42. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
  43. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  44. package/src/lib/ui/compile/compileShadow.ts +141 -25
  45. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  46. package/src/lib/ui/compile/lowerScript.ts +23 -0
  47. package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
  48. package/src/lib/ui/compile/wrapReactionCellSources.ts +117 -0
  49. package/src/lib/ui/createScope.ts +42 -1
  50. package/src/lib/ui/dom/assertClaimedText.ts +14 -4
  51. package/src/lib/ui/dom/attr.ts +25 -2
  52. package/src/lib/ui/dom/writeCell.ts +22 -0
  53. package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
  54. package/src/lib/ui/runtime/claimExpected.ts +6 -1
  55. package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
  56. package/src/lib/ui/startClient.ts +22 -1
  57. package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
  58. package/src/serverEntry.ts +12 -0
@@ -1,3 +1,6 @@
1
+ import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
2
+ import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
3
+
1
4
  /*
2
5
  A text claim that MUST match the server's rendered text. On hydrate,
3
6
  appendStatic/appendText adopt a merged SSR text node and split it at the CLIENT
@@ -12,9 +15,16 @@ the following siblings' text AFTER it, so the value is exactly the leading slice
12
15
  mismatch means SSR and the client build disagree on the text here.
13
16
  */
14
17
  export function assertClaimedText(node: Text, value: string): void {
15
- if (!node.data.startsWith(value)) {
16
- throw new Error(
17
- `[abide] hydration desync: expected server text beginning with ${JSON.stringify(value)} here, but the server DOM had ${JSON.stringify(node.data)} — SSR markup and the client build disagree on the text at this position.`,
18
- )
18
+ if (node.data.startsWith(value)) {
19
+ return
20
+ }
21
+ /* Report on the `hydrate` channel with the render-path. With the channel on, keep hydrating
22
+ (re-split proceeds below on the client length) so one reload surfaces every text divergence;
23
+ off, the hard throw stays the default. A text miss is cursor-safe to continue past. */
24
+ if (!reportHydrationDivergence('text desync', { expected: value, server: node.data })) {
25
+ return
19
26
  }
27
+ throw new Error(
28
+ `[abide] hydration desync at ${CURRENT_PATH.current || '(root)'}: expected server text beginning with ${JSON.stringify(value)} here, but the server DOM had ${JSON.stringify(node.data)} — SSR markup and the client build disagree on the text at this position.`,
29
+ )
20
30
  }
@@ -1,4 +1,15 @@
1
1
  import { effect } from '../effect.ts'
2
+ import { RENDER } from '../runtime/RENDER.ts'
3
+ import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
4
+
5
+ /* The attribute string an element carries for `value`, or `null` when absent — the present/absent +
6
+ stringify semantics below, factored so the hydration compare and the write agree exactly. */
7
+ function attributeText(value: unknown): string | null {
8
+ if (value === false || value === null || value === undefined) {
9
+ return null
10
+ }
11
+ return value === true ? '' : String(value)
12
+ }
2
13
 
3
14
  /*
4
15
  Binds an element attribute to `read()`. A boolean true sets the bare attribute,
@@ -8,12 +19,24 @@ changed attribute touches the DOM.
8
19
  */
9
20
  // @documentation plumbing
10
21
  export function attr(element: Element, name: string, read: () => unknown): void {
22
+ /* Captured at bind time (inside the hydrating build) so the first effect run can compare the
23
+ server-rendered attribute before overwriting it — an attribute divergence is otherwise
24
+ invisible, since the effect always overwrites. */
25
+ let hydrating = RENDER.hydration !== undefined
11
26
  effect(() => {
12
27
  const value = read()
13
- if (value === false || value === null || value === undefined) {
28
+ const next = attributeText(value)
29
+ if (hydrating) {
30
+ hydrating = false
31
+ const server = element.getAttribute(name)
32
+ if (server !== next) {
33
+ reportHydrationDivergence(`attr "${name}" desync`, { server, client: next })
34
+ }
35
+ }
36
+ if (next === null) {
14
37
  element.removeAttribute(name)
15
38
  } else {
16
- element.setAttribute(name, value === true ? '' : String(value))
39
+ element.setAttribute(name, next)
17
40
  }
18
41
  })
19
42
  }
@@ -0,0 +1,22 @@
1
+ import { isAsyncCell } from '../../shared/isAsyncCell.ts'
2
+ import type { AsyncState } from '../../shared/types/AsyncState.ts'
3
+
4
+ /*
5
+ Unified write for a `linked` reference the compiler lowers to `$$writeCell(NAME, value)`
6
+ from an author assignment (`draft = x`, `draft += 1`, `draft++`). The read side is
7
+ `$$readCell` — a call, so not an assignable target — which is why an assignment to a
8
+ `linked` binding needs its own lowering. A `linked` cell is a writable `State` for a
9
+ sync seed (write `.value`) or a writable `AsyncState` for an async/stream seed (write
10
+ `.set(value)`, which latches until the next reseed); one write shape covers both, so
11
+ codegen never branches on the seed kind. Returns `value` so the lowered assignment still
12
+ evaluates to the written value in expression position (`a = b = draft = x`).
13
+ */
14
+ // @documentation plumbing
15
+ export function writeCell(cell: unknown, value: unknown): unknown {
16
+ if (isAsyncCell(cell)) {
17
+ ;(cell as AsyncState<unknown>).set(value)
18
+ } else {
19
+ ;(cell as { value: unknown }).value = value
20
+ }
21
+ return value
22
+ }
@@ -0,0 +1,11 @@
1
+ /* The doc-state warm-seed manifest: an SSR-captured reactive-document snapshot, keyed by its
2
+ scope's serialization-stable render-path id, as a ref-json-encoded STRING (decoded at the read
3
+ site in `createScope`). `startClient` drains `__SSR__.docs` into here before mount; a hydrating
4
+ scope reads its key to seed a plain `state(initial)` to the SERVER value instead of the fresh
5
+ init — a uuid/timestamp/random otherwise diverges from the SSR HTML. Backed by
6
+ `globalThis.__abideDocs` so an inline pre-bundle script and the framework share one store —
7
+ whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
8
+ const globalScope = globalThis as { __abideDocs?: Record<string, string> }
9
+ globalScope.__abideDocs ??= {}
10
+
11
+ export const DOC_SEED: Record<string, string> = globalScope.__abideDocs
@@ -1,5 +1,7 @@
1
+ import { CURRENT_PATH } from './CURRENT_PATH.ts'
1
2
  import { claimChild } from './claimChild.ts'
2
3
  import type { RENDER } from './RENDER.ts'
4
+ import { reportHydrationDivergence } from './reportHydrationDivergence.ts'
3
5
 
4
6
  /*
5
7
  A hydration claim that MUST succeed. Like `claimChild`, but throws a structural error
@@ -18,8 +20,11 @@ export function claimExpected(
18
20
  ): Node {
19
21
  const node = claimChild(hydration, parent)
20
22
  if (node === null) {
23
+ /* Name where first on the `hydrate` channel (return ignored — a missing structural node
24
+ desyncs the claim cursor, so unlike a text miss this can't safely continue) then throw. */
25
+ reportHydrationDivergence('structure desync', { expected })
21
26
  throw new Error(
22
- `[abide] hydration desync: expected ${expected} here, but the server DOM had no matching node — SSR markup and the client build disagree on structure at this position.`,
27
+ `[abide] hydration desync at ${CURRENT_PATH.current || '(root)'}: expected ${expected} here, but the server DOM had no matching node — SSR markup and the client build disagree on structure at this position.`,
23
28
  )
24
29
  }
25
30
  return node
@@ -0,0 +1,30 @@
1
+ import { log } from '../../shared/log.ts'
2
+ import type { ChannelLog } from '../../shared/types/ChannelLog.ts'
3
+ import { CURRENT_PATH } from './CURRENT_PATH.ts'
4
+
5
+ /* The `hydrate` diagnostic channel. `createChannelLog` attaches a live `enabled()` (re-read per
6
+ call, so toggling `abide-debug` in devtools takes effect without reload) that the public
7
+ `ChannelLog` type narrows away — reach it here in the one place that needs it. The channel is
8
+ silent unless DEBUG/`abide-debug` matches `hydrate`. */
9
+ const hydrateChannel = log.channel('hydrate') as ChannelLog & { enabled(): boolean }
10
+
11
+ /*
12
+ Reports an SSR↔client hydration divergence on the DEBUG-gated `hydrate` channel, tagged with the
13
+ ambient render-path of the enclosing component/branch/row (`CURRENT_PATH` is coarse — it does not
14
+ descend to the individual node/attribute, so the path locates the component, the detail names the
15
+ value). Returns whether the caller should STILL THROW: with the channel off it returns `true`, so
16
+ the caller's hard throw stays the default and a real desync fails loudly; with the channel on it
17
+ warns and returns `false`, so the caller can keep hydrating and one reload surfaces EVERY
18
+ divergence instead of aborting at the first. Structural callers ignore the return (continuing past
19
+ a missing node desyncs the claim cursor) and throw regardless — the warn just names where first.
20
+ */
21
+ export function reportHydrationDivergence(
22
+ summary: string,
23
+ detail: Record<string, unknown>,
24
+ ): boolean {
25
+ if (!hydrateChannel.enabled()) {
26
+ return true
27
+ }
28
+ hydrateChannel.warn(`${summary} at ${CURRENT_PATH.current || '(root)'}`, detail)
29
+ return false
30
+ }
@@ -1,3 +1,5 @@
1
+ import { applyCacheStalenessLocally } from '../shared/applyCacheStalenessLocally.ts'
2
+ import { cacheStalenessSlot } from '../shared/cacheStalenessSlot.ts'
1
3
  import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
2
4
  import { createCacheStore } from '../shared/createCacheStore.ts'
3
5
  import { pageSlot } from '../shared/pageSlot.ts'
@@ -8,9 +10,11 @@ import { probeNavigation } from './probeNavigation.ts'
8
10
  import { router } from './router.ts'
9
11
  import { CELL_SEED } from './runtime/CELL_SEED.ts'
10
12
  import { clientPage } from './runtime/clientPage.ts'
13
+ import { DOC_SEED } from './runtime/DOC_SEED.ts'
11
14
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
12
15
  import { seedBootState } from './seedBootState.ts'
13
16
  import { seedStreamedResolution } from './seedStreamedResolution.ts'
17
+ import { subscribeCacheStaleness } from './subscribeCacheStaleness.ts'
14
18
 
15
19
  /*
16
20
  The official abide-ui client entry. Reads the server's `window.__SSR__` payload,
@@ -49,6 +53,10 @@ export function startClient(
49
53
  cacheStoreSlot.resolver = () => store
50
54
  /* One tab store: cache(fn, { shared: true }) shares it, so shared is a no-op here. */
51
55
  sharedCacheStoreSlot.resolver = () => store
56
+ /* invalidate()/refresh() apply to THIS tab's cache (the server entry installs a
57
+ broadcaster instead — the ADR-0041 side-swap). Same function as the slot fallback so
58
+ the local-apply path can't diverge. */
59
+ cacheStalenessSlot.resolver = () => applyCacheStalenessLocally
52
60
  /* Seed both SSR cache partitions through the one streamed-resolution sink: `ssr.cache`
53
61
  (inline — reads settled at render-return, in __SSR__) and `__abideResumeCache` (pending
54
62
  {#await} reads whose `__abideResolve(...)` chunks the stream pushed during parse, before
@@ -66,6 +74,12 @@ export function startClient(
66
74
  if (ssr.cells !== undefined) {
67
75
  Object.assign(CELL_SEED, ssr.cells)
68
76
  }
77
+ /* Seed the doc-state warm partition into DOC_SEED before mount: a hydrating `createScope` reads
78
+ its render-path key to adopt the SSR doc snapshot, so a plain `state(initial)` keeps the server
79
+ value (the consume-once swap in `scope.replace`) instead of re-running a divergent init. */
80
+ if (ssr.docs !== undefined) {
81
+ Object.assign(DOC_SEED, ssr.docs)
82
+ }
69
83
  /* Keep the cache channel live past boot: replace the head's buffering collector with
70
84
  the store-connected sink so a post-boot resolution — the inline doc-stream cache
71
85
  script — seeds the store through `seedStreamedResolution` directly instead of pushing
@@ -74,5 +88,12 @@ export function startClient(
74
88
  ;(globalThis as { __abideResolve?: (resolution: StreamedResolution) => void }).__abideResolve =
75
89
  seedStreamedResolution
76
90
 
77
- return router(target, routes, layoutRoutes, probeNavigation)
91
+ /* Subscribe to the reserved cache-staleness pipe (ADR-0041) AFTER seeding, so a server
92
+ broadcast drops/refetches this tab's freshly-hydrated cache — live-only, never replay. */
93
+ const disposeStaleness = subscribeCacheStaleness()
94
+ const disposeRouter = router(target, routes, layoutRoutes, probeNavigation)
95
+ return () => {
96
+ disposeStaleness()
97
+ disposeRouter()
98
+ }
78
99
  }
@@ -0,0 +1,85 @@
1
+ import { abideLog } from '../shared/abideLog.ts'
2
+ import { CACHE_STALENESS_SOCKET } from '../shared/CACHE_STALENESS_SOCKET.ts'
3
+ import { cache } from '../shared/cache.ts'
4
+ import { matcherFromEnvelope } from '../shared/matcherFromEnvelope.ts'
5
+ import { SocketDisconnectedError } from '../shared/SocketDisconnectedError.ts'
6
+ import type { CacheStalenessFrame } from '../shared/types/CacheStalenessFrame.ts'
7
+ import type { Socket } from '../shared/types/Socket.ts'
8
+ import { socketProxy } from './socketProxy.ts'
9
+
10
+ /*
11
+ The client half of the cross-client staleness broadcast (ADR-0041): every tab
12
+ subscribes to the reserved __abide/cache socket at boot and applies each frame to
13
+ its own cache. A frame rebuilds the entry predicate via matcherFromEnvelope and
14
+ drives the SAME store loop a local invalidate()/refresh() does
15
+ (cache.invalidateMatching / cache.refreshMatching), so wire-driven and local applies
16
+ can't diverge.
17
+
18
+ LIVE-ONLY, never replay: bare iteration (replay 0) on both boot and reconnect — a
19
+ client applies only frames published while it is connected. The reserved topic keeps
20
+ no tail, so there is nothing to catch up on; a client offline when a frame was
21
+ published misses it and falls back to SWR staleness. Applies are idempotent, so
22
+ correctness has no dependency on frame ordering.
23
+
24
+ No-op on the server (no window): the broadcast is the server's job, not something it
25
+ consumes. Returns a disposer that stops the loop and closes the subscription.
26
+ */
27
+ export function subscribeCacheStaleness(injectedSocket?: Socket<CacheStalenessFrame>): () => void {
28
+ if (typeof window === 'undefined') {
29
+ return () => undefined
30
+ }
31
+ /* Default to the browser channel proxy; tests inject a socket over their own channel. */
32
+ const socket = injectedSocket ?? socketProxy<CacheStalenessFrame>(CACHE_STALENESS_SOCKET)
33
+ const controller = new AbortController()
34
+ /* `let`: a reconnect swaps in a fresh live iterator after a transport loss. */
35
+ let iterator = socket[Symbol.asyncIterator]()
36
+ ;(async () => {
37
+ while (!controller.signal.aborted) {
38
+ let next: IteratorResult<CacheStalenessFrame>
39
+ try {
40
+ next = await iterator.next()
41
+ } catch (error) {
42
+ if (controller.signal.aborted) {
43
+ return
44
+ }
45
+ /* Transport loss: re-open a fresh LIVE subscription (no replay) and keep going. */
46
+ if (error instanceof SocketDisconnectedError) {
47
+ iterator = socket[Symbol.asyncIterator]()
48
+ continue
49
+ }
50
+ abideLog.error(error)
51
+ return
52
+ }
53
+ if (controller.signal.aborted || next.done === true) {
54
+ return
55
+ }
56
+ /* One malformed frame must not tear down the live pipe: a throw from decode/apply
57
+ is logged and swallowed so every subsequent frame still delivers. Frames come
58
+ only from the trusted server (clientPublish is false), so this is defense in
59
+ depth, not an expected path. */
60
+ try {
61
+ applyFrame(next.value)
62
+ } catch (error) {
63
+ abideLog.error(error)
64
+ }
65
+ }
66
+ })()
67
+ return () => {
68
+ controller.abort()
69
+ iterator.return?.(undefined)?.catch(() => undefined)
70
+ }
71
+ }
72
+
73
+ /* Applies one decoded frame to this tab's cache via the shared apply-by-matcher seam. */
74
+ function applyFrame(frame: CacheStalenessFrame): void {
75
+ const matches = matcherFromEnvelope(frame)
76
+ /* The tripwire label + the rpc-error-registry prefix, mirroring what a local selector
77
+ derives: a tag frame has no key prefix to clear. */
78
+ const label = frame.mode === 'tags' ? `tags: ${frame.tags.join(', ')}` : frame.match
79
+ const prefix = frame.mode === 'tags' ? undefined : frame.match
80
+ if (frame.op === 'invalidate') {
81
+ cache.invalidateMatching(matches, label, prefix)
82
+ } else {
83
+ cache.refreshMatching(matches, label, prefix)
84
+ }
85
+ }
@@ -26,11 +26,14 @@ import { shell } from './_virtual/shell.ts'
26
26
  import { sockets } from './_virtual/sockets.ts'
27
27
  import { exitWithParent } from './lib/bundle/exitWithParent.ts'
28
28
  import { loadEnvFromBinaryDir } from './lib/cli/loadEnvFromBinaryDir.ts'
29
+ import { broadcastCacheStaleness } from './lib/server/runtime/cacheStalenessBroadcaster.ts'
29
30
  import { createServer } from './lib/server/runtime/createServer.ts'
30
31
  import { requestContext } from './lib/server/runtime/requestContext.ts'
31
32
  import { resolvePageSnapshot } from './lib/server/runtime/resolvePageSnapshot.ts'
33
+ import { cacheStalenessSlot } from './lib/shared/cacheStalenessSlot.ts'
32
34
  import { cacheStoreSlot } from './lib/shared/cacheStoreSlot.ts'
33
35
  import { createCacheStore } from './lib/shared/createCacheStore.ts'
36
+ import { docSnapshotsSlot } from './lib/shared/docSnapshotsSlot.ts'
34
37
  import { loadEnvFromDataDir } from './lib/shared/loadEnvFromDataDir.ts'
35
38
  import { pageSlot } from './lib/shared/pageSlot.ts'
36
39
  import { pendingAsyncCellsSlot } from './lib/shared/pendingAsyncCellsSlot.ts'
@@ -79,6 +82,13 @@ sharedCacheStoreSlot.resolver = () => sharedCacheStore
79
82
 
80
83
  cacheStoreSlot.resolver = () => requestContext.getStore()?.cache ?? sharedCacheStore
81
84
 
85
+ /*
86
+ Server-side invalidate()/refresh() broadcast to every connected client instead of
87
+ mutating a throwaway request store (ADR-0041). The broadcaster is imported ONLY here
88
+ so its server socket code never enters the client reachability graph.
89
+ */
90
+ cacheStalenessSlot.resolver = () => broadcastCacheStaleness
91
+
82
92
  /* One process-wide fallback list for reads with no request in flight (boot/cron/socket) —
83
93
  real requests each carry their own `pendingAsyncCells`, so the SSR barrier drains a
84
94
  per-request list and concurrent renders never cross-contaminate. */
@@ -89,6 +99,8 @@ const sharedResolvedCells = { entries: [] }
89
99
  resolvedCellsSlot.resolver = () => requestContext.getStore()?.resolvedCells ?? sharedResolvedCells
90
100
  const sharedStreamedCells = { entries: [] }
91
101
  streamedCellsSlot.resolver = () => requestContext.getStore()?.streamedCells ?? sharedStreamedCells
102
+ const sharedDocSnapshots = { entries: [] }
103
+ docSnapshotsSlot.resolver = () => requestContext.getStore()?.docSnapshots ?? sharedDocSnapshots
92
104
 
93
105
  pageSlot.resolver = resolvePageSnapshot
94
106