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.
- package/CHANGELOG.md +53 -0
- package/dist/actor-D_54lz_1.d.ts +310 -0
- package/dist/actor-D_54lz_1.d.ts.map +1 -0
- package/dist/actor-client.d.ts +13 -4
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +63 -7
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -4
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +16 -2
- package/dist/actor-react.js.map +1 -1
- package/dist/actor.d.ts +2 -176
- package/dist/actor.js +13 -2
- package/dist/actor.js.map +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/client-Bf6uSEAk.js +1342 -0
- package/dist/client-Bf6uSEAk.js.map +1 -0
- package/dist/client-P_NNNRM-.d.ts +243 -0
- package/dist/client-P_NNNRM-.d.ts.map +1 -0
- package/dist/client.d.ts +2 -202
- package/dist/client.js +2 -1026
- package/dist/errors-DCk6ch5n.js.map +1 -1
- package/dist/errors-DvhSXnxk.d.ts +28 -0
- package/dist/errors-DvhSXnxk.d.ts.map +1 -0
- package/dist/index.d.ts +3 -35
- package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
- package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -2
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
- package/dist/server-DwPrMqHB.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
- package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +1 -1
- package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
- package/dist/store-polling-CmxUbV93.js.map +1 -0
- package/dist/store-postgres.d.ts +6 -4
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +701 -53
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-C7-XZyW9.js +94 -0
- package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
- package/dist/store-redis-http.d.ts +2 -2
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +184 -38
- package/dist/store-redis-http.js.map +1 -1
- package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
- package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
- package/dist/store-redis.d.ts +5 -13
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +27 -271
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +1 -1
- package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
- package/dist/wire-BO5wWCb1.js.map +1 -0
- package/docs/actors/04-routes.mdx +40 -8
- package/docs/guides/03-react.mdx +52 -10
- package/docs/guides/05-production.mdx +54 -5
- package/docs/guides/09-presence.mdx +12 -7
- package/docs/guides/10-transports.mdx +85 -22
- package/docs/reference/01-api.mdx +90 -28
- package/docs/reference/02-errors.mdx +45 -11
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/actor-client.ts +114 -14
- package/src/actor-react.ts +18 -3
- package/src/actor.ts +16 -1
- package/src/client-errors.ts +51 -0
- package/src/client.ts +577 -175
- package/src/errors.ts +4 -9
- package/src/index.ts +1 -1
- package/src/internal.ts +1 -1
- package/src/postgres-notification-scope.ts +134 -0
- package/src/postgres-notifications.ts +244 -0
- package/src/postgres-pool.ts +65 -0
- package/src/postgres-resources.ts +185 -0
- package/src/presence-recovery.ts +120 -0
- package/src/react.ts +3 -0
- package/src/redis-http-subscriptions.ts +199 -0
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +146 -19
- package/src/session-socket.ts +241 -88
- package/src/sse.ts +17 -0
- package/src/store-polling.ts +32 -12
- package/src/store-postgres.ts +292 -75
- package/src/store-presence-polling.ts +125 -0
- package/src/store-redis-core.ts +37 -30
- package/src/store-redis-http.ts +51 -45
- package/src/store-redis-notify.ts +464 -0
- package/src/store-redis.ts +9 -364
- package/src/store.ts +3 -4
- package/src/stream-activity.ts +32 -0
- package/src/wire.ts +39 -15
- package/dist/actor-shared-BACubf4x.d.ts +0 -136
- package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
- package/dist/actor.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server-CBET-jSz.js.map +0 -1
- package/dist/server-CKY3_lbw.d.ts.map +0 -1
- package/dist/store-polling-6DW7F1DT.js.map +0 -1
- package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
- package/dist/wire--yji6mO3.js.map +0 -1
package/src/session-socket.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The A2 socket protocol's server half (specs/a2-api.md §13): the
|
|
3
3
|
* per-socket shell — teardown, backpressure, heartbeat, the malformed
|
|
4
|
-
* budget, the in-flight bound
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* platform hands over) is re-exported as public surface.
|
|
4
|
+
* budget, and the in-flight bound. `sessionSocket` binds one session to
|
|
5
|
+
* one socket; `sessionsSocket` is the opt-in multiplexed handler. Only
|
|
6
|
+
* `A2Socket`, the structural socket the platform hands over, is public.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { A2Error } from './errors.ts'
|
|
11
10
|
import { SOCKET_TIMINGS, STREAM_TIMINGS } from './internal.ts'
|
|
11
|
+
import { streamActivities } from './stream-activity.ts'
|
|
12
12
|
import type { Event } from './store.ts'
|
|
13
13
|
import type { EventDefs, PresencePatch, PresenceSnapshot } from './contract.ts'
|
|
14
14
|
import type { ParsedPushEvent, ParsedPushPresence } from './push-envelope.ts'
|
|
@@ -256,17 +256,214 @@ export type SessionsSocketOptions = {
|
|
|
256
256
|
): A2Error | null | Promise<A2Error | null>
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
export type SessionSocketOptions = SessionsSocketOptions & {
|
|
260
|
+
startAfter?: number
|
|
261
|
+
}
|
|
262
|
+
|
|
259
263
|
type SocketSubscription = {
|
|
260
264
|
target: SocketSession
|
|
261
265
|
iterator: AsyncIterator<Event | PresencePatch | PresenceSnapshot>
|
|
262
266
|
stopped: boolean
|
|
263
267
|
}
|
|
264
268
|
|
|
269
|
+
const socketSubscription = (
|
|
270
|
+
target: SocketSession,
|
|
271
|
+
startAfter: number,
|
|
272
|
+
presence: boolean,
|
|
273
|
+
): SocketSubscription => ({
|
|
274
|
+
target,
|
|
275
|
+
iterator: (presence
|
|
276
|
+
? target.stream({ startAfter, presence: true })
|
|
277
|
+
: target.stream({ startAfter }))[Symbol.asyncIterator](),
|
|
278
|
+
stopped: false,
|
|
279
|
+
})
|
|
280
|
+
|
|
265
281
|
const stopSubscription = (subscription: SocketSubscription): void => {
|
|
266
282
|
subscription.stopped = true
|
|
267
283
|
void subscription.iterator.return?.()?.catch(() => {})
|
|
268
284
|
}
|
|
269
285
|
|
|
286
|
+
const pumpSubscription = async (
|
|
287
|
+
shell: SocketShell,
|
|
288
|
+
subscription: SocketSubscription,
|
|
289
|
+
frameFor: (item: Event | PresencePatch | PresenceSnapshot) => string,
|
|
290
|
+
): Promise<void> => {
|
|
291
|
+
for (;;) {
|
|
292
|
+
while (
|
|
293
|
+
!shell.torn() &&
|
|
294
|
+
!subscription.stopped &&
|
|
295
|
+
shell.buffered() > SOCKET_TIMINGS.highWaterMarkBytes
|
|
296
|
+
) {
|
|
297
|
+
// oxlint-disable-next-line no-await-in-loop -- backpressure park
|
|
298
|
+
await new Promise((wake) => setTimeout(wake, SOCKET_TIMINGS.resumePollMs))
|
|
299
|
+
}
|
|
300
|
+
if (shell.torn() || subscription.stopped) return
|
|
301
|
+
// oxlint-disable-next-line no-await-in-loop -- stream pump
|
|
302
|
+
const { value, done } = await subscription.iterator.next()
|
|
303
|
+
if (shell.torn() || subscription.stopped || done) return
|
|
304
|
+
shell.send(frameFor(value))
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const handleSocketPush = async (
|
|
309
|
+
shell: SocketShell,
|
|
310
|
+
target: SocketSession,
|
|
311
|
+
sessionId: string,
|
|
312
|
+
frame: Record<string, unknown>,
|
|
313
|
+
req: number,
|
|
314
|
+
ackSessionId: string | undefined,
|
|
315
|
+
gatePush: SessionsSocketOptions['gatePush'],
|
|
316
|
+
activate: () => void,
|
|
317
|
+
): Promise<void> => {
|
|
318
|
+
try {
|
|
319
|
+
const events = parsePushEvents(frame['events'])
|
|
320
|
+
const denial = (await gatePush?.(sessionId, events, undefined)) ?? null
|
|
321
|
+
if (denial !== null) {
|
|
322
|
+
shell.send(socketErrorAckFor(req, denial, ackSessionId))
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
activate()
|
|
326
|
+
// oxlint-disable-next-line a2/await-handler-append -- this transport append is awaited before its ack
|
|
327
|
+
const appended = await target.append(...events)
|
|
328
|
+
activate()
|
|
329
|
+
shell.send(socketAckFor(req, appended, ackSessionId))
|
|
330
|
+
} catch (error) {
|
|
331
|
+
shell.send(socketErrorAckFor(req, asA2Error(error), ackSessionId))
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const applySocketPresence = async (
|
|
336
|
+
shell: SocketShell,
|
|
337
|
+
target: SocketSession,
|
|
338
|
+
sessionId: string,
|
|
339
|
+
frame: Record<string, unknown>,
|
|
340
|
+
gatePush: SessionsSocketOptions['gatePush'],
|
|
341
|
+
): Promise<void> => {
|
|
342
|
+
if (typeof target.setPresence !== 'function') {
|
|
343
|
+
shell.malformedFrame('presence frame on a presence-less session')
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
let patch: ParsedPushPresence | undefined
|
|
347
|
+
try {
|
|
348
|
+
patch = parsePresenceSibling(frame)
|
|
349
|
+
} catch {
|
|
350
|
+
shell.malformedFrame('invalid presence frame')
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
if (!patch) return
|
|
354
|
+
try {
|
|
355
|
+
const denial = (await gatePush?.(sessionId, [], patch)) ?? null
|
|
356
|
+
if (denial !== null) return
|
|
357
|
+
await target.setPresence(patch)
|
|
358
|
+
} catch (error) {
|
|
359
|
+
const failure = asA2Error(error)
|
|
360
|
+
if (
|
|
361
|
+
failure.code === 'INVALID_PAYLOAD' ||
|
|
362
|
+
failure.code === 'UNKNOWN_PRESENCE_FIELD'
|
|
363
|
+
) {
|
|
364
|
+
shell.malformedFrame('invalid presence frame')
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
shell.shutdown(1011, 'internal error')
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function sessionSocket<D extends EventDefs>(
|
|
372
|
+
sessionId: string,
|
|
373
|
+
session: Pick<Session<D>, 'stream'>,
|
|
374
|
+
socket: A2Socket,
|
|
375
|
+
options?: SessionSocketOptions,
|
|
376
|
+
): void {
|
|
377
|
+
const target = session as unknown as SocketSession
|
|
378
|
+
const subscription = socketSubscription(
|
|
379
|
+
target,
|
|
380
|
+
options?.startAfter ?? 0,
|
|
381
|
+
options?.presence === true,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
const handleFrame = (frame: Record<string, unknown>): void => {
|
|
385
|
+
switch (frame['kind']) {
|
|
386
|
+
case 'push': {
|
|
387
|
+
const req = frame['req']
|
|
388
|
+
if (typeof req !== 'number') {
|
|
389
|
+
shell.malformedFrame('push frame without a numeric req')
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
if (frame['sessionId'] !== undefined) {
|
|
393
|
+
shell.send(
|
|
394
|
+
socketErrorAckFor(
|
|
395
|
+
req,
|
|
396
|
+
new A2Error(
|
|
397
|
+
'INVALID_PAYLOAD',
|
|
398
|
+
'sessionId is not allowed on a single-session socket',
|
|
399
|
+
),
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
if (shell.overCap()) {
|
|
405
|
+
shell.send(
|
|
406
|
+
socketErrorAckFor(
|
|
407
|
+
req,
|
|
408
|
+
new A2Error(
|
|
409
|
+
'STORE_UNAVAILABLE',
|
|
410
|
+
'push shed: too many in flight on this socket',
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
shell.track(
|
|
417
|
+
handleSocketPush(
|
|
418
|
+
shell,
|
|
419
|
+
target,
|
|
420
|
+
sessionId,
|
|
421
|
+
frame,
|
|
422
|
+
req,
|
|
423
|
+
undefined,
|
|
424
|
+
options?.gatePush,
|
|
425
|
+
() => streamActivities.get(subscription.iterator)?.activate(),
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
case 'presence':
|
|
431
|
+
if (frame['sessionId'] !== undefined) {
|
|
432
|
+
shell.malformedFrame(
|
|
433
|
+
'sessionId is not allowed on a single-session socket',
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
if (!shell.overCap()) {
|
|
438
|
+
shell.track(
|
|
439
|
+
applySocketPresence(
|
|
440
|
+
shell,
|
|
441
|
+
target,
|
|
442
|
+
sessionId,
|
|
443
|
+
frame,
|
|
444
|
+
options?.gatePush,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
return
|
|
449
|
+
default:
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const shell = socketShell(
|
|
455
|
+
socket,
|
|
456
|
+
() => stopSubscription(subscription),
|
|
457
|
+
handleFrame,
|
|
458
|
+
options?.deadline,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
void pumpSubscription(shell, subscription, socketFrameFor).then(
|
|
462
|
+
() => shell.shutdown(1000),
|
|
463
|
+
() => shell.shutdown(1011, 'stream failed'),
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
270
467
|
const parseSubscribeEntries = (
|
|
271
468
|
value: unknown,
|
|
272
469
|
): Array<{ id: string; index: number }> | null => {
|
|
@@ -308,45 +505,21 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
308
505
|
if (subscriptions.get(id) === subscription) subscriptions.delete(id)
|
|
309
506
|
}
|
|
310
507
|
|
|
311
|
-
const pump = async (
|
|
312
|
-
id: string,
|
|
313
|
-
subscription: SocketSubscription,
|
|
314
|
-
): Promise<void> => {
|
|
315
|
-
for (;;) {
|
|
316
|
-
for (;;) {
|
|
317
|
-
if (
|
|
318
|
-
shell.torn() ||
|
|
319
|
-
subscription.stopped ||
|
|
320
|
-
shell.buffered() <= SOCKET_TIMINGS.highWaterMarkBytes
|
|
321
|
-
)
|
|
322
|
-
break
|
|
323
|
-
// oxlint-disable-next-line no-await-in-loop -- backpressure park
|
|
324
|
-
await new Promise((wake) =>
|
|
325
|
-
setTimeout(wake, SOCKET_TIMINGS.resumePollMs),
|
|
326
|
-
)
|
|
327
|
-
}
|
|
328
|
-
if (shell.torn() || subscription.stopped) return
|
|
329
|
-
// oxlint-disable-next-line no-await-in-loop -- stream pump
|
|
330
|
-
const { value, done } = await subscription.iterator.next()
|
|
331
|
-
if (shell.torn() || subscription.stopped) return
|
|
332
|
-
if (done) return
|
|
333
|
-
shell.send(socketFrameFor(value, id))
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
508
|
const runSubscription = async (
|
|
338
509
|
id: string,
|
|
339
510
|
subscription: SocketSubscription,
|
|
340
511
|
): Promise<void> => {
|
|
341
512
|
try {
|
|
342
|
-
await
|
|
513
|
+
await pumpSubscription(shell, subscription, (item) =>
|
|
514
|
+
socketFrameFor(item, id),
|
|
515
|
+
)
|
|
343
516
|
if (shell.torn() || subscription.stopped) return
|
|
344
517
|
retire(id, subscription)
|
|
345
518
|
shell.send(socketUnsubscribedFor(id))
|
|
346
|
-
} catch {
|
|
519
|
+
} catch (error) {
|
|
347
520
|
if (shell.torn() || subscription.stopped) return
|
|
348
521
|
retire(id, subscription)
|
|
349
|
-
shell.send(socketUnsubscribedFor(id, 'stream failed'))
|
|
522
|
+
shell.send(socketUnsubscribedFor(id, 'stream failed', asA2Error(error)))
|
|
350
523
|
}
|
|
351
524
|
}
|
|
352
525
|
|
|
@@ -354,33 +527,33 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
354
527
|
id: string,
|
|
355
528
|
index: number,
|
|
356
529
|
): Promise<void> => {
|
|
530
|
+
const existing = subscriptions.get(id)
|
|
531
|
+
if (existing) {
|
|
532
|
+
stopSubscription(existing)
|
|
533
|
+
subscriptions.delete(id)
|
|
534
|
+
}
|
|
357
535
|
let session: Pick<Session<D>, 'stream'> | null
|
|
358
536
|
try {
|
|
359
537
|
session = await resolve(id, index)
|
|
360
|
-
} catch {
|
|
538
|
+
} catch (error) {
|
|
361
539
|
if (!shell.torn()) {
|
|
362
|
-
shell.send(socketUnsubscribedFor(id, 'stream failed'))
|
|
540
|
+
shell.send(socketUnsubscribedFor(id, 'stream failed', asA2Error(error)))
|
|
363
541
|
}
|
|
364
542
|
return
|
|
365
543
|
}
|
|
366
544
|
if (shell.torn()) return
|
|
367
545
|
if (session === null) {
|
|
368
|
-
shell.send(
|
|
546
|
+
shell.send(
|
|
547
|
+
socketUnsubscribedFor(
|
|
548
|
+
id,
|
|
549
|
+
'subscribe rejected',
|
|
550
|
+
new A2Error('FORBIDDEN', 'the subscription is not authorized'),
|
|
551
|
+
),
|
|
552
|
+
)
|
|
369
553
|
return
|
|
370
554
|
}
|
|
371
555
|
const target = session as unknown as SocketSession
|
|
372
|
-
const
|
|
373
|
-
if (existing) stopSubscription(existing)
|
|
374
|
-
const iterator = (
|
|
375
|
-
presence
|
|
376
|
-
? target.stream({ startAfter: index, presence: true })
|
|
377
|
-
: target.stream({ startAfter: index })
|
|
378
|
-
)[Symbol.asyncIterator]()
|
|
379
|
-
const subscription: SocketSubscription = {
|
|
380
|
-
target,
|
|
381
|
-
iterator,
|
|
382
|
-
stopped: false,
|
|
383
|
-
}
|
|
556
|
+
const subscription = socketSubscription(target, index, presence)
|
|
384
557
|
subscriptions.set(id, subscription)
|
|
385
558
|
shell.send(socketSubscribedFor(id))
|
|
386
559
|
void runSubscription(id, subscription)
|
|
@@ -415,19 +588,19 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
415
588
|
)
|
|
416
589
|
return
|
|
417
590
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
591
|
+
await handleSocketPush(
|
|
592
|
+
shell,
|
|
593
|
+
subscription.target,
|
|
594
|
+
sessionId,
|
|
595
|
+
frame,
|
|
596
|
+
req,
|
|
597
|
+
sessionId,
|
|
598
|
+
options?.gatePush,
|
|
599
|
+
() => {
|
|
600
|
+
const current = subscriptions.get(sessionId)
|
|
601
|
+
if (current) streamActivities.get(current.iterator)?.activate()
|
|
602
|
+
},
|
|
603
|
+
)
|
|
431
604
|
}
|
|
432
605
|
|
|
433
606
|
const handlePresence = async (
|
|
@@ -437,33 +610,13 @@ export function sessionsSocket<D extends EventDefs>(
|
|
|
437
610
|
const subscription = subscriptions.get(sessionId)
|
|
438
611
|
// An unsubscribe race, not malformedness — the plane repaints.
|
|
439
612
|
if (subscription === undefined) return
|
|
440
|
-
|
|
441
|
-
shell
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
} catch {
|
|
448
|
-
shell.malformedFrame('invalid presence frame')
|
|
449
|
-
return
|
|
450
|
-
}
|
|
451
|
-
if (!patch) return
|
|
452
|
-
try {
|
|
453
|
-
const denial = (await options?.gatePush?.(sessionId, [], patch)) ?? null
|
|
454
|
-
if (denial !== null) return
|
|
455
|
-
await subscription.target.setPresence(patch)
|
|
456
|
-
} catch (error) {
|
|
457
|
-
const failure = asA2Error(error)
|
|
458
|
-
if (
|
|
459
|
-
failure.code === 'INVALID_PAYLOAD' ||
|
|
460
|
-
failure.code === 'UNKNOWN_PRESENCE_FIELD'
|
|
461
|
-
) {
|
|
462
|
-
shell.malformedFrame('invalid presence frame')
|
|
463
|
-
return
|
|
464
|
-
}
|
|
465
|
-
shell.shutdown(1011, 'internal error')
|
|
466
|
-
}
|
|
613
|
+
await applySocketPresence(
|
|
614
|
+
shell,
|
|
615
|
+
subscription.target,
|
|
616
|
+
sessionId,
|
|
617
|
+
frame,
|
|
618
|
+
options?.gatePush,
|
|
619
|
+
)
|
|
467
620
|
}
|
|
468
621
|
|
|
469
622
|
const handleFrame = (frame: Record<string, unknown>): void => {
|
package/src/sse.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { STREAM_TIMINGS } from './internal.ts'
|
|
|
10
10
|
import type { Event } from './store.ts'
|
|
11
11
|
import type { PresencePatch, PresenceSnapshot } from './contract.ts'
|
|
12
12
|
import { invocationDeadlineMs } from './platform.ts'
|
|
13
|
+
import { streamActivities } from './stream-activity.ts'
|
|
13
14
|
import {
|
|
14
15
|
eventToWire,
|
|
15
16
|
presencePatchToWire,
|
|
@@ -41,7 +42,10 @@ export function sseResponse(
|
|
|
41
42
|
let deadlineTimer: ReturnType<typeof setTimeout> | undefined
|
|
42
43
|
let closed = false
|
|
43
44
|
let iteratorReturned = false
|
|
45
|
+
let stopActivity: (() => void) | undefined
|
|
44
46
|
const stopTimers = (): void => {
|
|
47
|
+
stopActivity?.()
|
|
48
|
+
stopActivity = undefined
|
|
45
49
|
if (heartbeat !== undefined) clearInterval(heartbeat)
|
|
46
50
|
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer)
|
|
47
51
|
heartbeat = undefined
|
|
@@ -61,6 +65,19 @@ export function sseResponse(
|
|
|
61
65
|
// early proof this is a stream. Comment frames are invisible to
|
|
62
66
|
// SSE consumers.
|
|
63
67
|
controller.enqueue(encoder.encode(': connected\n\n'))
|
|
68
|
+
const activity = streamActivities.get(iterator)
|
|
69
|
+
if (activity) {
|
|
70
|
+
const announce = (idle: boolean): void => {
|
|
71
|
+
if (closed) return
|
|
72
|
+
controller.enqueue(
|
|
73
|
+
encoder.encode(
|
|
74
|
+
`event: a2-activity\ndata: ${idle ? 'idle' : 'active'}\n\n`,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
stopActivity = activity.subscribe(announce)
|
|
79
|
+
if (activity.idle) announce(true)
|
|
80
|
+
}
|
|
64
81
|
// Heartbeat: comment frames are ignored by SSE parsers but are
|
|
65
82
|
// bytes on the wire — liveness proof for the client's watchdog
|
|
66
83
|
// and for idle-timeout-happy intermediaries.
|
package/src/store-polling.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { POLL_TIMINGS } from './internal.ts'
|
|
17
|
-
import type {
|
|
17
|
+
import type { Event } from './store.ts'
|
|
18
|
+
import { createStreamActivity, streamActivities } from './stream-activity.ts'
|
|
18
19
|
|
|
19
20
|
/** One cancellable sleep — the injection seam for cadence tests. */
|
|
20
21
|
export type Sleeper = { promise: Promise<void>; cancel: () => void }
|
|
@@ -56,47 +57,66 @@ export type PollingStreamOptions = {
|
|
|
56
57
|
* SSE client), waking any pending sleep immediately.
|
|
57
58
|
*/
|
|
58
59
|
export function pollingStream(
|
|
59
|
-
readAfter: (afterIndex: number) =>
|
|
60
|
+
readAfter: (afterIndex: number) => Event[] | Promise<Event[]>,
|
|
60
61
|
options: PollingStreamOptions,
|
|
61
|
-
): AsyncIterable<
|
|
62
|
+
): AsyncIterable<Event> {
|
|
62
63
|
const startAfter = options.startAfter ?? 0
|
|
63
64
|
const sleep = options.sleep ?? defaultSleep
|
|
64
65
|
|
|
65
66
|
return {
|
|
66
|
-
[Symbol.asyncIterator](): AsyncIterator<
|
|
67
|
+
[Symbol.asyncIterator](): AsyncIterator<Event> {
|
|
67
68
|
let last = startAfter
|
|
68
|
-
let buffer:
|
|
69
|
+
let buffer: Event[] = []
|
|
69
70
|
let closed = false
|
|
70
71
|
let pending: Sleeper | null = null
|
|
71
72
|
let interval = floor()
|
|
73
|
+
let activityVersion = 0
|
|
74
|
+
const activity = createStreamActivity(() => {
|
|
75
|
+
if (closed) return
|
|
76
|
+
activityVersion += 1
|
|
77
|
+
interval = floor()
|
|
78
|
+
pending?.cancel()
|
|
79
|
+
})
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
async next(): Promise<IteratorResult<
|
|
81
|
+
const iterator: AsyncIterator<Event> = {
|
|
82
|
+
async next(): Promise<IteratorResult<Event>> {
|
|
75
83
|
for (;;) {
|
|
76
84
|
if (closed) return { value: undefined, done: true }
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
try {
|
|
86
|
+
// oxlint-disable-next-line no-await-in-loop -- poll loop
|
|
87
|
+
if (buffer.length === 0) buffer = await readAfter(last)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (closed) return { value: undefined, done: true }
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
if (closed) return { value: undefined, done: true }
|
|
79
93
|
const row = buffer.shift()
|
|
80
94
|
if (row) {
|
|
81
95
|
// Activity: tighten back to the floor.
|
|
82
|
-
|
|
96
|
+
activity.activate()
|
|
83
97
|
last = row.index
|
|
84
98
|
return { value: row, done: false }
|
|
85
99
|
}
|
|
86
100
|
// Empty poll: wait, then back off toward the idle ceiling.
|
|
101
|
+
activity.setIdle(interval === ceiling())
|
|
102
|
+
const version = activityVersion
|
|
87
103
|
pending = sleep(interval)
|
|
88
104
|
// oxlint-disable-next-line no-await-in-loop -- poll loop
|
|
89
105
|
await pending.promise
|
|
90
106
|
pending = null
|
|
91
|
-
|
|
107
|
+
if (version === activityVersion) {
|
|
108
|
+
interval = Math.min(interval * 2, ceiling())
|
|
109
|
+
}
|
|
92
110
|
}
|
|
93
111
|
},
|
|
94
|
-
async return(): Promise<IteratorResult<
|
|
112
|
+
async return(): Promise<IteratorResult<Event>> {
|
|
95
113
|
closed = true
|
|
96
114
|
pending?.cancel()
|
|
97
115
|
return { value: undefined, done: true }
|
|
98
116
|
},
|
|
99
117
|
}
|
|
118
|
+
streamActivities.set(iterator, activity)
|
|
119
|
+
return iterator
|
|
100
120
|
},
|
|
101
121
|
}
|
|
102
122
|
}
|