experimental-a2 0.9.0 → 0.11.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 (123) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -202
  24. package/dist/client.js +2 -1026
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
  45. package/dist/server-DwPrMqHB.d.ts.map +1 -0
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +85 -22
  80. package/docs/reference/01-api.mdx +90 -28
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +577 -175
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server-fetch.ts +51 -23
  100. package/src/server.ts +146 -19
  101. package/src/session-socket.ts +241 -88
  102. package/src/sse.ts +17 -0
  103. package/src/store-polling.ts +32 -12
  104. package/src/store-postgres.ts +292 -75
  105. package/src/store-presence-polling.ts +125 -0
  106. package/src/store-redis-core.ts +37 -30
  107. package/src/store-redis-http.ts +51 -45
  108. package/src/store-redis-notify.ts +464 -0
  109. package/src/store-redis.ts +9 -364
  110. package/src/store.ts +3 -4
  111. package/src/stream-activity.ts +32 -0
  112. package/src/wire.ts +39 -15
  113. package/dist/actor-shared-BACubf4x.d.ts +0 -136
  114. package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
  115. package/dist/actor.d.ts.map +0 -1
  116. package/dist/client.d.ts.map +0 -1
  117. package/dist/client.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/server-CBET-jSz.js.map +0 -1
  120. package/dist/server-CKY3_lbw.d.ts.map +0 -1
  121. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  122. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  123. package/dist/wire--yji6mO3.js.map +0 -1
@@ -0,0 +1,120 @@
1
+ import type { PresencePatch } from './contract.ts'
2
+ import type { PresenceRow } from './store.ts'
3
+
4
+ type Field = Pick<PresenceRow, 'participant' | 'field' | 'seen' | 'at'>
5
+ const key = ({
6
+ participant,
7
+ field,
8
+ }: Pick<Field, 'participant' | 'field'>): string =>
9
+ JSON.stringify([participant, field])
10
+
11
+ export function presenceRecovery({
12
+ read,
13
+ deliver,
14
+ isActive,
15
+ }: {
16
+ read: () => Promise<PresenceRow[]>
17
+ deliver: (patch: PresencePatch) => void
18
+ isActive: () => boolean
19
+ }): {
20
+ read(): Promise<PresenceRow[]>
21
+ reconnect(): Promise<void>
22
+ update(patch: PresencePatch): void
23
+ } {
24
+ const fields = new Map<string, Field>()
25
+ const reading = new Set<Set<string>>()
26
+ let generation = 0
27
+ let latest: Promise<PresenceRow[]> | undefined
28
+
29
+ const remember = ({ participant, field, seen, at }: Field): void => {
30
+ const id = key({ participant, field })
31
+ const previous = fields.get(id)
32
+ if (
33
+ !previous ||
34
+ previous.at.getTime() < at.getTime() ||
35
+ (previous.at.getTime() === at.getTime() && previous.seen !== seen)
36
+ )
37
+ fields.set(id, { participant, field, seen, at })
38
+ }
39
+ const snapshot = async ({
40
+ reconcile,
41
+ }: {
42
+ reconcile: boolean
43
+ }): Promise<PresenceRow[]> => {
44
+ const run = generation
45
+ const before = new Map(fields)
46
+ const changed = new Set<string>()
47
+ reading.add(changed)
48
+ try {
49
+ const rows = await read()
50
+ if (!isActive()) return rows
51
+ if (run !== generation) return latest!
52
+ const live = new Set<string>()
53
+ for (const row of rows) {
54
+ const id = key(row)
55
+ live.add(id)
56
+ if (changed.has(id)) continue
57
+ remember(row)
58
+ if (reconcile)
59
+ deliver({
60
+ participant: row.participant,
61
+ values: { [row.field]: row.value },
62
+ seen: row.seen,
63
+ at: row.at,
64
+ })
65
+ }
66
+ if (reconcile) {
67
+ for (const [id, prior] of before) {
68
+ if (live.has(id) || changed.has(id) || fields.get(id) !== prior)
69
+ continue
70
+ fields.delete(id)
71
+ deliver({
72
+ participant: prior.participant,
73
+ values: { [prior.field]: null },
74
+ seen: prior.seen,
75
+ at: prior.at,
76
+ })
77
+ }
78
+ }
79
+ return rows
80
+ } finally {
81
+ reading.delete(changed)
82
+ if (reading.size === 0) latest = undefined
83
+ }
84
+ }
85
+
86
+ return {
87
+ async read() {
88
+ let pending = latest
89
+ while (pending) {
90
+ // oxlint-disable-next-line no-await-in-loop -- follow replacement reconnects before starting a fresh read
91
+ await pending
92
+ if (latest === pending) break
93
+ pending = latest
94
+ }
95
+ return snapshot({ reconcile: false })
96
+ },
97
+ async reconnect() {
98
+ generation += 1
99
+ latest = snapshot({ reconcile: true })
100
+ await latest
101
+ },
102
+ update(patch) {
103
+ for (const [field, value] of Object.entries(patch.values)) {
104
+ if (value === undefined) continue
105
+ const id = key({ participant: patch.participant, field })
106
+ const previous = fields.get(id)
107
+ if (previous && previous.at.getTime() > patch.at.getTime()) continue
108
+ for (const changed of reading) changed.add(id)
109
+ if (value === null) fields.delete(id)
110
+ else
111
+ remember({
112
+ participant: patch.participant,
113
+ field,
114
+ seen: patch.seen,
115
+ at: patch.at,
116
+ })
117
+ }
118
+ },
119
+ }
120
+ }
package/src/react.ts CHANGED
@@ -137,6 +137,8 @@ export type UseSessionResult<
137
137
  * back. Rejects with the server's A2Error codes.
138
138
  */
139
139
  push: (...events: AppendInput<D>[]) => PushResult<D>
140
+ /** Restart the live subscription without resetting its state or cursor. */
141
+ reconnect: () => void
140
142
  /**
141
143
  * Backscroll: fetch a bounded slice of older events into `events`.
142
144
  * Defaults page backward 50 at a time from the oldest loaded event.
@@ -316,6 +318,7 @@ function useSessionResult<D extends EventDefs, S, P extends PresenceDefs>(
316
318
  index: snapshot.index,
317
319
  connection: snapshot.connection,
318
320
  push: session.push,
321
+ reconnect: session.reconnect,
319
322
  loadHistory: session.loadHistory,
320
323
  history: snapshot.history,
321
324
  ...(setPresence && presence ? { presence, setPresence } : {}),
@@ -0,0 +1,199 @@
1
+ import { A2Error } from './errors.ts'
2
+ import { NOTIFY_TIMINGS } from './internal.ts'
3
+ import { defaultSleep, type Sleeper } from './store-polling.ts'
4
+ import type { RedisConnection } from './store-redis-notify.ts'
5
+
6
+ export function redisHttpSubscriptions({
7
+ url,
8
+ token,
9
+ onReconnect,
10
+ fallback,
11
+ }: {
12
+ url: string
13
+ token: string
14
+ onReconnect: (channel: string) => void
15
+ fallback: { enabled(): boolean; enable(): Promise<void> }
16
+ }): RedisConnection {
17
+ const subscriptions = new Map<
18
+ string,
19
+ { ready: Promise<void>; stop(): void }
20
+ >()
21
+ const listeners = new Set<(channel: string, message: string) => void>()
22
+ let closed = false
23
+ let supported = false
24
+
25
+ const subscribe = (channel: string): Promise<void> => {
26
+ if (fallback.enabled()) return Promise.resolve()
27
+ const existing = subscriptions.get(channel)
28
+ if (existing) return existing.ready
29
+ let resolve!: () => void
30
+ let reject!: (error: unknown) => void
31
+ const ready = new Promise<void>((yes, no) => {
32
+ resolve = yes
33
+ reject = no
34
+ })
35
+ let stopped = false
36
+ let connected = false
37
+ let request: AbortController | undefined
38
+ let retry: Sleeper | undefined
39
+ const subscription = {
40
+ ready,
41
+ stop() {
42
+ stopped = true
43
+ request?.abort()
44
+ retry?.cancel()
45
+ resolve()
46
+ },
47
+ }
48
+ subscriptions.set(channel, subscription)
49
+
50
+ const run = async (): Promise<void> => {
51
+ let delay = 100
52
+ for (;;) {
53
+ if (stopped) return
54
+ request = new AbortController()
55
+ const timeout = setTimeout(
56
+ () => request?.abort(),
57
+ NOTIFY_TIMINGS.safetyReadMs,
58
+ )
59
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
60
+ let connectedAt: number | undefined
61
+ try {
62
+ // oxlint-disable-next-line no-await-in-loop -- reconnect loop
63
+ const response = await fetch(
64
+ `${url.replace(/\/$/, '')}/subscribe/${encodeURIComponent(channel)}`,
65
+ {
66
+ method: 'POST',
67
+ headers: {
68
+ authorization: `Bearer ${token}`,
69
+ accept: 'text/event-stream',
70
+ },
71
+ signal: request.signal,
72
+ },
73
+ )
74
+ if (!supported && [404, 405, 501].includes(response.status)) {
75
+ // oxlint-disable-next-line no-await-in-loop -- finish capability detection before falling back
76
+ await response.body?.cancel()
77
+ // oxlint-disable-next-line no-await-in-loop -- confirm that ordinary commands work
78
+ await fallback.enable()
79
+ for (const active of subscriptions.values()) active.stop()
80
+ subscriptions.clear()
81
+ return
82
+ }
83
+ if (
84
+ !response.ok ||
85
+ !response.headers
86
+ .get('content-type')
87
+ ?.startsWith('text/event-stream') ||
88
+ !response.body
89
+ ) {
90
+ // oxlint-disable-next-line no-await-in-loop -- release a failed subscription attempt
91
+ await response.body?.cancel()
92
+ throw new A2Error(
93
+ 'STORE_UNAVAILABLE',
94
+ `redis rest subscription failed: status ${response.status}`,
95
+ )
96
+ }
97
+ reader = response.body.getReader()
98
+ const decoder = new TextDecoder()
99
+ let buffer = ''
100
+ let data: string[] = []
101
+ let acknowledged = false
102
+ for (;;) {
103
+ if (stopped) break
104
+ // oxlint-disable-next-line no-await-in-loop -- consume SSE chunks
105
+ const chunk = await reader.read()
106
+ if (chunk.done) break
107
+ buffer += decoder.decode(chunk.value, { stream: true })
108
+ let newline: number
109
+ while ((newline = buffer.indexOf('\n')) !== -1) {
110
+ const line = buffer.slice(0, newline).replace(/\r$/, '')
111
+ buffer = buffer.slice(newline + 1)
112
+ if (line.startsWith('data:')) {
113
+ data.push(line.slice(5).replace(/^ /, ''))
114
+ } else if (line === '' && data.length > 0) {
115
+ const message = data.join('\n')
116
+ data = []
117
+ if (message.startsWith(`subscribe,${channel},`)) {
118
+ if (acknowledged) continue
119
+ acknowledged = true
120
+ supported = true
121
+ clearTimeout(timeout)
122
+ connectedAt = Date.now()
123
+ resolve()
124
+ if (connected) onReconnect(channel)
125
+ connected = true
126
+ } else if (message.startsWith(`message,${channel},`)) {
127
+ const payload = message.slice(`message,${channel},`.length)
128
+ for (const listener of listeners) listener(channel, payload)
129
+ }
130
+ }
131
+ }
132
+ }
133
+ if (!connected && !stopped) {
134
+ throw new A2Error(
135
+ 'STORE_UNAVAILABLE',
136
+ 'redis rest subscription ended before acknowledgement',
137
+ )
138
+ }
139
+ } catch (error) {
140
+ if (!connected && !stopped) {
141
+ reject(error)
142
+ if (subscriptions.get(channel) === subscription)
143
+ subscriptions.delete(channel)
144
+ return
145
+ }
146
+ } finally {
147
+ clearTimeout(timeout)
148
+ request.abort()
149
+ // oxlint-disable-next-line no-await-in-loop -- close this attempt before reconnecting
150
+ await reader?.cancel().catch(() => {})
151
+ reader?.releaseLock()
152
+ }
153
+ if (stopped) return
154
+ if (
155
+ connectedAt !== undefined &&
156
+ Date.now() - connectedAt >= NOTIFY_TIMINGS.safetyReadMs
157
+ )
158
+ delay = 100
159
+ retry = defaultSleep(delay)
160
+ // oxlint-disable-next-line no-await-in-loop -- bounded reconnect backoff
161
+ await retry.promise
162
+ delay = Math.min(delay * 2, NOTIFY_TIMINGS.safetyReadMs)
163
+ }
164
+ }
165
+ void run()
166
+ return ready
167
+ }
168
+
169
+ const connection: RedisConnection = {
170
+ call(command, ...args) {
171
+ if (closed)
172
+ return Promise.reject(
173
+ new A2Error('STORE_UNAVAILABLE', 'the store was closed'),
174
+ )
175
+ const channel = String(args[0])
176
+ if (command === 'echo') return Promise.resolve(args[0])
177
+ if (command === 'subscribe') return subscribe(channel)
178
+ if (command === 'unsubscribe') {
179
+ subscriptions.get(channel)?.stop()
180
+ subscriptions.delete(channel)
181
+ return Promise.resolve(null)
182
+ }
183
+ return Promise.reject(
184
+ new Error(`unsupported subscription command: ${command}`),
185
+ )
186
+ },
187
+ duplicate: () => connection,
188
+ on(_event, listener) {
189
+ listeners.add(listener)
190
+ },
191
+ disconnect() {
192
+ closed = true
193
+ for (const subscription of subscriptions.values()) subscription.stop()
194
+ subscriptions.clear()
195
+ listeners.clear()
196
+ },
197
+ }
198
+ return connection
199
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from './push-envelope.ts'
15
15
  import type { A2Server } from './server.ts'
16
16
  import {
17
+ sessionSocket,
17
18
  sessionsSocket,
18
19
  type A2Socket,
19
20
  type SocketSession,
@@ -76,6 +77,8 @@ export type ServerFetchOptions<
76
77
  > = {
77
78
  authorize?: (operation: A2Operation<D, P>) => boolean | Promise<boolean>
78
79
  upgradeWebSocket?: UpgradeWebSocket
80
+ /** Allow one upgraded connection to select more than one session. */
81
+ multiplexWebSocket?: boolean
79
82
  }
80
83
 
81
84
  export type ServerIngressContext = {
@@ -212,7 +215,27 @@ export function createServerFetch<
212
215
  await serverFetchHooks.get(fetch)?.validateIngress?.(context)
213
216
  }
214
217
 
215
- const attach = (socket: A2Socket): void => {
218
+ const gateSocketPush = async (
219
+ sessionId: string,
220
+ events: ParsedPushEvent[],
221
+ pushedPresence?: ParsedPushPresence,
222
+ ): Promise<A2Error | null> => {
223
+ try {
224
+ await authorize(
225
+ freezePush<D, P>(sessionId, events, pushedPresence, 'websocket'),
226
+ )
227
+ await validateIngress({
228
+ sessionId,
229
+ events,
230
+ ...(pushedPresence === undefined ? {} : { presence: pushedPresence }),
231
+ })
232
+ return null
233
+ } catch (error) {
234
+ return asA2Error(error)
235
+ }
236
+ }
237
+
238
+ const attachMultiplexed = (socket: A2Socket): void => {
216
239
  const deadline = invocationDeadlineMs()
217
240
  sessionsSocket<D>(
218
241
  async (sessionId, startAfter) => {
@@ -230,28 +253,7 @@ export function createServerFetch<
230
253
  {
231
254
  presence,
232
255
  ...(deadline === null ? {} : { deadline }),
233
- gatePush: async (sessionId, events, pushedPresence) => {
234
- try {
235
- await authorize(
236
- freezePush<D, P>(
237
- sessionId,
238
- events,
239
- pushedPresence,
240
- 'websocket',
241
- ),
242
- )
243
- await validateIngress({
244
- sessionId,
245
- events,
246
- ...(pushedPresence === undefined
247
- ? {}
248
- : { presence: pushedPresence }),
249
- })
250
- return null
251
- } catch (error) {
252
- return asA2Error(error)
253
- }
254
- },
256
+ gatePush: gateSocketPush,
255
257
  },
256
258
  )
257
259
  }
@@ -265,6 +267,32 @@ export function createServerFetch<
265
267
  { status: 426 },
266
268
  )
267
269
  }
270
+ if (options.multiplexWebSocket === true) {
271
+ return await options.upgradeWebSocket(attachMultiplexed)
272
+ }
273
+
274
+ const { searchParams } = new URL(request.url)
275
+ const sessionId = searchParams.get('sessionId')
276
+ if (sessionId === null || sessionId.length === 0) {
277
+ throw new A2Error('INVALID_PAYLOAD', 'missing sessionId')
278
+ }
279
+ const startAfter = parseResumeIndex(searchParams.get('index'))
280
+ await authorize({
281
+ type: 'stream',
282
+ sessionId,
283
+ startAfter,
284
+ transport: 'websocket',
285
+ })
286
+ const target = server.session(sessionId)
287
+ const attach = (socket: A2Socket): void => {
288
+ const deadline = invocationDeadlineMs()
289
+ sessionSocket(sessionId, target, socket, {
290
+ startAfter,
291
+ presence,
292
+ ...(deadline === null ? {} : { deadline }),
293
+ gatePush: gateSocketPush,
294
+ })
295
+ }
268
296
  return await options.upgradeWebSocket(attach)
269
297
  }
270
298
 
package/src/server.ts CHANGED
@@ -31,7 +31,8 @@ import {
31
31
  type DrainOutcome,
32
32
  type DrainResult,
33
33
  } from './internal.ts'
34
- import { defaultSleep } from './store-polling.ts'
34
+ import { defaultSleep, type Sleeper } from './store-polling.ts'
35
+ import { createStreamActivity, streamActivities } from './stream-activity.ts'
35
36
  import type {
36
37
  A2Store,
37
38
  AppendEvent,
@@ -1377,12 +1378,59 @@ export function createServer<
1377
1378
  id: string,
1378
1379
  startAfter: number,
1379
1380
  ): AsyncIterable<ContractEvent<D>> => {
1380
- const store = resolveStore()
1381
+ let closed = false
1382
+ let inner: AsyncIterator<Event> | undefined
1383
+ let stopActivity: (() => void) | undefined
1384
+ const activity = createStreamActivity(() => {
1385
+ if (inner && !closed) streamActivities.get(inner)?.activate()
1386
+ })
1387
+ let closing: Promise<void> | undefined
1388
+ const close = (): Promise<void> => {
1389
+ closed = true
1390
+ closing ??= (async () => {
1391
+ stopActivity?.()
1392
+ await inner?.return?.()
1393
+ })()
1394
+ return closing
1395
+ }
1381
1396
  const outer = async function* (): AsyncGenerator<ContractEvent<D>> {
1382
- const inner = (await store).stream(nsId(id), { startAfter })
1383
- for await (const event of inner) yield toSessionEvent(id, event)
1397
+ const store = await resolveStore()
1398
+ if (closed) return
1399
+ inner = store.stream(nsId(id), { startAfter })[Symbol.asyncIterator]()
1400
+ const sourceActivity = streamActivities.get(inner)
1401
+ if (sourceActivity) {
1402
+ stopActivity = sourceActivity.subscribe(activity.setIdle)
1403
+ activity.setIdle(sourceActivity.idle)
1404
+ }
1405
+ try {
1406
+ for (;;) {
1407
+ if (closed) return
1408
+ const result = await inner.next()
1409
+ if (closed || result.done) return
1410
+ yield toSessionEvent(id, result.value)
1411
+ }
1412
+ } finally {
1413
+ await close()
1414
+ }
1415
+ }
1416
+ const iterator = outer()
1417
+ return {
1418
+ [Symbol.asyncIterator]() {
1419
+ const stream = {
1420
+ next: () => iterator.next(),
1421
+ async return() {
1422
+ await close()
1423
+ return iterator.return(undefined)
1424
+ },
1425
+ async throw(error: unknown) {
1426
+ await close()
1427
+ return iterator.throw(error)
1428
+ },
1429
+ }
1430
+ streamActivities.set(stream, activity)
1431
+ return stream
1432
+ },
1384
1433
  }
1385
- return outer()
1386
1434
  }
1387
1435
 
1388
1436
  // ── presence ──────────────────────────────────────────────
@@ -1505,10 +1553,47 @@ export function createServer<
1505
1553
  startAfter: number,
1506
1554
  ): AsyncIterable<ContractEvent<D> | PresencePatch | PresenceSnapshot> => {
1507
1555
  const ns = nsId(id)
1556
+ let closed = false
1557
+ let events: AsyncIterator<Event> | undefined
1558
+ let unsubscribe: (() => void) | undefined
1559
+ let sleeper: Sleeper | null = null
1560
+ let wakeQueue: (() => void) | null = null
1561
+ let presenceInterval = Math.min(
1562
+ POLL_TIMINGS.activeFloorMs,
1563
+ POLL_TIMINGS.idleCeilingMs,
1564
+ )
1565
+ let presenceActivityVersion = 0
1566
+ let stopActivity: (() => void) | undefined
1567
+ const activity = createStreamActivity(() => {
1568
+ if (closed) return
1569
+ presenceActivityVersion += 1
1570
+ presenceInterval = Math.min(
1571
+ POLL_TIMINGS.activeFloorMs,
1572
+ POLL_TIMINGS.idleCeilingMs,
1573
+ )
1574
+ sleeper?.cancel()
1575
+ if (events) streamActivities.get(events)?.activate()
1576
+ })
1577
+ let closing: Promise<void> | undefined
1578
+ const close = (): Promise<void> => {
1579
+ closed = true
1580
+ sleeper?.cancel()
1581
+ wakeQueue?.()
1582
+ closing ??= (async () => {
1583
+ try {
1584
+ stopActivity?.()
1585
+ unsubscribe?.()
1586
+ } finally {
1587
+ await events?.return?.()
1588
+ }
1589
+ })()
1590
+ return closing
1591
+ }
1508
1592
  const outer = async function* (): AsyncGenerator<
1509
1593
  ContractEvent<D> | PresencePatch | PresenceSnapshot
1510
1594
  > {
1511
1595
  const store = await resolveStore()
1596
+ if (closed) return
1512
1597
  const api = requirePresence(store)
1513
1598
 
1514
1599
  const readRows = async (): Promise<PresenceRow[]> => {
@@ -1614,8 +1699,9 @@ export function createServer<
1614
1699
  string,
1615
1700
  Map<string, { value: unknown; seen: number; at: Date }>
1616
1701
  >()
1617
- let wakeQueue: (() => void) | null = null
1618
1702
  const enqueue = (patch: PresencePatch): void => {
1703
+ if (closed) return
1704
+ activity.activate()
1619
1705
  for (const [field, value] of Object.entries(patch.values)) {
1620
1706
  if (value === undefined) continue
1621
1707
  let fields = queue.get(patch.participant)
@@ -1650,17 +1736,22 @@ export function createServer<
1650
1736
  }
1651
1737
  return null
1652
1738
  }
1653
- // Subscribe before the snapshot read: a patch landing between
1654
- // the two is painted twice, never lost — repaints are idempotent.
1655
- const unsubscribe = api.subscribe?.(ns, enqueue)
1656
-
1657
- const events = store.stream(ns, { startAfter })[Symbol.asyncIterator]()
1658
1739
  let pendingEvent: Promise<IteratorResult<Event>> | null = null
1659
1740
  try {
1741
+ // Subscribe before the snapshot read: a patch landing between
1742
+ // the two is painted twice, never lost — repaints are idempotent.
1743
+ unsubscribe = api.subscribe?.(ns, enqueue)
1744
+ events = store.stream(ns, { startAfter })[Symbol.asyncIterator]()
1745
+ const sourceActivity = streamActivities.get(events)
1746
+ if (sourceActivity) {
1747
+ stopActivity = sourceActivity.subscribe(activity.setIdle)
1748
+ activity.setIdle(sourceActivity.idle)
1749
+ }
1660
1750
  // Exactly one snapshot before any live item: the pruned map,
1661
1751
  // per-field stamps exact — a reconnecting client replaces its
1662
1752
  // foreign entries wholesale from it.
1663
1753
  const snapshotRows = await readRows()
1754
+ if (closed) return
1664
1755
  for (const row of snapshotRows) {
1665
1756
  let fields = painted.get(row.participant)
1666
1757
  if (!fields) {
@@ -1676,9 +1767,11 @@ export function createServer<
1676
1767
  yield { snapshot: foldPresenceRows(snapshotRows) }
1677
1768
 
1678
1769
  for (;;) {
1770
+ if (closed) return
1679
1771
  for (;;) {
1680
1772
  const patch = dequeue()
1681
1773
  if (!patch) break
1774
+ if (closed) return
1682
1775
  paint(patch)
1683
1776
  yield patch
1684
1777
  }
@@ -1693,35 +1786,69 @@ export function createServer<
1693
1786
  arrival.then(() => 'patch' as const),
1694
1787
  ])
1695
1788
  wakeQueue = null
1789
+ if (closed) return
1696
1790
  if (woke === 'patch') continue
1697
1791
  } else {
1698
- const sleeper = defaultSleep(POLL_TIMINGS.idleCeilingMs)
1792
+ sleeper = defaultSleep(presenceInterval)
1699
1793
  woke = await Promise.race([
1700
1794
  pendingEvent.then(() => 'event' as const),
1701
1795
  sleeper.promise.then(() => 'tick' as const),
1702
1796
  ])
1703
1797
  sleeper.cancel()
1798
+ sleeper = null
1704
1799
  }
1800
+ if (closed) return
1705
1801
  if (woke === 'event') {
1706
1802
  const result = await pendingEvent
1707
1803
  pendingEvent = null
1708
- if (result.done) return
1804
+ if (closed || result.done) return
1805
+ activity.activate()
1709
1806
  yield toSessionEvent(id, result.value)
1710
1807
  }
1711
- if (!unsubscribe) {
1712
- for (const patch of diffRows(await readRows())) yield patch
1808
+ if (!closed && !unsubscribe) {
1809
+ const version = presenceActivityVersion
1810
+ const rows = await readRows()
1811
+ if (closed) return
1812
+ const patches = diffRows(rows)
1813
+ if (patches.length > 0) activity.activate()
1814
+ else if (version === presenceActivityVersion) {
1815
+ presenceInterval = Math.min(
1816
+ presenceInterval * 2,
1817
+ POLL_TIMINGS.idleCeilingMs,
1818
+ )
1819
+ }
1820
+ for (const patch of patches) {
1821
+ if (closed) return
1822
+ yield patch
1823
+ }
1713
1824
  }
1714
1825
  }
1715
1826
  } finally {
1716
- wakeQueue = null
1717
- unsubscribe?.()
1718
1827
  // The consumer may close while an event read is in flight; its
1719
1828
  // settlement is no longer anyone's business.
1720
1829
  pendingEvent?.catch(() => {})
1721
- await events.return?.()
1830
+ await close()
1831
+ wakeQueue = null
1722
1832
  }
1723
1833
  }
1724
- return outer()
1834
+ const iterator = outer()
1835
+ return {
1836
+ [Symbol.asyncIterator]() {
1837
+ const stream = {
1838
+ next: () => iterator.next(),
1839
+ async return() {
1840
+ await close()
1841
+ return iterator.return(undefined)
1842
+ },
1843
+ async throw(error: unknown) {
1844
+ await close()
1845
+ return iterator.throw(error)
1846
+ },
1847
+ }
1848
+ streamActivities.set(stream, activity)
1849
+ return stream
1850
+ },
1851
+ }
1725
1852
  }
1726
1853
 
1727
1854
  const makeSession = <Append>(