@syncular/testkit 0.1.2 → 0.2.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/README.md +210 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.js +145 -0
- package/dist/clock.d.ts +24 -0
- package/dist/clock.js +32 -0
- package/dist/create-test-sync.d.ts +67 -0
- package/dist/create-test-sync.js +90 -0
- package/dist/faults.d.ts +52 -50
- package/dist/faults.js +74 -192
- package/dist/index.d.ts +17 -15
- package/dist/index.js +18 -15
- package/dist/react.d.ts +10 -0
- package/dist/react.js +41 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +68 -0
- package/package.json +52 -29
- package/src/client.ts +218 -0
- package/src/clock.ts +45 -0
- package/src/create-test-sync.ts +158 -0
- package/src/faults.ts +77 -338
- package/src/index.ts +44 -14
- package/src/react.ts +46 -0
- package/src/server.ts +102 -0
- package/dist/audit.d.ts +0 -22
- package/dist/audit.d.ts.map +0 -1
- package/dist/audit.js +0 -40
- package/dist/audit.js.map +0 -1
- package/dist/client-bridge.d.ts +0 -77
- package/dist/client-bridge.d.ts.map +0 -1
- package/dist/client-bridge.js +0 -540
- package/dist/client-bridge.js.map +0 -1
- package/dist/deterministic.d.ts +0 -31
- package/dist/deterministic.d.ts.map +0 -1
- package/dist/deterministic.js +0 -69
- package/dist/deterministic.js.map +0 -1
- package/dist/disposable.d.ts +0 -10
- package/dist/disposable.d.ts.map +0 -1
- package/dist/disposable.js +0 -28
- package/dist/disposable.js.map +0 -1
- package/dist/faults.d.ts.map +0 -1
- package/dist/faults.js.map +0 -1
- package/dist/hono-node-server.d.ts +0 -20
- package/dist/hono-node-server.d.ts.map +0 -1
- package/dist/hono-node-server.js +0 -90
- package/dist/hono-node-server.js.map +0 -1
- package/dist/http-fixtures.d.ts +0 -32
- package/dist/http-fixtures.d.ts.map +0 -1
- package/dist/http-fixtures.js +0 -117
- package/dist/http-fixtures.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/project-scoped-tasks.d.ts +0 -40
- package/dist/project-scoped-tasks.d.ts.map +0 -1
- package/dist/project-scoped-tasks.js +0 -247
- package/dist/project-scoped-tasks.js.map +0 -1
- package/dist/realtime-ws.d.ts +0 -48
- package/dist/realtime-ws.d.ts.map +0 -1
- package/dist/realtime-ws.js +0 -203
- package/dist/realtime-ws.js.map +0 -1
- package/dist/runtime-process.d.ts +0 -14
- package/dist/runtime-process.d.ts.map +0 -1
- package/dist/runtime-process.js +0 -99
- package/dist/runtime-process.js.map +0 -1
- package/dist/sync-builders.d.ts +0 -48
- package/dist/sync-builders.d.ts.map +0 -1
- package/dist/sync-builders.js +0 -66
- package/dist/sync-builders.js.map +0 -1
- package/dist/sync-http.d.ts +0 -48
- package/dist/sync-http.d.ts.map +0 -1
- package/dist/sync-http.js +0 -43
- package/dist/sync-http.js.map +0 -1
- package/dist/sync-parse.d.ts +0 -8
- package/dist/sync-parse.d.ts.map +0 -1
- package/dist/sync-parse.js +0 -38
- package/dist/sync-parse.js.map +0 -1
- package/dist/sync-response.d.ts +0 -6
- package/dist/sync-response.d.ts.map +0 -1
- package/dist/sync-response.js +0 -19
- package/dist/sync-response.js.map +0 -1
- package/src/audit.ts +0 -74
- package/src/client-bridge.ts +0 -841
- package/src/deterministic.ts +0 -121
- package/src/disposable.ts +0 -50
- package/src/hono-node-server.ts +0 -144
- package/src/http-fixtures.ts +0 -187
- package/src/project-scoped-tasks.ts +0 -351
- package/src/realtime-ws.ts +0 -354
- package/src/runtime-process.ts +0 -159
- package/src/sync-builders.ts +0 -136
- package/src/sync-http.ts +0 -117
- package/src/sync-parse.ts +0 -86
- 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
|
-
}
|
package/src/realtime-ws.ts
DELETED
|
@@ -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
|
-
}
|