experimental-a2 0.5.1 → 0.7.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 (148) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +8 -7
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +41 -7
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +74 -35
  21. package/dist/react.js.map +1 -1
  22. package/dist/scheduler-qstash.d.ts +3 -3
  23. package/dist/scheduler-qstash.js +4 -5
  24. package/dist/scheduler-qstash.js.map +1 -1
  25. package/dist/scheduler-vercel.d.ts +2 -2
  26. package/dist/scheduler-vercel.js +4 -4
  27. package/dist/scheduler-vercel.js.map +1 -1
  28. package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
  29. package/dist/server-286j79Mt.js.map +1 -0
  30. package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
  31. package/dist/server-DgXmORIq.d.ts.map +1 -0
  32. package/dist/server.d.ts +3 -3
  33. package/dist/server.js +1 -1
  34. package/dist/store-N8PXxDAS.js.map +1 -1
  35. package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
  36. package/dist/store-flRz1OWh.d.ts.map +1 -0
  37. package/dist/store-memory.d.ts +1 -1
  38. package/dist/store-memory.d.ts.map +1 -1
  39. package/dist/store-memory.js +1 -59
  40. package/dist/store-memory.js.map +1 -1
  41. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  42. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  43. package/dist/store-postgres.d.ts +1 -1
  44. package/dist/store-postgres.js +1 -81
  45. package/dist/store-postgres.js.map +1 -1
  46. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
  47. package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
  48. package/dist/store-redis-http.d.ts +1 -1
  49. package/dist/store-redis-http.js +2 -3
  50. package/dist/store-redis-http.js.map +1 -1
  51. package/dist/store-redis.d.ts +1 -1
  52. package/dist/store-redis.js +3 -4
  53. package/dist/store-redis.js.map +1 -1
  54. package/dist/store-sqlite.d.ts +1 -1
  55. package/dist/store-sqlite.d.ts.map +1 -1
  56. package/dist/store-sqlite.js +1 -72
  57. package/dist/store-sqlite.js.map +1 -1
  58. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  59. package/dist/wire-B6te_wns.js.map +1 -0
  60. package/docs/guides/03-react.mdx +118 -29
  61. package/docs/guides/05-production.mdx +9 -11
  62. package/docs/guides/06-ai-agents.mdx +8 -14
  63. package/docs/guides/09-presence.mdx +19 -21
  64. package/docs/guides/10-transports.mdx +104 -86
  65. package/docs/reference/01-api.mdx +186 -279
  66. package/docs/reference/02-errors.mdx +5 -7
  67. package/package.json +1 -14
  68. package/src/ai-server.ts +9 -5
  69. package/src/client.ts +71 -35
  70. package/src/errors.ts +1 -0
  71. package/src/internal.ts +3 -62
  72. package/src/push-envelope.ts +24 -21
  73. package/src/react.ts +118 -44
  74. package/src/scheduler-qstash.ts +3 -3
  75. package/src/scheduler-vercel.ts +2 -2
  76. package/src/server-fetch.ts +344 -0
  77. package/src/server.ts +73 -225
  78. package/src/session-socket.ts +36 -20
  79. package/src/sse.ts +2 -2
  80. package/src/store-memory.ts +0 -81
  81. package/src/store-postgres.ts +0 -100
  82. package/src/store-redis-core.ts +47 -211
  83. package/src/store-redis-http.ts +0 -1
  84. package/src/store-redis.ts +0 -1
  85. package/src/store-sqlite.ts +0 -119
  86. package/src/store.ts +0 -60
  87. package/src/wire.ts +2 -1
  88. package/dist/ai-D_PGS-JR.d.ts +0 -334
  89. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  90. package/dist/cli-B3VuxoDe.js +0 -599
  91. package/dist/cli-B3VuxoDe.js.map +0 -1
  92. package/dist/cli-bin.d.ts +0 -1
  93. package/dist/cli-bin.js +0 -7
  94. package/dist/cli-bin.js.map +0 -1
  95. package/dist/cli.d.ts +0 -20
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js +0 -2
  98. package/dist/client-BKlyLiOU.js +0 -1008
  99. package/dist/client-BKlyLiOU.js.map +0 -1
  100. package/dist/client-D7mvIXrF.d.ts +0 -191
  101. package/dist/client-D7mvIXrF.d.ts.map +0 -1
  102. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  103. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  104. package/dist/devtools-kJJaORn-.js +0 -340
  105. package/dist/devtools-kJJaORn-.js.map +0 -1
  106. package/dist/devtools-server.browser.d.ts +0 -1
  107. package/dist/devtools-server.browser.js +0 -6
  108. package/dist/devtools-server.browser.js.map +0 -1
  109. package/dist/devtools-server.d.ts +0 -23
  110. package/dist/devtools-server.d.ts.map +0 -1
  111. package/dist/devtools-server.js +0 -1270
  112. package/dist/devtools-server.js.map +0 -1
  113. package/dist/devtools.d.ts +0 -2
  114. package/dist/devtools.js +0 -2
  115. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  116. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  117. package/dist/http.d.ts +0 -151
  118. package/dist/http.d.ts.map +0 -1
  119. package/dist/http.js +0 -706
  120. package/dist/http.js.map +0 -1
  121. package/dist/inspection-DaxB5jM2.js +0 -13
  122. package/dist/inspection-DaxB5jM2.js.map +0 -1
  123. package/dist/internal-DstsI6Re.js.map +0 -1
  124. package/dist/platform-B4TnJtWu.js +0 -34
  125. package/dist/platform-B4TnJtWu.js.map +0 -1
  126. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  127. package/dist/server-Duw6MVlB.js.map +0 -1
  128. package/dist/store-DysUkTH3.d.ts.map +0 -1
  129. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  130. package/dist/testing.browser.d.ts +0 -1
  131. package/dist/testing.browser.js +0 -6
  132. package/dist/testing.browser.js.map +0 -1
  133. package/dist/testing.d.ts +0 -32
  134. package/dist/testing.d.ts.map +0 -1
  135. package/dist/testing.js +0 -103
  136. package/dist/testing.js.map +0 -1
  137. package/dist/wire-BFQmSJ-9.js.map +0 -1
  138. package/docs/guides/07-devtools.mdx +0 -229
  139. package/src/cli-bin.ts +0 -5
  140. package/src/cli.ts +0 -1046
  141. package/src/devtools-app.ts +0 -989
  142. package/src/devtools-server.browser.ts +0 -5
  143. package/src/devtools-server.ts +0 -604
  144. package/src/devtools.ts +0 -716
  145. package/src/http.ts +0 -394
  146. package/src/inspection.ts +0 -39
  147. package/src/testing.browser.ts +0 -5
  148. package/src/testing.ts +0 -185
package/src/ai-server.ts CHANGED
@@ -57,11 +57,14 @@ import {
57
57
  createServer,
58
58
  type A2Server,
59
59
  type HandlerContext,
60
- type PushValidationContext,
61
60
  type ServerOptions,
62
61
  } from './server.ts'
62
+ import {
63
+ setServerFetchHooks,
64
+ type ServerIngressContext,
65
+ } from './server-fetch.ts'
63
66
 
64
- export function validateAgentPush(context: PushValidationContext): void {
67
+ function validateAgentIngress(context: ServerIngressContext): void {
65
68
  const rejected = context.events.find((event) => {
66
69
  if (event.type !== 'ai.message.created') {
67
70
  return !(
@@ -203,7 +206,7 @@ export type CreateAgentServerOptions<
203
206
  D extends AIEventDefs<M> & EventDefs,
204
207
  T extends ToolSet = ToolSet,
205
208
  > = CreateHandlersOptions<M, D, T> &
206
- Omit<ServerOptions<D>, 'contract' | 'handlers' | 'validatePush'> & {
209
+ Omit<ServerOptions<D>, 'contract' | 'handlers'> & {
207
210
  handlers?: ServerOptions<D>['handlers']
208
211
  }
209
212
 
@@ -1708,12 +1711,13 @@ export function createAgentServer<
1708
1711
  ...(compaction === undefined ? {} : { compaction }),
1709
1712
  ...(progress === undefined ? {} : { progress }),
1710
1713
  })
1711
- return createServer({
1714
+ const server = createServer({
1712
1715
  ...serverOptions,
1713
1716
  contract: definition.contract,
1714
1717
  handlers: { ...builtIns, ...handlers },
1715
- validatePush: validateAgentPush,
1716
1718
  })
1719
+ setServerFetchHooks(server, { validateIngress: validateAgentIngress })
1720
+ return server
1717
1721
  }
1718
1722
 
1719
1723
  export type { Instructions, LanguageModel, ToolSet }
package/src/client.ts CHANGED
@@ -177,13 +177,19 @@ export type SessionClient<
177
177
  * this throws a TypeError there.
178
178
  */
179
179
  loadHistory(options?: LoadHistoryOptions): Promise<ContractEvent<D>[]>
180
- /** Open the live stream (idempotent while open). Reconnects with
181
- * backoff and resumes from the frontier until `close()`. */
182
- connect(): void
183
180
  /**
184
- * Stop the live stream. Not terminal: `connect()` starts it again
185
- * from the current frontier — which is what makes the React
186
- * StrictMode mount dance (setup → cleanup → setup) work.
181
+ * Take a lease on the live stream and return its release. Leases
182
+ * refcount per handle — the first connects, releasing the last
183
+ * closes — so independent consumers of one identity-mapped handle
184
+ * (two hooks, a hook plus vanilla code) never fight over the
185
+ * stream. Releasing twice is a no-op. While any lease is held the
186
+ * stream reconnects with backoff and resumes from the frontier.
187
+ */
188
+ connect(): () => void
189
+ /**
190
+ * The hard stop: drops every outstanding lease and closes the
191
+ * stream now. Not terminal — a later `connect()` starts fresh from
192
+ * the current frontier.
187
193
  */
188
194
  close(): void
189
195
  }
@@ -193,11 +199,8 @@ export type SessionOptions<D extends EventDefs, S> = {
193
199
  initialIndex?: number
194
200
  /** Server-rendered history through `initialIndex`. Seeds the event feed. */
195
201
  initialEvents?: ContractEvent<D>[]
196
- /**
197
- * This client's presence identity — required to call `setPresence`.
198
- * Caller-supplied (a user id, a tab nonce, a guest name): A2 does
199
- * not invent an identity story. Bound once per session handle.
200
- */
202
+ /** Overrides the client's `participant` for this session handle.
203
+ * Bound once per handle. */
201
204
  participant?: string
202
205
  }
203
206
 
@@ -263,6 +266,14 @@ export type CreateClientOptions<
263
266
  /** How long an idle session keeps its in-memory identity, in
264
267
  * milliseconds. Defaults to five minutes; `Infinity` disables GC. */
265
268
  gcTime?: number
269
+ /**
270
+ * This client's presence identity — required to call `setPresence`.
271
+ * Caller-supplied (a user id, a tab nonce, a guest name): A2 does
272
+ * not invent an identity story; the app states it once here. The
273
+ * default for every session handle this client creates;
274
+ * `session(id, { participant })` overrides it per handle.
275
+ */
276
+ participant?: string
266
277
  }
267
278
 
268
279
  const PUSH_ATTEMPTS = 3
@@ -797,7 +808,7 @@ const wsTransport = (
797
808
  settleChannel(
798
809
  channel,
799
810
  new Error(
800
- 'subscribe not confirmed: is the ws route serving the multiplexed socket (handle with options.upgrade)?',
811
+ 'subscribe not confirmed: is the ws route calling server.fetch with upgradeWebSocket?',
801
812
  ),
802
813
  )
803
814
  }, STREAM_TIMINGS.stallTimeoutMs)
@@ -1022,7 +1033,7 @@ export function createClient<
1022
1033
  // stream; the own entry is the local echo — server copies of self
1023
1034
  // are ignored (the echo is at least as new, and comparing it to
1024
1035
  // server stamps would put two clocks in one order).
1025
- let participant = sessionOptions?.participant
1036
+ let participant = sessionOptions?.participant ?? options.participant
1026
1037
  // Participant and field keys arrive off the wire, so the map and
1027
1038
  // every entry are built null-prototype — see `nullProtoRecord`.
1028
1039
  let presenceState: PresenceMap = nullProtoRecord()
@@ -1292,7 +1303,7 @@ export function createClient<
1292
1303
  const setPresence = (values: PresencePatch<P>['values']): void => {
1293
1304
  if (participant === undefined) {
1294
1305
  throw new TypeError(
1295
- 'setPresence requires a participant — pass one in the session options (the SessionProvider participant prop)',
1306
+ 'setPresence requires a participant — pass one to createClient({ participant }) or in the session options (the SessionProvider/useSession participant prop)',
1296
1307
  )
1297
1308
  }
1298
1309
  const validated: Record<string, unknown> = nullProtoRecord()
@@ -1551,7 +1562,7 @@ export function createClient<
1551
1562
  const readHistory = transport.history
1552
1563
  if (readHistory === undefined) {
1553
1564
  throw new TypeError(
1554
- 'loadHistory requires an http api — the ws transport has no history lane; serve the session over handle() and pass its route url as the string or { type: "http" } api',
1565
+ 'loadHistory requires an http api — the ws transport has no history lane; serve the session with server.fetch and pass its route url as the string or { type: "http" } api',
1555
1566
  )
1556
1567
  }
1557
1568
  const before = loadOptions?.before
@@ -1600,11 +1611,34 @@ export function createClient<
1600
1611
 
1601
1612
  // ── the live stream ──────────────────────────────────────────
1602
1613
  // A run-generation model instead of a terminal `closed` flag:
1603
- // close() bumps the generation (the running loop notices and
1614
+ // stopping bumps the generation (the running loop notices and
1604
1615
  // exits), connect() starts a fresh one. Reentrant by design.
1616
+ // Consumers hold leases: connect() counts one and returns its
1617
+ // release, the last release stops the stream, and close() is the
1618
+ // hard stop — it drops every lease and stops now (bumping
1619
+ // `leaseEpoch` makes the outstanding releases inert).
1605
1620
  let generation = 0
1606
1621
  let active = false
1607
1622
  let abort: AbortController | null = null
1623
+ let leases = 0
1624
+ let leaseEpoch = 0
1625
+
1626
+ const stopStream = (): void => {
1627
+ touch(sessionId)
1628
+ if (!active) return
1629
+ active = false
1630
+ generation += 1 // the running loop notices and exits
1631
+ abort?.abort()
1632
+ abort = null
1633
+ // A pending trailing send dies with the stream — losing one is
1634
+ // fine by definition; sets made while closed accumulate in
1635
+ // `presenceResend` and repaint after the next connect.
1636
+ clearTimeout(presenceTimer)
1637
+ presenceTimer = undefined
1638
+ presenceBuffer = null
1639
+ status = 'closed'
1640
+ notify()
1641
+ }
1608
1642
 
1609
1643
  const runStream = async (run: number): Promise<void> => {
1610
1644
  let backoff = STREAM_TIMINGS.reconnectBaseMs
@@ -1727,27 +1761,29 @@ export function createClient<
1727
1761
  loadHistory,
1728
1762
  connect() {
1729
1763
  touch(sessionId)
1730
- if (active) return
1731
- active = true
1732
- status = 'connecting'
1733
- notify()
1734
- void runStream(generation)
1764
+ leases += 1
1765
+ const epoch = leaseEpoch
1766
+ if (!active) {
1767
+ active = true
1768
+ status = 'connecting'
1769
+ notify()
1770
+ void runStream(generation)
1771
+ }
1772
+ let released = false
1773
+ return () => {
1774
+ // Idempotent, and inert after a hard close() — a stale
1775
+ // release must not touch leases taken since.
1776
+ if (released || leaseEpoch !== epoch) return
1777
+ released = true
1778
+ touch(sessionId)
1779
+ leases -= 1
1780
+ if (leases === 0) stopStream()
1781
+ }
1735
1782
  },
1736
1783
  close() {
1737
- touch(sessionId)
1738
- if (!active) return
1739
- active = false
1740
- generation += 1 // the running loop notices and exits
1741
- abort?.abort()
1742
- abort = null
1743
- // A pending trailing send dies with the stream — losing one is
1744
- // fine by definition; sets made while closed accumulate in
1745
- // `presenceResend` and repaint after the next connect.
1746
- clearTimeout(presenceTimer)
1747
- presenceTimer = undefined
1748
- presenceBuffer = null
1749
- status = 'closed'
1750
- notify()
1784
+ leases = 0
1785
+ leaseEpoch += 1
1786
+ stopStream()
1751
1787
  },
1752
1788
  hydrate(next) {
1753
1789
  touch(sessionId)
package/src/errors.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  export type A2ErrorCode =
10
10
  | 'INVALID_PAYLOAD' // schema validation failed — thrown before anything is written
11
+ | 'FORBIDDEN' // external ingress was denied by server.fetch authorization
11
12
  | 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map
12
13
  | 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
13
14
  | 'SUPERSEDED_ATTEMPT' // handler append from an attempt a recovery claim replaced
package/src/internal.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ScheduledEvent } from './scheduler-task.ts'
7
- import type { A2Scheduler, DrainableServer } from './server.ts'
7
+ import type { DrainableServer } from './server.ts'
8
8
 
9
9
  export type DrainOutcome = 'busy' | 'stalled' | 'settled'
10
10
 
@@ -67,14 +67,6 @@ export type ServerInternals = {
67
67
 
68
68
  export const serverInternals: WeakMap<object, ServerInternals> = new WeakMap()
69
69
 
70
- type ServerSchedulerBinding = {
71
- scheduler: A2Scheduler | undefined
72
- }
73
-
74
- /** Scheduler configuration captured only for servers made by `createServer`. */
75
- export const serverSchedulerBindings: WeakMap<object, ServerSchedulerBinding> =
76
- new WeakMap()
77
-
78
70
  const objectLike = (value: unknown): value is object =>
79
71
  (typeof value === 'object' && value !== null) || typeof value === 'function'
80
72
 
@@ -232,13 +224,11 @@ const isDrainableServer = (value: unknown): value is DrainableServer => {
232
224
 
233
225
  /**
234
226
  * Validate the server list captured by a scheduler route at construction time.
235
- * Built-in adapters keep their low-level target seam structural for custom
236
- * integrations. The application-facing `schedulerHandler` uses strict mode
237
- * so only servers made by this A2 copy reach a configured adapter.
227
+ * The seam stays structural so custom scheduler integrations can supply their
228
+ * own drainable targets.
238
229
  */
239
230
  export function assertSchedulerTargets(
240
231
  servers: readonly unknown[],
241
- options: { allowStructural: boolean },
242
232
  ): asserts servers is readonly DrainableServer[] {
243
233
  const contracts = new Set<string>()
244
234
  for (const target of servers) {
@@ -252,56 +242,7 @@ export function assertSchedulerTargets(
252
242
  )
253
243
  }
254
244
  contracts.add(name)
255
-
256
- const binding = serverSchedulerBindings.get(target)
257
- if (!binding) {
258
- if (!options.allowStructural) {
259
- throw new TypeError(
260
- `a2 scheduler: server for contract '${name}' was not created by createServer()`,
261
- )
262
- }
263
- continue
264
- }
265
- }
266
- }
267
-
268
- /** Require every real server to share one exact configured scheduler. */
269
- export function assertServerSchedulerBindings(
270
- scheduler: A2Scheduler,
271
- servers: readonly DrainableServer[],
272
- ): void {
273
- for (const server of servers) {
274
- const binding = serverSchedulerBindings.get(server)
275
- if (!binding?.scheduler) {
276
- throw new TypeError(
277
- `a2 scheduler: server for contract '${server.contract.name}' has no scheduler configured`,
278
- )
279
- }
280
- if (binding.scheduler !== scheduler) {
281
- throw new TypeError(
282
- `a2 scheduler: server for contract '${server.contract.name}' is configured with a different scheduler instance`,
283
- )
284
- }
285
- }
286
- }
287
-
288
- /** Resolve the scheduler privately bound to a real A2 server. */
289
- export function schedulerForServer(server: unknown): A2Scheduler {
290
- if (!isDrainableServer(server)) {
291
- throw new TypeError('a2 scheduler: expected a server from createServer()')
292
- }
293
- const binding = serverSchedulerBindings.get(server)
294
- if (!binding) {
295
- throw new TypeError(
296
- `a2 scheduler: server for contract '${server.contract.name}' was not created by createServer()`,
297
- )
298
- }
299
- if (!binding.scheduler) {
300
- throw new TypeError(
301
- `a2 scheduler: server for contract '${server.contract.name}' has no scheduler configured`,
302
- )
303
245
  }
304
- return binding.scheduler
305
246
  }
306
247
 
307
248
  /**
@@ -1,14 +1,25 @@
1
1
  /**
2
- * The push envelope's trust boundary — parsing and provenance-branding
3
- * for `{ sessionId, events, presence? }`, shared verbatim by the HTTP
2
+ * The push envelope's trust boundary for `{ sessionId, events, presence? }`,
3
+ * shared verbatim by the HTTP
4
4
  * POST lane and the socket's push/presence frames so INVALID_PAYLOAD
5
- * shapes are one implementation. Internal module — `handle` (and the
6
- * socket handler) compose it; it is not part of the public surface.
5
+ * shapes are one implementation. Internal module, not public API.
7
6
  */
8
7
 
9
8
  import { A2Error } from './errors.ts'
10
9
  import { MAX_DATE_MS, RESERVED_PARTICIPANT_IDS } from './internal.ts'
11
- import type { PushedEvent, PushedPresence } from './server.ts'
10
+
11
+ export type ParsedPushEvent = {
12
+ type: string
13
+ payload: unknown
14
+ id?: string
15
+ }
16
+
17
+ export type ParsedPushPresence = {
18
+ participant: string
19
+ values: Record<string, unknown>
20
+ seen?: number
21
+ at?: number
22
+ }
12
23
 
13
24
  export const invalidPushBody = (message: string): A2Error =>
14
25
  new A2Error('INVALID_PAYLOAD', `malformed push body: ${message}`)
@@ -17,18 +28,13 @@ const invalid = invalidPushBody
17
28
 
18
29
  export type PushBody = {
19
30
  sessionId: string
20
- /** Branded: `session.append` accepts these directly (the push route
21
- * path); schema validation still happens inside `append`. Empty only
22
- * for a presence-only push. */
23
- events: PushedEvent[]
24
- /** Branded like `events`: `session.setPresence` accepts it whole;
25
- * field validation still happens inside `setPresence`. */
26
- presence?: PushedPresence
31
+ events: ParsedPushEvent[]
32
+ presence?: ParsedPushPresence
27
33
  }
28
34
 
29
35
  export function parsePresenceSibling(
30
36
  value: unknown,
31
- ): PushedPresence | undefined {
37
+ ): ParsedPushPresence | undefined {
32
38
  if (value === undefined) return undefined
33
39
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
34
40
  throw invalid('presence must be an object when present')
@@ -68,14 +74,12 @@ export function parsePresenceSibling(
68
74
  } = { participant, values: values as Record<string, unknown> }
69
75
  if (seen !== undefined) out.seen = seen
70
76
  if (at !== undefined) out.at = at
71
- Object.defineProperty(out, '~a2.pushed', { value: true })
72
- return out as PushedPresence
77
+ return out
73
78
  }
74
79
 
75
- /** The events half of the envelope — shared verbatim by the POST body
76
- * and the socket's `push` frames, so the provenance brand and the
77
- * INVALID_PAYLOAD shapes are one implementation. */
78
- export function parsePushEvents(events: unknown): PushedEvent[] {
80
+ /** The events half of the envelope, shared by the POST body and socket
81
+ * `push` frames so INVALID_PAYLOAD shapes have one implementation. */
82
+ export function parsePushEvents(events: unknown): ParsedPushEvent[] {
79
83
  if (!Array.isArray(events) || events.length === 0) {
80
84
  throw invalid('events must be a non-empty array')
81
85
  }
@@ -95,8 +99,7 @@ export function parsePushEvents(events: unknown): PushedEvent[] {
95
99
  payload,
96
100
  }
97
101
  if (id !== undefined) out.id = id
98
- Object.defineProperty(out, '~a2.pushed', { value: true })
99
- return out as PushedEvent
102
+ return out
100
103
  })
101
104
  }
102
105
 
package/src/react.ts CHANGED
@@ -10,6 +10,12 @@
10
10
  * renders resolve the same live session. Everything is typed by the
11
11
  * reducer value — no type arguments, and nothing here ever touches the
12
12
  * machine module.
13
+ *
14
+ * The standalone `useSession(client, sessionId, options?)` is the
15
+ * provider-less primitive for client-first apps: identity-mapped
16
+ * handles, one leased stream per session, and an app-owned hydration
17
+ * input — the hook holds the stream until the app hands it a
18
+ * `{ state, index }` fold.
13
19
  */
14
20
 
15
21
  import {
@@ -44,6 +50,30 @@ import type {
44
50
  } from './contract.ts'
45
51
  import type { Reducer } from './reducer.ts'
46
52
 
53
+ export type UseSessionOptions<S> = {
54
+ /** Overrides the client's `participant` for this session — for apps
55
+ * whose identity is only known at mount time. Bound once per
56
+ * session handle. */
57
+ participant?: string
58
+ /**
59
+ * The server fold this session mounts from: the `{ state, index }`
60
+ * pair `session.state(reducer)` returns, fetched by the app through
61
+ * its own route. Atomic on purpose — the fold and its frontier
62
+ * travel together or not at all. While `undefined` (the app's fetch
63
+ * has not landed), the hook holds the stream; when the value
64
+ * arrives, the session hydrates through the ordinary re-hydration
65
+ * seam and the stream connects at that frontier. To fold from the log's start
66
+ * deliberately, hand the fold's true starting point:
67
+ * `{ state: reducer.initialState, index: 0 }` — state at index 0 is
68
+ * the reducer's seed by definition, so the explicit replay needs no
69
+ * special vocabulary. There is no pending/ready echo on the result:
70
+ * whether the fold has been handed over is this option — the
71
+ * caller's own input — so the data layer's loading state IS the
72
+ * pending state.
73
+ */
74
+ hydrate?: { state: S; index: number } | undefined
75
+ }
76
+
47
77
  export type SessionProviderProps<D extends EventDefs, S> = {
48
78
  /** Which session to subscribe to. */
49
79
  sessionId: string
@@ -55,11 +85,8 @@ export type SessionProviderProps<D extends EventDefs, S> = {
55
85
  initialIndex: number
56
86
  /** Server-rendered event history through `initialIndex`. */
57
87
  initialEvents?: ContractEvent<D>[]
58
- /**
59
- * This client's presence identity — required to call `setPresence`.
60
- * Caller-supplied (a user id, a tab nonce, a guest name): A2 does
61
- * not invent an identity story.
62
- */
88
+ /** Overrides the client's `participant` for this session — for apps
89
+ * whose identity is only known at mount time. */
63
90
  participant?: string
64
91
  children?: ReactNode
65
92
  }
@@ -156,7 +183,6 @@ export function createReact<
156
183
  options: { client: A2Client<D, S, P> } | { reducer: Reducer<D, S, P> },
157
184
  ): BoundA2React<D, S, P> | A2React<D, S, P> {
158
185
  const Context = createContext<SessionClient<D, S, P> | null>(null)
159
- const providerMounts = new WeakMap<SessionClient<D, S, P> & object, number>()
160
186
  const resolveSession: (
161
187
  sessionId: string,
162
188
  api: string | undefined,
@@ -232,53 +258,101 @@ export function createReact<
232
258
  ),
233
259
  [sessionId, api, initialState, initialIndex, initialEvents, participant],
234
260
  )
235
- useEffect(() => {
236
- const mounts = providerMounts.get(client) ?? 0
237
- providerMounts.set(client, mounts + 1)
238
- if (mounts === 0) client.connect()
239
- return () => {
240
- const remaining = (providerMounts.get(client) ?? 1) - 1
241
- if (remaining === 0) {
242
- providerMounts.delete(client)
243
- client.close()
244
- } else {
245
- providerMounts.set(client, remaining)
246
- }
247
- }
248
- }, [client])
261
+ useSessionLifecycle(client, true)
249
262
  return createElement(Context.Provider, { value: client }, props.children)
250
263
  }
251
264
 
252
- function useSession(): UseSessionResult<D, S, P> {
265
+ function useBoundSession(): UseSessionResult<D, S, P> {
253
266
  const client = useContext(Context)
254
267
  if (!client) {
255
268
  throw new Error(
256
269
  'useSession must be rendered inside its matching SessionProvider',
257
270
  )
258
271
  }
259
- const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(
260
- client.subscribe,
261
- client.getSnapshot,
262
- client.getSnapshot,
263
- )
264
- return useMemo(() => {
265
- // Runtime mirror of the WithPresence surface: the client attaches
266
- // setPresence (and the snapshot carries presence) exactly when the
267
- // reducer declares presence fields — no runtime-inert members.
268
- const { setPresence } = client as Partial<SessionClientPresence<P>>
269
- const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>
270
- return {
271
- state: snapshot.state,
272
- events: snapshot.events,
273
- index: snapshot.index,
274
- connection: snapshot.connection,
275
- push: client.push,
276
- loadHistory: client.loadHistory,
277
- history: snapshot.history,
278
- ...(setPresence && presence ? { presence, setPresence } : {}),
279
- } as UseSessionResult<D, S, P>
280
- }, [snapshot, client])
272
+ return useSessionResult(client)
281
273
  }
282
274
 
283
- return { SessionProvider, useSession }
275
+ return { SessionProvider, useSession: useBoundSession }
276
+ }
277
+
278
+ // ── the shared session lifecycle ───────────────────────────────
279
+
280
+ /**
281
+ * Hold a stream lease while mounted and `live` — the client refcounts
282
+ * leases per handle, so however many providers and hooks mount one
283
+ * session, the first connects and the last release closes
284
+ * (StrictMode-safe: the lease is retaken on remount). A standalone
285
+ * hook without a `hydrate` value passes `live: false` and holds the
286
+ * stream — connecting an unhydrated session would replay the whole
287
+ * log, and the library never picks the expensive path silently.
288
+ */
289
+ function useSessionLifecycle<D extends EventDefs, S, P extends PresenceDefs>(
290
+ session: SessionClient<D, S, P>,
291
+ live: boolean,
292
+ ): void {
293
+ useEffect(() => {
294
+ if (!live) return
295
+ return session.connect()
296
+ }, [session, live])
297
+ }
298
+
299
+ function useSessionResult<D extends EventDefs, S, P extends PresenceDefs>(
300
+ session: SessionClient<D, S, P>,
301
+ ): UseSessionResult<D, S, P> {
302
+ const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(
303
+ session.subscribe,
304
+ session.getSnapshot,
305
+ session.getSnapshot,
306
+ )
307
+ return useMemo(() => {
308
+ // Runtime mirror of the WithPresence surface: the client attaches
309
+ // setPresence (and the snapshot carries presence) exactly when the
310
+ // reducer declares presence fields — no runtime-inert members.
311
+ const { setPresence } = session as Partial<SessionClientPresence<P>>
312
+ const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>
313
+ return {
314
+ state: snapshot.state,
315
+ events: snapshot.events,
316
+ index: snapshot.index,
317
+ connection: snapshot.connection,
318
+ push: session.push,
319
+ loadHistory: session.loadHistory,
320
+ history: snapshot.history,
321
+ ...(setPresence && presence ? { presence, setPresence } : {}),
322
+ } as UseSessionResult<D, S, P>
323
+ }, [snapshot, session])
324
+ }
325
+
326
+ /**
327
+ * The standalone, provider-less hook — for apps whose components reach
328
+ * sessions ad hoc (a sidebar of channel sessions, a user session read
329
+ * from a menu). Takes the shared client and a session id; the handle
330
+ * is identity-mapped, so every hook and provider mounting the same
331
+ * session shares one runtime and one leased stream. Hydration is
332
+ * app-owned: pass `hydrate` when your fetch lands; until then the
333
+ * stream is held, and your data layer's loading state is the pending
334
+ * state.
335
+ */
336
+ export function useSession<
337
+ D extends EventDefs,
338
+ S,
339
+ P extends PresenceDefs = Record<never, never>,
340
+ >(
341
+ client: A2Client<D, S, P>,
342
+ sessionId: string,
343
+ options?: UseSessionOptions<S>,
344
+ ): UseSessionResult<D, S, P> {
345
+ const { participant, hydrate } = options ?? {}
346
+ const session = useMemo(
347
+ () =>
348
+ client.session(sessionId, {
349
+ ...(hydrate === undefined
350
+ ? {}
351
+ : { initialState: hydrate.state, initialIndex: hydrate.index }),
352
+ ...(participant === undefined ? {} : { participant }),
353
+ }),
354
+ [client, sessionId, hydrate, participant],
355
+ )
356
+ useSessionLifecycle(session, hydrate !== undefined)
357
+ return useSessionResult(session)
284
358
  }
@@ -94,7 +94,7 @@ export type QStashTransport = {
94
94
  }
95
95
 
96
96
  export type QStashSchedulerOptions = {
97
- /** Exact URL mounted with `schedulerHandler(...)`. Inferred in local dev and on Vercel. */
97
+ /** Exact URL mounted with `scheduler.handler(...)`. Inferred in local dev and on Vercel. */
98
98
  url?: string
99
99
  /** QStash API token. Default: `QSTASH_TOKEN`. */
100
100
  token?: string
@@ -819,7 +819,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
819
819
  },
820
820
 
821
821
  handler(...servers: DrainableServer[]) {
822
- assertSchedulerTargets(servers, { allowStructural: true })
822
+ assertSchedulerTargets(servers)
823
823
  const byContract = new Map<string, DrainableServer>()
824
824
  for (const server of servers) {
825
825
  byContract.set(server.contract.name, server)
@@ -851,7 +851,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
851
851
  const server = byContract.get(task.contract)
852
852
  if (!server) {
853
853
  throw new Error(
854
- `a2 scheduler: no server for contract '${task.contract}' — pass it to schedulerHandler(...)`,
854
+ `a2 scheduler: no server for contract '${task.contract}' — pass it to scheduler.handler(...)`,
855
855
  )
856
856
  }
857
857
  const internals = serverInternals.get(server)
@@ -368,7 +368,7 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
368
368
  },
369
369
 
370
370
  handler(...servers: DrainableServer[]) {
371
- assertSchedulerTargets(servers, { allowStructural: true })
371
+ assertSchedulerTargets(servers)
372
372
  const byContract = new Map<string, DrainableServer>()
373
373
  for (const server of servers) {
374
374
  byContract.set(server.contract.name, server)
@@ -386,7 +386,7 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
386
386
  // wiring bug — throw so it redelivers and stays visible in
387
387
  // queue observability instead of vanishing on an ack.
388
388
  throw new Error(
389
- `a2 scheduler: no server for contract '${task.contract}' — pass it to schedulerHandler(...)`,
389
+ `a2 scheduler: no server for contract '${task.contract}' — pass it to scheduler.handler(...)`,
390
390
  )
391
391
  }
392
392
  const internals = serverInternals.get(server)