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
|
@@ -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-fetch.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
1383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1830
|
+
await close()
|
|
1831
|
+
wakeQueue = null
|
|
1722
1832
|
}
|
|
1723
1833
|
}
|
|
1724
|
-
|
|
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>(
|