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.
- package/CHANGELOG.md +45 -0
- package/dist/actor-DJi3RsNu.d.ts +310 -0
- package/dist/actor-DJi3RsNu.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 -208
- package/dist/client.js +2 -1206
- 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-DlLyvaSH.js → server-BeNADlCI.js} +171 -65
- package/dist/server-BeNADlCI.js.map +1 -0
- package/dist/{server-DgCrSuhB.d.ts → server-DjZZa1wr.d.ts} +9 -3
- package/dist/{server-DgCrSuhB.d.ts.map → server-DjZZa1wr.d.ts.map} +1 -1
- package/dist/server.d.ts +3 -3
- package/dist/server.js +2 -2
- 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 +15 -2
- package/docs/reference/01-api.mdx +124 -17
- 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 +253 -96
- 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.ts +160 -22
- package/src/session-socket.ts +25 -7
- 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-USo5MyuF.d.ts +0 -136
- package/dist/actor-shared-USo5MyuF.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-DlLyvaSH.js.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.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
|
|
618
|
-
if (typeof
|
|
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:
|
|
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
|
-
|
|
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
|
|
1383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1841
|
+
await close()
|
|
1842
|
+
wakeQueue = null
|
|
1722
1843
|
}
|
|
1723
1844
|
}
|
|
1724
|
-
|
|
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>(
|