@syncular/testkit 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +210 -0
  2. package/dist/client.d.ts +68 -0
  3. package/dist/client.js +145 -0
  4. package/dist/clock.d.ts +24 -0
  5. package/dist/clock.js +32 -0
  6. package/dist/create-test-sync.d.ts +67 -0
  7. package/dist/create-test-sync.js +90 -0
  8. package/dist/faults.d.ts +52 -50
  9. package/dist/faults.js +74 -192
  10. package/dist/index.d.ts +17 -11
  11. package/dist/index.js +18 -11
  12. package/dist/react.d.ts +10 -0
  13. package/dist/react.js +41 -0
  14. package/dist/server.d.ts +44 -4
  15. package/dist/server.js +68 -4
  16. package/package.json +41 -57
  17. package/src/client.ts +218 -0
  18. package/src/clock.ts +45 -0
  19. package/src/create-test-sync.ts +158 -0
  20. package/src/faults.ts +77 -338
  21. package/src/index.ts +44 -10
  22. package/src/react.ts +46 -0
  23. package/src/server.ts +102 -3
  24. package/dist/audit.d.ts +0 -22
  25. package/dist/audit.d.ts.map +0 -1
  26. package/dist/audit.js +0 -40
  27. package/dist/audit.js.map +0 -1
  28. package/dist/client-bridge.d.ts +0 -77
  29. package/dist/client-bridge.d.ts.map +0 -1
  30. package/dist/client-bridge.js +0 -540
  31. package/dist/client-bridge.js.map +0 -1
  32. package/dist/deterministic.d.ts +0 -31
  33. package/dist/deterministic.d.ts.map +0 -1
  34. package/dist/deterministic.js +0 -69
  35. package/dist/deterministic.js.map +0 -1
  36. package/dist/disposable.d.ts +0 -10
  37. package/dist/disposable.d.ts.map +0 -1
  38. package/dist/disposable.js +0 -28
  39. package/dist/disposable.js.map +0 -1
  40. package/dist/faults.d.ts.map +0 -1
  41. package/dist/faults.js.map +0 -1
  42. package/dist/hono-node-server.d.ts +0 -20
  43. package/dist/hono-node-server.d.ts.map +0 -1
  44. package/dist/hono-node-server.js +0 -90
  45. package/dist/hono-node-server.js.map +0 -1
  46. package/dist/http-fixtures.d.ts +0 -32
  47. package/dist/http-fixtures.d.ts.map +0 -1
  48. package/dist/http-fixtures.js +0 -117
  49. package/dist/http-fixtures.js.map +0 -1
  50. package/dist/index.d.ts.map +0 -1
  51. package/dist/index.js.map +0 -1
  52. package/dist/project-scoped-tasks.d.ts +0 -40
  53. package/dist/project-scoped-tasks.d.ts.map +0 -1
  54. package/dist/project-scoped-tasks.js +0 -247
  55. package/dist/project-scoped-tasks.js.map +0 -1
  56. package/dist/realtime-ws.d.ts +0 -48
  57. package/dist/realtime-ws.d.ts.map +0 -1
  58. package/dist/realtime-ws.js +0 -203
  59. package/dist/realtime-ws.js.map +0 -1
  60. package/dist/runtime-process.d.ts +0 -14
  61. package/dist/runtime-process.d.ts.map +0 -1
  62. package/dist/runtime-process.js +0 -99
  63. package/dist/runtime-process.js.map +0 -1
  64. package/dist/server.d.ts.map +0 -1
  65. package/dist/server.js.map +0 -1
  66. package/dist/sync-builders.d.ts +0 -48
  67. package/dist/sync-builders.d.ts.map +0 -1
  68. package/dist/sync-builders.js +0 -66
  69. package/dist/sync-builders.js.map +0 -1
  70. package/dist/sync-http.d.ts +0 -48
  71. package/dist/sync-http.d.ts.map +0 -1
  72. package/dist/sync-http.js +0 -43
  73. package/dist/sync-http.js.map +0 -1
  74. package/dist/sync-parse.d.ts +0 -8
  75. package/dist/sync-parse.d.ts.map +0 -1
  76. package/dist/sync-parse.js +0 -38
  77. package/dist/sync-parse.js.map +0 -1
  78. package/dist/sync-response.d.ts +0 -6
  79. package/dist/sync-response.d.ts.map +0 -1
  80. package/dist/sync-response.js +0 -19
  81. package/dist/sync-response.js.map +0 -1
  82. package/src/audit.ts +0 -74
  83. package/src/client-bridge.ts +0 -841
  84. package/src/deterministic.ts +0 -121
  85. package/src/disposable.ts +0 -50
  86. package/src/hono-node-server.ts +0 -144
  87. package/src/http-fixtures.ts +0 -187
  88. package/src/project-scoped-tasks.ts +0 -351
  89. package/src/realtime-ws.ts +0 -354
  90. package/src/runtime-process.ts +0 -159
  91. package/src/sync-builders.ts +0 -136
  92. package/src/sync-http.ts +0 -117
  93. package/src/sync-parse.ts +0 -86
  94. package/src/sync-response.ts +0 -43
@@ -1,351 +0,0 @@
1
- import {
2
- isRecord,
3
- type SyncOperation,
4
- type SyncSubscriptionRequest,
5
- } from '@syncular/core';
6
- import type {
7
- ApplyOperationResult,
8
- EmittedChange,
9
- ServerTableHandler,
10
- SyncCoreDb,
11
- } from '@syncular/server';
12
- import type { Kysely } from 'kysely';
13
- import { sql } from 'kysely';
14
-
15
- export interface ProjectScopedTasksRow {
16
- id: string;
17
- title: string;
18
- completed: number;
19
- user_id: string;
20
- project_id: string;
21
- server_version: number;
22
- }
23
-
24
- export interface ProjectScopedTasksDb extends SyncCoreDb {
25
- tasks: ProjectScopedTasksRow;
26
- }
27
-
28
- export const PROJECT_SCOPED_TASKS_DDL = `
29
- CREATE TABLE IF NOT EXISTS tasks (
30
- id TEXT PRIMARY KEY,
31
- title TEXT NOT NULL DEFAULT '',
32
- completed INTEGER NOT NULL DEFAULT 0,
33
- user_id TEXT NOT NULL,
34
- project_id TEXT NOT NULL,
35
- server_version INTEGER NOT NULL DEFAULT 1
36
- )
37
- `;
38
-
39
- export interface ProjectScopedTasksHandlerOptions {
40
- projectScopeCount?: number;
41
- }
42
-
43
- function parseProjectScopedTaskPayload(payload: SyncOperation['payload']): {
44
- title?: string;
45
- completed?: number;
46
- project_id?: string;
47
- } {
48
- if (!isRecord(payload)) {
49
- return {};
50
- }
51
-
52
- return {
53
- title: typeof payload.title === 'string' ? payload.title : undefined,
54
- completed:
55
- typeof payload.completed === 'number' ? payload.completed : undefined,
56
- project_id:
57
- typeof payload.project_id === 'string' ? payload.project_id : undefined,
58
- };
59
- }
60
-
61
- export async function ensureProjectScopedTasksTable<
62
- DB extends SyncCoreDb & { tasks: ProjectScopedTasksRow },
63
- >(db: Kysely<DB>): Promise<void> {
64
- await sql.raw(PROJECT_SCOPED_TASKS_DDL).execute(db);
65
- }
66
-
67
- export function createProjectScopedTasksHandler<
68
- DB extends SyncCoreDb & { tasks: ProjectScopedTasksRow },
69
- >(options: ProjectScopedTasksHandlerOptions = {}): ServerTableHandler<DB> {
70
- const projectScopeCount = Math.max(1, options.projectScopeCount ?? 100);
71
-
72
- return {
73
- table: 'tasks',
74
- scopePatterns: ['user:{user_id}:project:{project_id}'],
75
-
76
- async resolveScopes(ctx) {
77
- return {
78
- user_id: ctx.actorId,
79
- project_id: Array.from(
80
- { length: projectScopeCount },
81
- (_, index) => `p${index}`
82
- ),
83
- };
84
- },
85
-
86
- extractScopes(row: Record<string, unknown>) {
87
- return {
88
- user_id: String(row.user_id ?? ''),
89
- project_id: String(row.project_id ?? ''),
90
- };
91
- },
92
-
93
- async snapshot(ctx) {
94
- const userIdValue = ctx.scopeValues.user_id;
95
- const projectIdValue = ctx.scopeValues.project_id;
96
- const userId = Array.isArray(userIdValue) ? userIdValue[0] : userIdValue;
97
- const projectId = Array.isArray(projectIdValue)
98
- ? projectIdValue[0]
99
- : projectIdValue;
100
-
101
- if (!userId || userId !== ctx.actorId) {
102
- return { rows: [], nextCursor: null };
103
- }
104
-
105
- if (!projectId) {
106
- return { rows: [], nextCursor: null };
107
- }
108
-
109
- const pageSize = Math.max(1, Math.min(10_000, ctx.limit));
110
- const cursor = ctx.cursor;
111
-
112
- const cursorFilter =
113
- cursor && cursor.length > 0
114
- ? sql`and ${sql.ref('id')} > ${sql.val(cursor)}`
115
- : sql``;
116
-
117
- const result = await sql<ProjectScopedTasksRow>`
118
- select id, title, completed, user_id, project_id, server_version
119
- from tasks
120
- where user_id = ${sql.val(userId)}
121
- and project_id = ${sql.val(projectId)}
122
- ${cursorFilter}
123
- order by id asc
124
- limit ${sql.val(pageSize + 1)}
125
- `.execute(ctx.db);
126
-
127
- const rows = result.rows;
128
- const hasMore = rows.length > pageSize;
129
- const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
130
- const nextCursor = hasMore
131
- ? (pageRows[pageRows.length - 1]?.id ?? null)
132
- : null;
133
-
134
- return {
135
- rows: pageRows,
136
- nextCursor:
137
- typeof nextCursor === 'string' && nextCursor.length > 0
138
- ? nextCursor
139
- : null,
140
- };
141
- },
142
-
143
- async applyOperation(
144
- ctx,
145
- op: SyncOperation,
146
- opIndex: number
147
- ): Promise<ApplyOperationResult> {
148
- if (op.table !== 'tasks') {
149
- return {
150
- result: {
151
- opIndex,
152
- status: 'error',
153
- error: `Unknown table: ${op.table}`,
154
- code: 'sync.unknown_table',
155
- retriable: false,
156
- },
157
- emittedChanges: [],
158
- };
159
- }
160
-
161
- if (op.op === 'delete') {
162
- const existingResult = await sql<{ id: string; project_id: string }>`
163
- select id, project_id from tasks
164
- where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
165
- limit 1
166
- `.execute(ctx.trx);
167
- const existing = existingResult.rows[0];
168
-
169
- if (!existing) {
170
- return { result: { opIndex, status: 'applied' }, emittedChanges: [] };
171
- }
172
-
173
- await sql`
174
- delete from tasks
175
- where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
176
- `.execute(ctx.trx);
177
-
178
- const emitted: EmittedChange = {
179
- table: 'tasks',
180
- row_id: op.row_id,
181
- op: 'delete',
182
- row_json: null,
183
- row_version: null,
184
- scopes: { user_id: ctx.actorId, project_id: existing.project_id },
185
- };
186
-
187
- return {
188
- result: { opIndex, status: 'applied' },
189
- emittedChanges: [emitted],
190
- };
191
- }
192
-
193
- const payload = parseProjectScopedTaskPayload(op.payload);
194
-
195
- const existingResult = await sql<{
196
- id: string;
197
- title: string;
198
- completed: number;
199
- project_id: string;
200
- server_version: number;
201
- }>`
202
- select id, title, completed, project_id, server_version
203
- from tasks
204
- where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
205
- limit 1
206
- `.execute(ctx.trx);
207
- const existing = existingResult.rows[0];
208
-
209
- if (
210
- existing &&
211
- op.base_version != null &&
212
- existing.server_version !== op.base_version
213
- ) {
214
- return {
215
- result: {
216
- opIndex,
217
- status: 'conflict',
218
- message: `Version conflict: server=${existing.server_version}, base=${op.base_version}`,
219
- code: 'sync.version_conflict',
220
- server_version: existing.server_version,
221
- server_row: {
222
- id: existing.id,
223
- title: existing.title,
224
- completed: existing.completed,
225
- user_id: ctx.actorId,
226
- project_id: existing.project_id,
227
- server_version: existing.server_version,
228
- },
229
- },
230
- emittedChanges: [],
231
- };
232
- }
233
-
234
- const projectId = payload.project_id ?? existing?.project_id;
235
- if (!projectId) {
236
- return {
237
- result: {
238
- opIndex,
239
- status: 'error',
240
- error: 'Missing project id',
241
- code: 'sync.invalid_request',
242
- retriable: false,
243
- },
244
- emittedChanges: [],
245
- };
246
- }
247
-
248
- if (existing) {
249
- const nextVersion = existing.server_version + 1;
250
- await sql`
251
- update tasks set
252
- title = ${sql.val(payload.title ?? existing.title)},
253
- completed = ${sql.val(payload.completed ?? existing.completed)},
254
- server_version = ${sql.val(nextVersion)}
255
- where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
256
- `.execute(ctx.trx);
257
- } else {
258
- await sql`
259
- insert into tasks (id, title, completed, user_id, project_id, server_version)
260
- values (
261
- ${sql.val(op.row_id)},
262
- ${sql.val(payload.title ?? '')},
263
- ${sql.val(payload.completed ?? 0)},
264
- ${sql.val(ctx.actorId)},
265
- ${sql.val(projectId)},
266
- ${sql.val(1)}
267
- )
268
- `.execute(ctx.trx);
269
- }
270
-
271
- const updatedResult = await sql<ProjectScopedTasksRow>`
272
- select id, title, completed, user_id, project_id, server_version
273
- from tasks
274
- where id = ${sql.val(op.row_id)} and user_id = ${sql.val(ctx.actorId)}
275
- limit 1
276
- `.execute(ctx.trx);
277
- const updated = updatedResult.rows[0];
278
- if (!updated) {
279
- throw new Error('TASKS_ROW_NOT_FOUND');
280
- }
281
-
282
- const emitted: EmittedChange = {
283
- table: 'tasks',
284
- row_id: op.row_id,
285
- op: 'upsert',
286
- row_json: {
287
- id: updated.id,
288
- title: updated.title,
289
- completed: updated.completed,
290
- user_id: updated.user_id,
291
- project_id: updated.project_id,
292
- server_version: updated.server_version,
293
- },
294
- row_version: updated.server_version,
295
- scopes: { user_id: ctx.actorId, project_id: updated.project_id },
296
- };
297
-
298
- return {
299
- result: { opIndex, status: 'applied' },
300
- emittedChanges: [emitted],
301
- };
302
- },
303
- };
304
- }
305
-
306
- export interface CreateProjectScopedTasksSubscriptionOptions {
307
- id?: string;
308
- userId: string;
309
- projectId?: string;
310
- cursor?: number;
311
- }
312
-
313
- export function createProjectScopedTasksSubscription(
314
- options: CreateProjectScopedTasksSubscriptionOptions
315
- ): SyncSubscriptionRequest {
316
- return {
317
- id: options.id ?? 'sub-tasks',
318
- table: 'tasks',
319
- scopes: {
320
- user_id: options.userId,
321
- project_id: options.projectId ?? 'p0',
322
- },
323
- cursor: options.cursor ?? 0,
324
- bootstrapState: null,
325
- crdtStateVectors: [],
326
- };
327
- }
328
-
329
- export interface CreateProjectScopedTaskUpsertOperationOptions {
330
- taskId: string;
331
- title: string;
332
- completed?: number;
333
- projectId?: string;
334
- baseVersion?: number | null;
335
- }
336
-
337
- export function createProjectScopedTaskUpsertOperation(
338
- options: CreateProjectScopedTaskUpsertOperationOptions
339
- ): SyncOperation {
340
- return {
341
- table: 'tasks',
342
- row_id: options.taskId,
343
- op: 'upsert',
344
- payload: {
345
- title: options.title,
346
- completed: options.completed ?? 0,
347
- project_id: options.projectId ?? 'p0',
348
- },
349
- base_version: options.baseVersion ?? null,
350
- };
351
- }
@@ -1,354 +0,0 @@
1
- import { isRecord } from '@syncular/core';
2
- import {
3
- type AsyncDisposableResource,
4
- createAsyncDisposableResource,
5
- type ResourceRunner,
6
- withAsyncDisposableFactory,
7
- } from './disposable';
8
-
9
- export type RealtimeWsQueryValue = string | number | boolean | null | undefined;
10
-
11
- export interface CreateRealtimeWsUrlOptions {
12
- baseUrl: string;
13
- clientId: string;
14
- actorId: string;
15
- actorQueryParam?: string;
16
- path?: string;
17
- query?: Record<string, RealtimeWsQueryValue>;
18
- }
19
-
20
- export interface WebSocketConstructor {
21
- new (url: string, protocols?: string | string[]): WebSocket;
22
- }
23
-
24
- export interface OpenRealtimeWsOptions extends CreateRealtimeWsUrlOptions {
25
- WebSocketCtor?: WebSocketConstructor;
26
- protocols?: string | string[];
27
- }
28
-
29
- export interface OpenRealtimeWsResourceOptions extends OpenRealtimeWsOptions {
30
- waitForOpen?: boolean;
31
- openTimeoutMs?: number;
32
- close?: CloseWsSafeOptions;
33
- }
34
-
35
- function appendQueryParam(
36
- url: URL,
37
- key: string,
38
- value: RealtimeWsQueryValue
39
- ): void {
40
- if (value === null || value === undefined) {
41
- return;
42
- }
43
-
44
- url.searchParams.set(key, String(value));
45
- }
46
-
47
- function toWsProtocol(protocol: string): string {
48
- if (protocol === 'http:') {
49
- return 'ws:';
50
- }
51
-
52
- if (protocol === 'https:') {
53
- return 'wss:';
54
- }
55
-
56
- return protocol;
57
- }
58
-
59
- export function createRealtimeWsUrl(
60
- options: CreateRealtimeWsUrlOptions
61
- ): string {
62
- const url = new URL(options.baseUrl);
63
- url.protocol = toWsProtocol(url.protocol);
64
-
65
- const path = options.path ?? '/sync/realtime';
66
- url.pathname = path.startsWith('/') ? path : `/${path}`;
67
- url.search = '';
68
-
69
- appendQueryParam(url, 'clientId', options.clientId);
70
- appendQueryParam(url, options.actorQueryParam ?? 'userId', options.actorId);
71
-
72
- for (const [key, value] of Object.entries(options.query ?? {})) {
73
- appendQueryParam(url, key, value);
74
- }
75
-
76
- return url.toString();
77
- }
78
-
79
- export function openRealtimeWs(options: OpenRealtimeWsOptions): WebSocket {
80
- const WebSocketCtor = options.WebSocketCtor ?? globalThis.WebSocket;
81
- if (!WebSocketCtor) {
82
- throw new Error('WebSocket constructor is unavailable in this runtime');
83
- }
84
-
85
- return new WebSocketCtor(createRealtimeWsUrl(options), options.protocols);
86
- }
87
-
88
- export interface WaitForWsOpenOptions {
89
- timeoutMs?: number;
90
- }
91
-
92
- export function waitForWsOpen(
93
- ws: WebSocket,
94
- options: WaitForWsOpenOptions = {}
95
- ): Promise<void> {
96
- const timeoutMs = options.timeoutMs ?? 10_000;
97
-
98
- return new Promise<void>((resolve, reject) => {
99
- const cleanup = () => {
100
- clearTimeout(timeout);
101
- ws.removeEventListener('open', onOpen);
102
- ws.removeEventListener('error', onError);
103
- ws.removeEventListener('close', onClose);
104
- };
105
-
106
- const onOpen = () => {
107
- cleanup();
108
- resolve();
109
- };
110
-
111
- const onError = () => {
112
- cleanup();
113
- reject(new Error('WebSocket emitted an error before opening'));
114
- };
115
-
116
- const onClose = (event: CloseEvent) => {
117
- cleanup();
118
- reject(
119
- new Error(
120
- `WebSocket closed before opening (code=${event.code} reason=${event.reason || 'none'})`
121
- )
122
- );
123
- };
124
-
125
- const timeout = setTimeout(() => {
126
- cleanup();
127
- reject(
128
- new Error(`Timed out waiting for WebSocket open (${timeoutMs}ms)`)
129
- );
130
- }, timeoutMs);
131
-
132
- ws.addEventListener('open', onOpen);
133
- ws.addEventListener('error', onError);
134
- ws.addEventListener('close', onClose);
135
- });
136
- }
137
-
138
- export interface WaitForWsMessageOptions {
139
- timeoutMs?: number;
140
- predicate?: (event: MessageEvent) => boolean;
141
- }
142
-
143
- export function waitForWsMessage(
144
- ws: WebSocket,
145
- options: WaitForWsMessageOptions = {}
146
- ): Promise<MessageEvent> {
147
- const timeoutMs = options.timeoutMs ?? 10_000;
148
-
149
- return new Promise<MessageEvent>((resolve, reject) => {
150
- const cleanup = () => {
151
- clearTimeout(timeout);
152
- ws.removeEventListener('message', onMessage);
153
- ws.removeEventListener('error', onError);
154
- ws.removeEventListener('close', onClose);
155
- };
156
-
157
- const onMessage = (event: MessageEvent) => {
158
- if (options.predicate && !options.predicate(event)) {
159
- return;
160
- }
161
-
162
- cleanup();
163
- resolve(event);
164
- };
165
-
166
- const onError = () => {
167
- cleanup();
168
- reject(new Error('WebSocket emitted an error before a message arrived'));
169
- };
170
-
171
- const onClose = (event: CloseEvent) => {
172
- cleanup();
173
- reject(
174
- new Error(
175
- `WebSocket closed before receiving a matching message (code=${event.code} reason=${event.reason || 'none'})`
176
- )
177
- );
178
- };
179
-
180
- const timeout = setTimeout(() => {
181
- cleanup();
182
- reject(
183
- new Error(`Timed out waiting for WebSocket message (${timeoutMs}ms)`)
184
- );
185
- }, timeoutMs);
186
-
187
- ws.addEventListener('message', onMessage);
188
- ws.addEventListener('error', onError);
189
- ws.addEventListener('close', onClose);
190
- });
191
- }
192
-
193
- function readTextPayload(data: MessageEvent['data']): string | null {
194
- if (typeof data === 'string') {
195
- return data;
196
- }
197
-
198
- if (data instanceof ArrayBuffer) {
199
- return new TextDecoder().decode(new Uint8Array(data));
200
- }
201
-
202
- if (ArrayBuffer.isView(data)) {
203
- return new TextDecoder().decode(
204
- new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
205
- );
206
- }
207
-
208
- return null;
209
- }
210
-
211
- export function parseWsJsonMessage(
212
- event: MessageEvent
213
- ): Record<string, unknown> | null {
214
- const payload = readTextPayload(event.data);
215
- if (!payload) {
216
- return null;
217
- }
218
-
219
- try {
220
- const parsed = JSON.parse(payload);
221
- return isRecord(parsed) ? parsed : null;
222
- } catch {
223
- return null;
224
- }
225
- }
226
-
227
- export interface WaitForWsJsonMessageOptions {
228
- timeoutMs?: number;
229
- predicate?: (message: Record<string, unknown>) => boolean;
230
- }
231
-
232
- export function waitForWsJsonMessage(
233
- ws: WebSocket,
234
- options: WaitForWsJsonMessageOptions = {}
235
- ): Promise<Record<string, unknown>> {
236
- const timeoutMs = options.timeoutMs ?? 10_000;
237
-
238
- return new Promise<Record<string, unknown>>((resolve, reject) => {
239
- const cleanup = () => {
240
- clearTimeout(timeout);
241
- ws.removeEventListener('message', onMessage);
242
- ws.removeEventListener('error', onError);
243
- ws.removeEventListener('close', onClose);
244
- };
245
-
246
- const onMessage = (event: MessageEvent) => {
247
- const parsed = parseWsJsonMessage(event);
248
- if (!parsed) {
249
- return;
250
- }
251
-
252
- if (options.predicate && !options.predicate(parsed)) {
253
- return;
254
- }
255
-
256
- cleanup();
257
- resolve(parsed);
258
- };
259
-
260
- const onError = () => {
261
- cleanup();
262
- reject(new Error('WebSocket emitted an error before JSON message'));
263
- };
264
-
265
- const onClose = (event: CloseEvent) => {
266
- cleanup();
267
- reject(
268
- new Error(
269
- `WebSocket closed before receiving JSON message (code=${event.code} reason=${event.reason || 'none'})`
270
- )
271
- );
272
- };
273
-
274
- const timeout = setTimeout(() => {
275
- cleanup();
276
- reject(
277
- new Error(
278
- `Timed out waiting for WebSocket JSON message (${timeoutMs}ms)`
279
- )
280
- );
281
- }, timeoutMs);
282
-
283
- ws.addEventListener('message', onMessage);
284
- ws.addEventListener('error', onError);
285
- ws.addEventListener('close', onClose);
286
- });
287
- }
288
-
289
- export interface CloseWsSafeOptions {
290
- code?: number;
291
- reason?: string;
292
- timeoutMs?: number;
293
- }
294
-
295
- const WS_CLOSING = 2;
296
- const WS_CLOSED = 3;
297
-
298
- export async function closeWsSafe(
299
- ws: WebSocket,
300
- options: CloseWsSafeOptions = {}
301
- ): Promise<void> {
302
- if (ws.readyState === WS_CLOSED) {
303
- return;
304
- }
305
-
306
- const timeoutMs = options.timeoutMs ?? 2_000;
307
-
308
- await new Promise<void>((resolve) => {
309
- const cleanup = () => {
310
- clearTimeout(timeout);
311
- ws.removeEventListener('close', onClose);
312
- resolve();
313
- };
314
-
315
- const onClose = () => {
316
- cleanup();
317
- };
318
-
319
- const timeout = setTimeout(() => {
320
- cleanup();
321
- }, timeoutMs);
322
-
323
- ws.addEventListener('close', onClose);
324
-
325
- if (ws.readyState !== WS_CLOSING) {
326
- try {
327
- ws.close(options.code, options.reason);
328
- } catch {
329
- cleanup();
330
- }
331
- }
332
- });
333
- }
334
-
335
- export async function openRealtimeWsResource(
336
- options: OpenRealtimeWsResourceOptions
337
- ): Promise<AsyncDisposableResource<WebSocket>> {
338
- const ws = openRealtimeWs(options);
339
-
340
- if (options.waitForOpen) {
341
- await waitForWsOpen(ws, { timeoutMs: options.openTimeoutMs });
342
- }
343
-
344
- return createAsyncDisposableResource(ws, () =>
345
- closeWsSafe(ws, options.close)
346
- );
347
- }
348
-
349
- export async function withRealtimeWs<TResult>(
350
- options: OpenRealtimeWsResourceOptions,
351
- run: ResourceRunner<WebSocket, TResult>
352
- ): Promise<TResult> {
353
- return withAsyncDisposableFactory(() => openRealtimeWsResource(options), run);
354
- }