experimental-a2 0.10.0 → 0.12.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 (121) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/actor-DJi3RsNu.d.ts +310 -0
  3. package/dist/actor-DJi3RsNu.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 -208
  24. package/dist/client.js +2 -1206
  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-DlLyvaSH.js → server-BeNADlCI.js} +171 -65
  43. package/dist/server-BeNADlCI.js.map +1 -0
  44. package/dist/{server-DgCrSuhB.d.ts → server-DjZZa1wr.d.ts} +9 -3
  45. package/dist/{server-DgCrSuhB.d.ts.map → server-DjZZa1wr.d.ts.map} +1 -1
  46. package/dist/server.d.ts +3 -3
  47. package/dist/server.js +2 -2
  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 +15 -2
  80. package/docs/reference/01-api.mdx +124 -17
  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 +253 -96
  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.ts +160 -22
  100. package/src/session-socket.ts +25 -7
  101. package/src/sse.ts +17 -0
  102. package/src/store-polling.ts +32 -12
  103. package/src/store-postgres.ts +292 -75
  104. package/src/store-presence-polling.ts +125 -0
  105. package/src/store-redis-core.ts +37 -30
  106. package/src/store-redis-http.ts +51 -45
  107. package/src/store-redis-notify.ts +464 -0
  108. package/src/store-redis.ts +9 -364
  109. package/src/store.ts +3 -4
  110. package/src/stream-activity.ts +32 -0
  111. package/src/wire.ts +39 -15
  112. package/dist/actor-shared-USo5MyuF.d.ts +0 -136
  113. package/dist/actor-shared-USo5MyuF.d.ts.map +0 -1
  114. package/dist/actor.d.ts.map +0 -1
  115. package/dist/client.d.ts.map +0 -1
  116. package/dist/client.js.map +0 -1
  117. package/dist/index.d.ts.map +0 -1
  118. package/dist/server-DlLyvaSH.js.map +0 -1
  119. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  120. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  121. 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
+ }
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,
@@ -300,6 +301,17 @@ export type HandlerEntry<
300
301
  handler: Handler<D, K, P>
301
302
  }
302
303
 
304
+ type HandlerFunction = (ctx: never) => Promise<unknown>
305
+
306
+ export function handler<
307
+ H extends HandlerFunction | { handler: HandlerFunction },
308
+ >(entry: H): H extends HandlerFunction ? { handler: H } : H
309
+ export function handler(
310
+ entry: HandlerFunction | { handler: HandlerFunction },
311
+ ): { handler: HandlerFunction } {
312
+ return typeof entry === 'function' ? { handler: entry } : entry
313
+ }
314
+
303
315
  export type ServerOptions<
304
316
  D extends EventDefs,
305
317
  P extends PresenceDefs = Record<never, never>,
@@ -614,8 +626,8 @@ export function createServer<
614
626
  `contract '${name}' has no event type '${type}' — cannot register a handler for it`,
615
627
  )
616
628
  }
617
- const handler = typeof entry === 'function' ? entry : entry?.handler
618
- if (typeof handler !== 'function') {
629
+ const callback = typeof entry === 'function' ? entry : entry?.handler
630
+ if (typeof callback !== 'function') {
619
631
  throw new TypeError(`handler for '${type}' must be a function`)
620
632
  }
621
633
  let abortOn: AbortMatcher | null = null
@@ -659,7 +671,7 @@ export function createServer<
659
671
  )
660
672
  }
661
673
  handlers.set(type, {
662
- handler: handler as Handler<D>,
674
+ handler: callback as Handler<D>,
663
675
  abortOn,
664
676
  lane: lane as Lane<D> | null,
665
677
  })
@@ -1377,12 +1389,59 @@ export function createServer<
1377
1389
  id: string,
1378
1390
  startAfter: number,
1379
1391
  ): AsyncIterable<ContractEvent<D>> => {
1380
- const store = resolveStore()
1392
+ let closed = false
1393
+ let inner: AsyncIterator<Event> | undefined
1394
+ let stopActivity: (() => void) | undefined
1395
+ const activity = createStreamActivity(() => {
1396
+ if (inner && !closed) streamActivities.get(inner)?.activate()
1397
+ })
1398
+ let closing: Promise<void> | undefined
1399
+ const close = (): Promise<void> => {
1400
+ closed = true
1401
+ closing ??= (async () => {
1402
+ stopActivity?.()
1403
+ await inner?.return?.()
1404
+ })()
1405
+ return closing
1406
+ }
1381
1407
  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)
1408
+ const store = await resolveStore()
1409
+ if (closed) return
1410
+ inner = store.stream(nsId(id), { startAfter })[Symbol.asyncIterator]()
1411
+ const sourceActivity = streamActivities.get(inner)
1412
+ if (sourceActivity) {
1413
+ stopActivity = sourceActivity.subscribe(activity.setIdle)
1414
+ activity.setIdle(sourceActivity.idle)
1415
+ }
1416
+ try {
1417
+ for (;;) {
1418
+ if (closed) return
1419
+ const result = await inner.next()
1420
+ if (closed || result.done) return
1421
+ yield toSessionEvent(id, result.value)
1422
+ }
1423
+ } finally {
1424
+ await close()
1425
+ }
1426
+ }
1427
+ const iterator = outer()
1428
+ return {
1429
+ [Symbol.asyncIterator]() {
1430
+ const stream = {
1431
+ next: () => iterator.next(),
1432
+ async return() {
1433
+ await close()
1434
+ return iterator.return(undefined)
1435
+ },
1436
+ async throw(error: unknown) {
1437
+ await close()
1438
+ return iterator.throw(error)
1439
+ },
1440
+ }
1441
+ streamActivities.set(stream, activity)
1442
+ return stream
1443
+ },
1384
1444
  }
1385
- return outer()
1386
1445
  }
1387
1446
 
1388
1447
  // ── presence ──────────────────────────────────────────────
@@ -1505,10 +1564,47 @@ export function createServer<
1505
1564
  startAfter: number,
1506
1565
  ): AsyncIterable<ContractEvent<D> | PresencePatch | PresenceSnapshot> => {
1507
1566
  const ns = nsId(id)
1567
+ let closed = false
1568
+ let events: AsyncIterator<Event> | undefined
1569
+ let unsubscribe: (() => void) | undefined
1570
+ let sleeper: Sleeper | null = null
1571
+ let wakeQueue: (() => void) | null = null
1572
+ let presenceInterval = Math.min(
1573
+ POLL_TIMINGS.activeFloorMs,
1574
+ POLL_TIMINGS.idleCeilingMs,
1575
+ )
1576
+ let presenceActivityVersion = 0
1577
+ let stopActivity: (() => void) | undefined
1578
+ const activity = createStreamActivity(() => {
1579
+ if (closed) return
1580
+ presenceActivityVersion += 1
1581
+ presenceInterval = Math.min(
1582
+ POLL_TIMINGS.activeFloorMs,
1583
+ POLL_TIMINGS.idleCeilingMs,
1584
+ )
1585
+ sleeper?.cancel()
1586
+ if (events) streamActivities.get(events)?.activate()
1587
+ })
1588
+ let closing: Promise<void> | undefined
1589
+ const close = (): Promise<void> => {
1590
+ closed = true
1591
+ sleeper?.cancel()
1592
+ wakeQueue?.()
1593
+ closing ??= (async () => {
1594
+ try {
1595
+ stopActivity?.()
1596
+ unsubscribe?.()
1597
+ } finally {
1598
+ await events?.return?.()
1599
+ }
1600
+ })()
1601
+ return closing
1602
+ }
1508
1603
  const outer = async function* (): AsyncGenerator<
1509
1604
  ContractEvent<D> | PresencePatch | PresenceSnapshot
1510
1605
  > {
1511
1606
  const store = await resolveStore()
1607
+ if (closed) return
1512
1608
  const api = requirePresence(store)
1513
1609
 
1514
1610
  const readRows = async (): Promise<PresenceRow[]> => {
@@ -1614,8 +1710,9 @@ export function createServer<
1614
1710
  string,
1615
1711
  Map<string, { value: unknown; seen: number; at: Date }>
1616
1712
  >()
1617
- let wakeQueue: (() => void) | null = null
1618
1713
  const enqueue = (patch: PresencePatch): void => {
1714
+ if (closed) return
1715
+ activity.activate()
1619
1716
  for (const [field, value] of Object.entries(patch.values)) {
1620
1717
  if (value === undefined) continue
1621
1718
  let fields = queue.get(patch.participant)
@@ -1650,17 +1747,22 @@ export function createServer<
1650
1747
  }
1651
1748
  return null
1652
1749
  }
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
1750
  let pendingEvent: Promise<IteratorResult<Event>> | null = null
1659
1751
  try {
1752
+ // Subscribe before the snapshot read: a patch landing between
1753
+ // the two is painted twice, never lost — repaints are idempotent.
1754
+ unsubscribe = api.subscribe?.(ns, enqueue)
1755
+ events = store.stream(ns, { startAfter })[Symbol.asyncIterator]()
1756
+ const sourceActivity = streamActivities.get(events)
1757
+ if (sourceActivity) {
1758
+ stopActivity = sourceActivity.subscribe(activity.setIdle)
1759
+ activity.setIdle(sourceActivity.idle)
1760
+ }
1660
1761
  // Exactly one snapshot before any live item: the pruned map,
1661
1762
  // per-field stamps exact — a reconnecting client replaces its
1662
1763
  // foreign entries wholesale from it.
1663
1764
  const snapshotRows = await readRows()
1765
+ if (closed) return
1664
1766
  for (const row of snapshotRows) {
1665
1767
  let fields = painted.get(row.participant)
1666
1768
  if (!fields) {
@@ -1676,9 +1778,11 @@ export function createServer<
1676
1778
  yield { snapshot: foldPresenceRows(snapshotRows) }
1677
1779
 
1678
1780
  for (;;) {
1781
+ if (closed) return
1679
1782
  for (;;) {
1680
1783
  const patch = dequeue()
1681
1784
  if (!patch) break
1785
+ if (closed) return
1682
1786
  paint(patch)
1683
1787
  yield patch
1684
1788
  }
@@ -1693,35 +1797,69 @@ export function createServer<
1693
1797
  arrival.then(() => 'patch' as const),
1694
1798
  ])
1695
1799
  wakeQueue = null
1800
+ if (closed) return
1696
1801
  if (woke === 'patch') continue
1697
1802
  } else {
1698
- const sleeper = defaultSleep(POLL_TIMINGS.idleCeilingMs)
1803
+ sleeper = defaultSleep(presenceInterval)
1699
1804
  woke = await Promise.race([
1700
1805
  pendingEvent.then(() => 'event' as const),
1701
1806
  sleeper.promise.then(() => 'tick' as const),
1702
1807
  ])
1703
1808
  sleeper.cancel()
1809
+ sleeper = null
1704
1810
  }
1811
+ if (closed) return
1705
1812
  if (woke === 'event') {
1706
1813
  const result = await pendingEvent
1707
1814
  pendingEvent = null
1708
- if (result.done) return
1815
+ if (closed || result.done) return
1816
+ activity.activate()
1709
1817
  yield toSessionEvent(id, result.value)
1710
1818
  }
1711
- if (!unsubscribe) {
1712
- for (const patch of diffRows(await readRows())) yield patch
1819
+ if (!closed && !unsubscribe) {
1820
+ const version = presenceActivityVersion
1821
+ const rows = await readRows()
1822
+ if (closed) return
1823
+ const patches = diffRows(rows)
1824
+ if (patches.length > 0) activity.activate()
1825
+ else if (version === presenceActivityVersion) {
1826
+ presenceInterval = Math.min(
1827
+ presenceInterval * 2,
1828
+ POLL_TIMINGS.idleCeilingMs,
1829
+ )
1830
+ }
1831
+ for (const patch of patches) {
1832
+ if (closed) return
1833
+ yield patch
1834
+ }
1713
1835
  }
1714
1836
  }
1715
1837
  } finally {
1716
- wakeQueue = null
1717
- unsubscribe?.()
1718
1838
  // The consumer may close while an event read is in flight; its
1719
1839
  // settlement is no longer anyone's business.
1720
1840
  pendingEvent?.catch(() => {})
1721
- await events.return?.()
1841
+ await close()
1842
+ wakeQueue = null
1722
1843
  }
1723
1844
  }
1724
- return outer()
1845
+ const iterator = outer()
1846
+ return {
1847
+ [Symbol.asyncIterator]() {
1848
+ const stream = {
1849
+ next: () => iterator.next(),
1850
+ async return() {
1851
+ await close()
1852
+ return iterator.return(undefined)
1853
+ },
1854
+ async throw(error: unknown) {
1855
+ await close()
1856
+ return iterator.throw(error)
1857
+ },
1858
+ }
1859
+ streamActivities.set(stream, activity)
1860
+ return stream
1861
+ },
1862
+ }
1725
1863
  }
1726
1864
 
1727
1865
  const makeSession = <Append>(