@kuralle-syrinx/server-workers 2.1.1 → 3.1.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/.dev.vars.example +17 -0
- package/package.json +12 -9
- package/src/live-realtime-session.test.ts +22 -23
- package/src/live-realtime-session.ts +37 -40
- package/src/live-session.test.ts +36 -31
- package/src/live-session.ts +52 -53
- package/src/worker-realtime.ts +16 -68
- package/src/worker-runtime.test.ts +144 -0
- package/src/worker.ts +81 -94
- package/wrangler.jsonc +8 -0
- package/src/alarm-scheduler.test.ts +0 -44
- package/src/alarm-scheduler.ts +0 -67
- package/src/durable-session-store.test.ts +0 -60
- package/src/durable-session-store.ts +0 -148
- package/src/r2-recorder.test.ts +0 -100
- package/src/r2-recorder.ts +0 -218
- package/src/test-storage.ts +0 -80
|
@@ -9,6 +9,7 @@ import { execFile } from "node:child_process";
|
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { Miniflare } from "miniflare";
|
|
11
11
|
import { afterEach, describe, expect, it } from "vitest";
|
|
12
|
+
import { decodeMuLawToPcm16, encodePcm16ToMuLaw } from "@kuralle-syrinx/core/audio";
|
|
12
13
|
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
14
15
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -58,6 +59,7 @@ function newMiniflare(script: string, bindings: Record<string, unknown>): Minifl
|
|
|
58
59
|
compatibilityFlags: ["nodejs_compat"],
|
|
59
60
|
durableObjects: {
|
|
60
61
|
VOICE_CONVERSATIONS: { className: "VoiceConversation", useSQLite: true },
|
|
62
|
+
TWILIO_VOICE_CONVERSATIONS: { className: "TwilioVoiceConversation", useSQLite: true },
|
|
61
63
|
},
|
|
62
64
|
vectorize: {
|
|
63
65
|
VECTORIZE: { dimensions: 1536, metric: "cosine", index_name: "kuralle-university-kb" },
|
|
@@ -163,6 +165,148 @@ describe("VoiceConversation worker runtime", () => {
|
|
|
163
165
|
);
|
|
164
166
|
});
|
|
165
167
|
|
|
168
|
+
describe("TwilioVoiceConversation worker runtime", () => {
|
|
169
|
+
// Deterministic, no keys: the Twilio Media Streams front accepts a WS upgrade at /twilio
|
|
170
|
+
// through withVoice(Agent, { transport: "twilio" }) — the cf-agents telephony front bundles
|
|
171
|
+
// and boots in workerd.
|
|
172
|
+
it("accepts a Twilio Media Streams WebSocket upgrade at /twilio", async () => {
|
|
173
|
+
const script = await buildWorker();
|
|
174
|
+
const mf = newMiniflare(script, {});
|
|
175
|
+
try {
|
|
176
|
+
const response = await mf.dispatchFetch("http://localhost/twilio?sessionId=twilio-boot", {
|
|
177
|
+
headers: { Upgrade: "websocket" },
|
|
178
|
+
});
|
|
179
|
+
expect(response.status).toBe(101);
|
|
180
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
181
|
+
expect(ws).toBeTruthy();
|
|
182
|
+
ws!.accept();
|
|
183
|
+
ws!.close();
|
|
184
|
+
} finally {
|
|
185
|
+
await mf.dispose();
|
|
186
|
+
}
|
|
187
|
+
}, 20_000);
|
|
188
|
+
|
|
189
|
+
// Deterministic: the Twilio Voice webhook returns TwiML that bridges the call to /twilio.
|
|
190
|
+
it("returns <Connect><Stream> TwiML from /incoming-call pointing at /twilio", async () => {
|
|
191
|
+
const script = await buildWorker();
|
|
192
|
+
const mf = newMiniflare(script, {});
|
|
193
|
+
try {
|
|
194
|
+
const res = await mf.dispatchFetch("http://localhost/incoming-call", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
197
|
+
body: "CallSid=CA0123456789abcdef&From=%2B15551234567",
|
|
198
|
+
});
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
expect(res.headers.get("content-type")).toContain("text/xml");
|
|
201
|
+
const twiml = await res.text();
|
|
202
|
+
expect(twiml).toContain("<Connect>");
|
|
203
|
+
expect(twiml).toContain("<Stream");
|
|
204
|
+
// The Twilio CallSid becomes the /twilio sessionId.
|
|
205
|
+
expect(twiml).toContain("/twilio?sessionId=CA0123456789abcdef");
|
|
206
|
+
} finally {
|
|
207
|
+
await mf.dispose();
|
|
208
|
+
}
|
|
209
|
+
}, 20_000);
|
|
210
|
+
|
|
211
|
+
// Live: EMULATE A PSTN CALL — speak the Twilio Media Streams protocol (connected/start/media
|
|
212
|
+
// as base64 μ-law 8 kHz) at /twilio and assert the agent answers with non-silent μ-law media
|
|
213
|
+
// frames on the phone leg. No carrier, no phone number — the protocol is just a WebSocket.
|
|
214
|
+
it.skipIf(!liveTurnEnabled)(
|
|
215
|
+
"answers an emulated Twilio Media Streams call end-to-end in workerd",
|
|
216
|
+
async () => {
|
|
217
|
+
expect(existsSync(TURN_DETECTION_FIXTURE)).toBe(true);
|
|
218
|
+
const pcm16k = readWav16kMono(TURN_DETECTION_FIXTURE);
|
|
219
|
+
const pcm8k = downsampleTo8k(pcm16k);
|
|
220
|
+
const script = await buildWorker();
|
|
221
|
+
const mf = newMiniflare(script, liveEnv);
|
|
222
|
+
try {
|
|
223
|
+
const response = await mf.dispatchFetch("http://localhost/twilio?sessionId=twilio-live", {
|
|
224
|
+
headers: { Upgrade: "websocket" },
|
|
225
|
+
});
|
|
226
|
+
expect(response.status).toBe(101);
|
|
227
|
+
const ws = (response as unknown as Response & { webSocket?: WorkersWebSocket }).webSocket;
|
|
228
|
+
expect(ws).toBeTruthy();
|
|
229
|
+
|
|
230
|
+
let downlinkFrames = 0;
|
|
231
|
+
let downlinkPeak = 0;
|
|
232
|
+
let clearReceived = false;
|
|
233
|
+
ws!.addEventListener("message", (event) => {
|
|
234
|
+
const data = event.data as string | ArrayBuffer;
|
|
235
|
+
if (typeof data !== "string" || !data.startsWith("{")) return;
|
|
236
|
+
const msg = JSON.parse(data) as Record<string, unknown>;
|
|
237
|
+
if (msg.event === "media") {
|
|
238
|
+
downlinkFrames += 1;
|
|
239
|
+
const media = msg.media as { payload?: string } | undefined;
|
|
240
|
+
if (media?.payload) {
|
|
241
|
+
const pcm = decodeMuLawToPcm16(base64ToBytes(media.payload));
|
|
242
|
+
for (const s of pcm) downlinkPeak = Math.max(downlinkPeak, Math.abs(s));
|
|
243
|
+
}
|
|
244
|
+
} else if (msg.event === "clear") {
|
|
245
|
+
clearReceived = true;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
ws!.accept();
|
|
249
|
+
|
|
250
|
+
const streamSid = "MZtwiliolivesmoke";
|
|
251
|
+
ws!.send(JSON.stringify({ event: "connected", protocol: "Call", version: "1.0.0" }));
|
|
252
|
+
ws!.send(JSON.stringify({
|
|
253
|
+
event: "start",
|
|
254
|
+
streamSid,
|
|
255
|
+
start: { streamSid, callSid: "CAtwiliolivesmoke", mediaFormat: { encoding: "audio/x-mulaw", sampleRate: 8000, channels: 1 } },
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
const sendMulaw = (frame: Int16Array): void => {
|
|
259
|
+
ws!.send(JSON.stringify({
|
|
260
|
+
event: "media",
|
|
261
|
+
streamSid,
|
|
262
|
+
media: { payload: bytesToBase64(encodePcm16ToMuLaw(frame)) },
|
|
263
|
+
}));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const frameSamples8k = 160; // 20ms at 8 kHz
|
|
267
|
+
for (let offset = 0; offset < pcm8k.length; offset += frameSamples8k) {
|
|
268
|
+
const frame = new Int16Array(frameSamples8k);
|
|
269
|
+
frame.set(pcm8k.subarray(offset, Math.min(offset + frameSamples8k, pcm8k.length)));
|
|
270
|
+
sendMulaw(frame);
|
|
271
|
+
await sleep(20);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Pad silence until the agent answers (Deepgram endpointing + kuralle + TTS).
|
|
275
|
+
const deadline = Date.now() + 60_000;
|
|
276
|
+
while (downlinkFrames === 0 && Date.now() < deadline) {
|
|
277
|
+
sendMulaw(new Int16Array(frameSamples8k));
|
|
278
|
+
await sleep(20);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
ws!.send(JSON.stringify({ event: "stop", streamSid }));
|
|
282
|
+
ws!.close();
|
|
283
|
+
|
|
284
|
+
// eslint-disable-next-line no-console
|
|
285
|
+
console.log(`[twilio-live] downlink media frames=${downlinkFrames} peak=${downlinkPeak} clear=${clearReceived}`);
|
|
286
|
+
expect(downlinkFrames).toBeGreaterThan(0);
|
|
287
|
+
expect(downlinkPeak).toBeGreaterThan(100); // non-silent answer on the phone leg
|
|
288
|
+
} finally {
|
|
289
|
+
await mf.dispose();
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
120_000,
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
function downsampleTo8k(samples: Int16Array): Int16Array {
|
|
297
|
+
const out = new Int16Array(Math.floor(samples.length / 2));
|
|
298
|
+
for (let i = 0; i < out.length; i += 1) out[i] = samples[i * 2]!;
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function base64ToBytes(value: string): Uint8Array {
|
|
303
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
307
|
+
return Buffer.from(bytes).toString("base64");
|
|
308
|
+
}
|
|
309
|
+
|
|
166
310
|
function firstSessionError(messages: ReadonlyArray<string | ArrayBuffer>): string | null {
|
|
167
311
|
for (const m of messages) {
|
|
168
312
|
if (typeof m !== "string") continue;
|
package/src/worker.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Cloudflare Workers cascaded voice host, built on `withVoice(Agent)` (issue #10 / W1).
|
|
4
|
+
// The Durable Object IS an `agents` SDK Agent: it provides hibernation, the
|
|
5
|
+
// `keepAlive()` lease, `Connection` lifecycle, and SQLite natively — so the prior
|
|
6
|
+
// manual alarm scheduler, durable session store, WebSocket lifecycle, and the 1012
|
|
7
|
+
// eviction-orphan workaround are gone. The brain is the `reasoner` param; the
|
|
8
|
+
// pipeline (Deepgram STT/TTS + kuralle) lives in live-session.ts.
|
|
2
9
|
|
|
10
|
+
import { Agent } from "agents";
|
|
11
|
+
import { withVoice } from "@kuralle-syrinx/cf-agents";
|
|
12
|
+
import { R2EdgeRecorder } from "@kuralle-syrinx/cf-agents/r2-recorder";
|
|
3
13
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import type { WorkersInboundSocketController } from "@kuralle-syrinx/ws/workers";
|
|
9
|
-
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
10
|
-
import { DurableObjectSessionStore } from "./durable-session-store.js";
|
|
11
|
-
import { createLiveVoiceAgentSession, type LiveSessionEnv } from "./live-session.js";
|
|
12
|
-
import { R2EdgeRecorder } from "./r2-recorder.js";
|
|
14
|
+
createLiveReasoner,
|
|
15
|
+
liveCascadedPipeline,
|
|
16
|
+
type LiveSessionEnv,
|
|
17
|
+
} from "./live-session.js";
|
|
13
18
|
|
|
14
19
|
export interface Env extends LiveSessionEnv {
|
|
15
20
|
VOICE_CONVERSATIONS: DurableObjectNamespace;
|
|
21
|
+
TWILIO_VOICE_CONVERSATIONS: DurableObjectNamespace;
|
|
16
22
|
/** Optional: when bound, full call audio is recorded to this bucket. */
|
|
17
23
|
RECORDINGS?: R2Bucket;
|
|
18
24
|
}
|
|
@@ -20,20 +26,83 @@ export interface Env extends LiveSessionEnv {
|
|
|
20
26
|
const INPUT_SAMPLE_RATE_HZ = 16000;
|
|
21
27
|
const OUTPUT_SAMPLE_RATE_HZ = 16000;
|
|
22
28
|
|
|
29
|
+
/** Browser/edge cascaded host (Syrinx JSON+envelope protocol over /ws). */
|
|
30
|
+
export class VoiceConversation extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
|
|
31
|
+
pipeline: liveCascadedPipeline,
|
|
32
|
+
reasoner: (env, ctx) => createLiveReasoner(env, ctx),
|
|
33
|
+
recorder: (env, { sessionId }) =>
|
|
34
|
+
env.RECORDINGS
|
|
35
|
+
? new R2EdgeRecorder({ bucket: env.RECORDINGS, sessionId, startedAtMs: Date.now() })
|
|
36
|
+
: undefined,
|
|
37
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
38
|
+
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
39
|
+
resumeWindowMs: 15_000,
|
|
40
|
+
}) {}
|
|
41
|
+
|
|
42
|
+
/** Telephony cascaded host (Twilio Media Streams μ-law 8 kHz over /twilio). Same pipeline/brain. */
|
|
43
|
+
export class TwilioVoiceConversation extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
|
|
44
|
+
transport: "twilio",
|
|
45
|
+
pipeline: liveCascadedPipeline,
|
|
46
|
+
reasoner: (env, ctx) => createLiveReasoner(env, ctx),
|
|
47
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
48
|
+
resumeWindowMs: 15_000,
|
|
49
|
+
}) {}
|
|
50
|
+
|
|
23
51
|
export default {
|
|
24
52
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
25
53
|
const url = new URL(request.url);
|
|
26
54
|
if (url.pathname === "/health") return new Response("ok");
|
|
27
55
|
if (url.pathname === "/recordings") return await listRecordings(url, env);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
56
|
+
// Twilio Voice webhook: returns TwiML that bridges the inbound PSTN call to this
|
|
57
|
+
// worker's own /twilio Media Streams endpoint over a bidirectional <Stream>. Point a
|
|
58
|
+
// Twilio number's "A call comes in" webhook at https://<host>/incoming-call.
|
|
59
|
+
if (url.pathname === "/incoming-call") return await twilioIncomingCallResponse(request, url);
|
|
60
|
+
// Name-addressed routing: one DO per sessionId (callSid for telephony). The Agent
|
|
61
|
+
// (partyserver) resolves its name from ctx.id.name, so a direct stub.fetch() upgrade
|
|
62
|
+
// is valid for both transports.
|
|
31
63
|
const sessionId = url.searchParams.get("sessionId") ?? crypto.randomUUID();
|
|
64
|
+
if (url.pathname === "/twilio") {
|
|
65
|
+
const id = env.TWILIO_VOICE_CONVERSATIONS.idFromName(sessionId);
|
|
66
|
+
return await env.TWILIO_VOICE_CONVERSATIONS.get(id).fetch(request);
|
|
67
|
+
}
|
|
68
|
+
if (url.pathname !== "/ws") return new Response("not found", { status: 404 });
|
|
32
69
|
const id = env.VOICE_CONVERSATIONS.idFromName(sessionId);
|
|
33
70
|
return await env.VOICE_CONVERSATIONS.get(id).fetch(request);
|
|
34
71
|
},
|
|
35
72
|
};
|
|
36
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Twilio Voice webhook → TwiML connecting the PSTN leg to /twilio over a bidirectional
|
|
76
|
+
* <Stream>. The Twilio CallSid becomes the session id, so the call's media stream and any
|
|
77
|
+
* resume/recording key off the same stable id.
|
|
78
|
+
*/
|
|
79
|
+
async function twilioIncomingCallResponse(request: Request, url: URL): Promise<Response> {
|
|
80
|
+
let callSid = url.searchParams.get("CallSid") ?? "";
|
|
81
|
+
if (!callSid && request.method === "POST") {
|
|
82
|
+
const form = await request.formData().catch(() => null);
|
|
83
|
+
const value = form?.get("CallSid");
|
|
84
|
+
if (typeof value === "string") callSid = value;
|
|
85
|
+
}
|
|
86
|
+
const sessionId = callSid || crypto.randomUUID();
|
|
87
|
+
const wsScheme = url.protocol === "https:" ? "wss" : "ws";
|
|
88
|
+
const streamUrl = `${wsScheme}://${url.host}/twilio?sessionId=${encodeURIComponent(sessionId)}`;
|
|
89
|
+
const twiml = [
|
|
90
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
91
|
+
"<Response>",
|
|
92
|
+
" <Connect>",
|
|
93
|
+
` <Stream url="${xmlEscape(streamUrl)}" />`,
|
|
94
|
+
" </Connect>",
|
|
95
|
+
"</Response>",
|
|
96
|
+
].join("\n");
|
|
97
|
+
return new Response(twiml, { headers: { "content-type": "text/xml; charset=utf-8" } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function xmlEscape(value: string): string {
|
|
101
|
+
return value.replace(/[<>&"']/g, (c) =>
|
|
102
|
+
({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c] ?? c,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
37
106
|
/** List recorded objects for a session: GET /recordings?sessionId=<id>. */
|
|
38
107
|
async function listRecordings(url: URL, env: Env): Promise<Response> {
|
|
39
108
|
const sessionId = url.searchParams.get("sessionId");
|
|
@@ -41,85 +110,3 @@ async function listRecordings(url: URL, env: Env): Promise<Response> {
|
|
|
41
110
|
const listed = await env.RECORDINGS.list({ prefix: `recordings/${sessionId}/` });
|
|
42
111
|
return Response.json(listed.objects.map((o) => ({ key: o.key, size: o.size })));
|
|
43
112
|
}
|
|
44
|
-
|
|
45
|
-
export class VoiceConversation {
|
|
46
|
-
private readonly scheduler: DurableObjectAlarmScheduler;
|
|
47
|
-
private readonly store: DurableObjectSessionStore;
|
|
48
|
-
private activeUpgrade: VoiceEdgeWebSocketUpgrade | null = null;
|
|
49
|
-
|
|
50
|
-
constructor(
|
|
51
|
-
private readonly ctx: DurableObjectState,
|
|
52
|
-
private readonly env: Env,
|
|
53
|
-
) {
|
|
54
|
-
this.scheduler = new DurableObjectAlarmScheduler(ctx.storage);
|
|
55
|
-
this.store = new DurableObjectSessionStore(ctx.storage, this.scheduler);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async fetch(request: Request): Promise<Response> {
|
|
59
|
-
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
|
|
60
|
-
return new Response("expected websocket", { status: 426 });
|
|
61
|
-
}
|
|
62
|
-
const requestUrl = new URL(request.url);
|
|
63
|
-
const sessionId = requestUrl.searchParams.get("sessionId") ?? crypto.randomUUID();
|
|
64
|
-
if (requestUrl.pathname === "/twilio") {
|
|
65
|
-
const upgrade = createTwilioEdgeWebSocketUpgrade(request, {
|
|
66
|
-
sessionStore: this.store,
|
|
67
|
-
scheduler: this.scheduler,
|
|
68
|
-
createSession: () =>
|
|
69
|
-
createLiveVoiceAgentSession(this.env, {
|
|
70
|
-
sessionId,
|
|
71
|
-
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
72
|
-
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
73
|
-
}),
|
|
74
|
-
engineSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
75
|
-
}, {
|
|
76
|
-
acceptWebSocket: (socket) => this.ctx.acceptWebSocket(socket as WebSocket),
|
|
77
|
-
});
|
|
78
|
-
this.activeUpgrade = upgrade;
|
|
79
|
-
return upgrade.response;
|
|
80
|
-
}
|
|
81
|
-
const recorder = this.env.RECORDINGS
|
|
82
|
-
? new R2EdgeRecorder({ bucket: this.env.RECORDINGS, sessionId, startedAtMs: Date.now() })
|
|
83
|
-
: undefined;
|
|
84
|
-
const upgrade = createVoiceEdgeWebSocketUpgrade(request, {
|
|
85
|
-
sessionStore: this.store,
|
|
86
|
-
scheduler: this.scheduler,
|
|
87
|
-
recorder,
|
|
88
|
-
createSession: () =>
|
|
89
|
-
createLiveVoiceAgentSession(this.env, {
|
|
90
|
-
sessionId,
|
|
91
|
-
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
92
|
-
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
93
|
-
}),
|
|
94
|
-
sessionId: () => sessionId,
|
|
95
|
-
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
96
|
-
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
97
|
-
resumeWindowMs: 15_000,
|
|
98
|
-
}, {
|
|
99
|
-
acceptWebSocket: (socket) => this.ctx.acceptWebSocket(socket as WebSocket),
|
|
100
|
-
});
|
|
101
|
-
this.activeUpgrade = upgrade;
|
|
102
|
-
return upgrade.response;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async alarm(): Promise<void> {
|
|
106
|
-
await this.scheduler.runDue();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): void {
|
|
110
|
-
this.controller?.message(message);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
webSocketClose(_ws: WebSocket, code: number, reason: string): void {
|
|
114
|
-
this.controller?.close(code, reason);
|
|
115
|
-
this.activeUpgrade = null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
webSocketError(_ws: WebSocket, error: unknown): void {
|
|
119
|
-
this.controller?.error(error instanceof Error ? error : new Error(String(error)));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private get controller(): WorkersInboundSocketController | undefined {
|
|
123
|
-
return this.activeUpgrade?.controller;
|
|
124
|
-
}
|
|
125
|
-
}
|
package/wrangler.jsonc
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
{
|
|
10
10
|
"name": "VOICE_CONVERSATIONS",
|
|
11
11
|
"class_name": "VoiceConversation"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "TWILIO_VOICE_CONVERSATIONS",
|
|
15
|
+
"class_name": "TwilioVoiceConversation"
|
|
12
16
|
}
|
|
13
17
|
]
|
|
14
18
|
},
|
|
@@ -22,6 +26,10 @@
|
|
|
22
26
|
{
|
|
23
27
|
"tag": "v1",
|
|
24
28
|
"new_sqlite_classes": ["VoiceConversation"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"tag": "v2",
|
|
32
|
+
"new_sqlite_classes": ["TwilioVoiceConversation"]
|
|
25
33
|
}
|
|
26
34
|
],
|
|
27
35
|
"vectorize": [
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
5
|
-
import { MemoryDurableStorage } from "./test-storage.js";
|
|
6
|
-
|
|
7
|
-
describe("DurableObjectAlarmScheduler", () => {
|
|
8
|
-
it("persists deadlines, fires due callbacks, and rearms the nearest remaining task", async () => {
|
|
9
|
-
const storage = new MemoryDurableStorage();
|
|
10
|
-
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
11
|
-
const fired: string[] = [];
|
|
12
|
-
|
|
13
|
-
scheduler.schedule("late", 1000, () => {
|
|
14
|
-
fired.push("late");
|
|
15
|
-
});
|
|
16
|
-
scheduler.schedule("soon", 50, () => {
|
|
17
|
-
fired.push("soon");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
expect(storage.tasks.has("soon")).toBe(true);
|
|
21
|
-
expect(storage.alarm).toBeLessThanOrEqual(Date.now() + 1000);
|
|
22
|
-
|
|
23
|
-
await scheduler.runDue(Date.now() + 100);
|
|
24
|
-
|
|
25
|
-
expect(fired).toEqual(["soon"]);
|
|
26
|
-
expect(storage.tasks.has("soon")).toBe(false);
|
|
27
|
-
expect(storage.tasks.has("late")).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("cancels persisted tasks before the alarm fires", async () => {
|
|
31
|
-
const storage = new MemoryDurableStorage();
|
|
32
|
-
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
33
|
-
let fired = false;
|
|
34
|
-
|
|
35
|
-
scheduler.schedule("task", 1, () => {
|
|
36
|
-
fired = true;
|
|
37
|
-
});
|
|
38
|
-
scheduler.cancel("task");
|
|
39
|
-
await scheduler.runDue(Date.now() + 10);
|
|
40
|
-
|
|
41
|
-
expect(fired).toBe(false);
|
|
42
|
-
expect(storage.tasks.size).toBe(0);
|
|
43
|
-
});
|
|
44
|
-
});
|
package/src/alarm-scheduler.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import type { ScheduledCallback, Scheduler } from "@kuralle-syrinx/core";
|
|
4
|
-
|
|
5
|
-
type SqlCursor<T> = Iterable<T>;
|
|
6
|
-
|
|
7
|
-
interface SqlStorage {
|
|
8
|
-
exec(query: string, ...bindings: unknown[]): SqlCursor<Record<string, unknown>>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface DurableSchedulerStorage {
|
|
12
|
-
readonly sql: SqlStorage;
|
|
13
|
-
setAlarm(scheduledTime: number | Date): Promise<void>;
|
|
14
|
-
deleteAlarm(): Promise<void>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class DurableObjectAlarmScheduler implements Scheduler {
|
|
18
|
-
private readonly callbacks = new Map<string, ScheduledCallback>();
|
|
19
|
-
|
|
20
|
-
constructor(private readonly storage: DurableSchedulerStorage) {
|
|
21
|
-
this.storage.sql.exec(
|
|
22
|
-
"CREATE TABLE IF NOT EXISTS scheduled_tasks (key TEXT PRIMARY KEY, deadline_ms INTEGER NOT NULL)",
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
schedule(key: string, delayMs: number, cb: ScheduledCallback): void {
|
|
27
|
-
const deadlineMs = Date.now() + Math.max(0, delayMs);
|
|
28
|
-
this.callbacks.set(key, cb);
|
|
29
|
-
this.storage.sql.exec(
|
|
30
|
-
"INSERT OR REPLACE INTO scheduled_tasks (key, deadline_ms) VALUES (?, ?)",
|
|
31
|
-
key,
|
|
32
|
-
deadlineMs,
|
|
33
|
-
);
|
|
34
|
-
void this.armNext();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
cancel(key: string): void {
|
|
38
|
-
this.callbacks.delete(key);
|
|
39
|
-
this.storage.sql.exec("DELETE FROM scheduled_tasks WHERE key = ?", key);
|
|
40
|
-
void this.armNext();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async runDue(nowMs = Date.now()): Promise<void> {
|
|
44
|
-
const due = [...this.storage.sql.exec(
|
|
45
|
-
"SELECT key FROM scheduled_tasks WHERE deadline_ms <= ? ORDER BY deadline_ms ASC",
|
|
46
|
-
nowMs,
|
|
47
|
-
)] as Array<{ key: string }>;
|
|
48
|
-
for (const row of due) {
|
|
49
|
-
this.storage.sql.exec("DELETE FROM scheduled_tasks WHERE key = ?", row.key);
|
|
50
|
-
const cb = this.callbacks.get(row.key);
|
|
51
|
-
this.callbacks.delete(row.key);
|
|
52
|
-
if (cb) await cb();
|
|
53
|
-
}
|
|
54
|
-
await this.armNext();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
private async armNext(): Promise<void> {
|
|
58
|
-
const [next] = [...this.storage.sql.exec(
|
|
59
|
-
"SELECT deadline_ms FROM scheduled_tasks ORDER BY deadline_ms ASC LIMIT 1",
|
|
60
|
-
)] as Array<{ deadline_ms: number }>;
|
|
61
|
-
if (!next) {
|
|
62
|
-
await this.storage.deleteAlarm();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
await this.storage.setAlarm(next.deadline_ms);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import type { ManagedSession } from "@kuralle-syrinx/server-websocket/session-store";
|
|
5
|
-
import { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
6
|
-
import { DurableObjectSessionStore } from "./durable-session-store.js";
|
|
7
|
-
import { MemoryDurableStorage } from "./test-storage.js";
|
|
8
|
-
|
|
9
|
-
describe("DurableObjectSessionStore", () => {
|
|
10
|
-
it("persists turn metadata and restores it when the live session is recreated", async () => {
|
|
11
|
-
const storage = new MemoryDurableStorage();
|
|
12
|
-
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
13
|
-
const store = new DurableObjectSessionStore(storage, scheduler);
|
|
14
|
-
|
|
15
|
-
const first = await store.lease("s1", async () => managed("s1", "turn-a"));
|
|
16
|
-
store.update("s1", (stored) => {
|
|
17
|
-
stored.currentContextId = "turn-b";
|
|
18
|
-
stored.inputSequence.lastSequence = 7;
|
|
19
|
-
});
|
|
20
|
-
first.managed.connectionCount = 0;
|
|
21
|
-
await store.release("s1", 1000);
|
|
22
|
-
|
|
23
|
-
const recreatedStore = new DurableObjectSessionStore(storage, scheduler);
|
|
24
|
-
const second = await recreatedStore.lease("s1", async () => managed("s1", "fresh"));
|
|
25
|
-
|
|
26
|
-
expect(second.resumed).toBe(true);
|
|
27
|
-
expect(second.managed.currentContextId).toBe("turn-b");
|
|
28
|
-
expect(second.managed.inputSequence.lastSequence).toBe(7);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("deletes retained sessions when the scheduled release fires", async () => {
|
|
32
|
-
const storage = new MemoryDurableStorage();
|
|
33
|
-
const scheduler = new DurableObjectAlarmScheduler(storage);
|
|
34
|
-
const store = new DurableObjectSessionStore(storage, scheduler);
|
|
35
|
-
const leased = await store.lease("s2", async () => managed("s2", "turn-a"));
|
|
36
|
-
leased.managed.connectionCount = 0;
|
|
37
|
-
|
|
38
|
-
await store.release("s2", 5);
|
|
39
|
-
await scheduler.runDue(Date.now() + 10);
|
|
40
|
-
|
|
41
|
-
expect(await store.get("s2")).toBeNull();
|
|
42
|
-
expect(storage.sessions.has("s2")).toBe(false);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
function managed(id: string, currentContextId: string): ManagedSession {
|
|
47
|
-
return {
|
|
48
|
-
id,
|
|
49
|
-
session: {
|
|
50
|
-
start: async () => undefined,
|
|
51
|
-
close: async () => undefined,
|
|
52
|
-
} as ManagedSession["session"],
|
|
53
|
-
currentContextId,
|
|
54
|
-
contextSampleRates: new Map(),
|
|
55
|
-
inputSequence: { lastSequence: null },
|
|
56
|
-
turnMetricsTurns: new Map(),
|
|
57
|
-
closeTimer: null,
|
|
58
|
-
connectionCount: 0,
|
|
59
|
-
};
|
|
60
|
-
}
|