@syncular/server-hono 0.0.6-95 → 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.
Files changed (104) hide show
  1. package/README.md +5 -23
  2. package/dist/admin-page.d.ts +14 -0
  3. package/dist/admin-page.js +217 -0
  4. package/dist/admin.d.ts +39 -0
  5. package/dist/admin.js +142 -0
  6. package/dist/index.d.ts +16 -14
  7. package/dist/index.js +134 -23
  8. package/package.json +19 -53
  9. package/src/admin-page.ts +217 -0
  10. package/src/admin.ts +193 -0
  11. package/src/index.ts +175 -31
  12. package/dist/api-key-auth.d.ts +0 -49
  13. package/dist/api-key-auth.d.ts.map +0 -1
  14. package/dist/api-key-auth.js +0 -110
  15. package/dist/api-key-auth.js.map +0 -1
  16. package/dist/blobs.d.ts +0 -69
  17. package/dist/blobs.d.ts.map +0 -1
  18. package/dist/blobs.js +0 -383
  19. package/dist/blobs.js.map +0 -1
  20. package/dist/console/gateway.d.ts +0 -33
  21. package/dist/console/gateway.d.ts.map +0 -1
  22. package/dist/console/gateway.js +0 -2534
  23. package/dist/console/gateway.js.map +0 -1
  24. package/dist/console/index.d.ts +0 -11
  25. package/dist/console/index.d.ts.map +0 -1
  26. package/dist/console/index.js +0 -11
  27. package/dist/console/index.js.map +0 -1
  28. package/dist/console/routes.d.ts +0 -33
  29. package/dist/console/routes.d.ts.map +0 -1
  30. package/dist/console/routes.js +0 -2890
  31. package/dist/console/routes.js.map +0 -1
  32. package/dist/console/schemas.d.ts +0 -490
  33. package/dist/console/schemas.d.ts.map +0 -1
  34. package/dist/console/schemas.js +0 -303
  35. package/dist/console/schemas.js.map +0 -1
  36. package/dist/console/types.d.ts +0 -142
  37. package/dist/console/types.d.ts.map +0 -1
  38. package/dist/console/types.js +0 -2
  39. package/dist/console/types.js.map +0 -1
  40. package/dist/console/ui.d.ts +0 -38
  41. package/dist/console/ui.d.ts.map +0 -1
  42. package/dist/console/ui.js +0 -43
  43. package/dist/console/ui.js.map +0 -1
  44. package/dist/create-server.d.ts +0 -68
  45. package/dist/create-server.d.ts.map +0 -1
  46. package/dist/create-server.js +0 -109
  47. package/dist/create-server.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/index.js.map +0 -1
  50. package/dist/openapi.d.ts +0 -45
  51. package/dist/openapi.d.ts.map +0 -1
  52. package/dist/openapi.js +0 -59
  53. package/dist/openapi.js.map +0 -1
  54. package/dist/proxy/connection-manager.d.ts +0 -78
  55. package/dist/proxy/connection-manager.d.ts.map +0 -1
  56. package/dist/proxy/connection-manager.js +0 -251
  57. package/dist/proxy/connection-manager.js.map +0 -1
  58. package/dist/proxy/index.d.ts +0 -8
  59. package/dist/proxy/index.d.ts.map +0 -1
  60. package/dist/proxy/index.js +0 -8
  61. package/dist/proxy/index.js.map +0 -1
  62. package/dist/proxy/routes.d.ts +0 -74
  63. package/dist/proxy/routes.d.ts.map +0 -1
  64. package/dist/proxy/routes.js +0 -147
  65. package/dist/proxy/routes.js.map +0 -1
  66. package/dist/rate-limit.d.ts +0 -101
  67. package/dist/rate-limit.d.ts.map +0 -1
  68. package/dist/rate-limit.js +0 -186
  69. package/dist/rate-limit.js.map +0 -1
  70. package/dist/routes.d.ts +0 -145
  71. package/dist/routes.d.ts.map +0 -1
  72. package/dist/routes.js +0 -1471
  73. package/dist/routes.js.map +0 -1
  74. package/dist/ws.d.ts +0 -235
  75. package/dist/ws.d.ts.map +0 -1
  76. package/dist/ws.js +0 -614
  77. package/dist/ws.js.map +0 -1
  78. package/src/__tests__/blob-routes.test.ts +0 -424
  79. package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
  80. package/src/__tests__/console-gateway-routes.test.ts +0 -1364
  81. package/src/__tests__/console-routes.test.ts +0 -1543
  82. package/src/__tests__/console-ui.test.ts +0 -114
  83. package/src/__tests__/create-server.test.ts +0 -342
  84. package/src/__tests__/pull-chunk-storage.test.ts +0 -576
  85. package/src/__tests__/rate-limit.test.ts +0 -78
  86. package/src/__tests__/realtime-bridge.test.ts +0 -135
  87. package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
  88. package/src/__tests__/ws-connection-manager.test.ts +0 -176
  89. package/src/api-key-auth.ts +0 -179
  90. package/src/blobs.ts +0 -534
  91. package/src/console/gateway.ts +0 -3603
  92. package/src/console/index.ts +0 -11
  93. package/src/console/routes.ts +0 -3748
  94. package/src/console/schemas.ts +0 -434
  95. package/src/console/types.ts +0 -151
  96. package/src/console/ui.ts +0 -100
  97. package/src/create-server.ts +0 -206
  98. package/src/openapi.ts +0 -74
  99. package/src/proxy/connection-manager.ts +0 -340
  100. package/src/proxy/index.ts +0 -8
  101. package/src/proxy/routes.ts +0 -223
  102. package/src/rate-limit.ts +0 -321
  103. package/src/routes.ts +0 -2007
  104. package/src/ws.ts +0 -802
@@ -1,424 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import type { BlobStorageAdapter } from '@syncular/core';
3
- import { createDatabase } from '@syncular/core';
4
- import {
5
- type BlobTokenSigner,
6
- createBlobManager,
7
- createDatabaseBlobStorageAdapter,
8
- createHmacTokenSigner,
9
- ensureBlobStorageSchemaSqlite,
10
- type SyncBlobDb,
11
- } from '@syncular/server';
12
- import { Hono } from 'hono';
13
- import type { Kysely } from 'kysely';
14
- import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
15
- import { createBlobRoutes } from '../blobs';
16
-
17
- interface UploadInitResponse {
18
- exists: boolean;
19
- uploadUrl?: string;
20
- uploadMethod?: 'PUT' | 'POST';
21
- }
22
-
23
- interface UrlResponse {
24
- url: string;
25
- }
26
-
27
- interface CompleteResponse {
28
- ok: boolean;
29
- error?: string;
30
- }
31
-
32
- const ACTOR_HEADER = 'x-user-id';
33
- const ACTOR_ID = 'user-1';
34
- const INVALID_HASH = 'invalid-hash';
35
-
36
- function toHex(bytes: Uint8Array): string {
37
- return Array.from(bytes)
38
- .map((value) => value.toString(16).padStart(2, '0'))
39
- .join('');
40
- }
41
-
42
- async function createHash(bytes: Uint8Array): Promise<string> {
43
- const digest = await crypto.subtle.digest('SHA-256', bytes);
44
- return `sha256:${toHex(new Uint8Array(digest))}`;
45
- }
46
-
47
- async function signBlobToken(args: {
48
- signer: BlobTokenSigner;
49
- hash: string;
50
- action: 'upload' | 'download';
51
- }): Promise<string> {
52
- return args.signer.sign(
53
- {
54
- hash: args.hash,
55
- action: args.action,
56
- expiresAt: Date.now() + 60_000,
57
- },
58
- 60
59
- );
60
- }
61
-
62
- function createDefaultAdapter(
63
- db: Kysely<SyncBlobDb>,
64
- tokenSigner: BlobTokenSigner
65
- ): BlobStorageAdapter {
66
- return createDatabaseBlobStorageAdapter({
67
- db,
68
- baseUrl: 'http://localhost/sync',
69
- tokenSigner,
70
- });
71
- }
72
-
73
- function createFallbackAdapter(
74
- db: Kysely<SyncBlobDb>,
75
- tokenSigner: BlobTokenSigner
76
- ): BlobStorageAdapter {
77
- const adapter = createDefaultAdapter(db, tokenSigner);
78
- return {
79
- name: 'database-fallback',
80
- signUpload: adapter.signUpload,
81
- signDownload: adapter.signDownload,
82
- exists: adapter.exists,
83
- delete: adapter.delete,
84
- getMetadata: adapter.getMetadata,
85
- };
86
- }
87
-
88
- function buildApp(args: {
89
- db: Kysely<SyncBlobDb>;
90
- tokenSigner: BlobTokenSigner;
91
- adapter: BlobStorageAdapter;
92
- authenticate?: (
93
- c: Parameters<typeof createBlobRoutes>[0]['authenticate']
94
- ) => ReturnType<Parameters<typeof createBlobRoutes>[0]['authenticate']>;
95
- canAccessBlob?: Parameters<typeof createBlobRoutes>[0]['canAccessBlob'];
96
- }): Hono {
97
- const blobManager = createBlobManager({
98
- db: args.db,
99
- adapter: args.adapter,
100
- });
101
-
102
- const app = new Hono();
103
- app.route(
104
- '/sync',
105
- createBlobRoutes({
106
- blobManager,
107
- authenticate: async (c) => {
108
- if (args.authenticate) {
109
- return args.authenticate(c);
110
- }
111
- const actorId = c.req.header(ACTOR_HEADER);
112
- return actorId ? { actorId } : null;
113
- },
114
- tokenSigner: args.tokenSigner,
115
- db: args.db,
116
- canAccessBlob: args.canAccessBlob,
117
- })
118
- );
119
- return app;
120
- }
121
-
122
- async function initiateUpload(args: {
123
- app: Hono;
124
- hash: string;
125
- size: number;
126
- mimeType?: string;
127
- }): Promise<UploadInitResponse> {
128
- const response = await args.app.request(
129
- 'http://localhost/sync/blobs/upload',
130
- {
131
- method: 'POST',
132
- headers: {
133
- 'content-type': 'application/json',
134
- [ACTOR_HEADER]: ACTOR_ID,
135
- },
136
- body: JSON.stringify({
137
- hash: args.hash,
138
- size: args.size,
139
- mimeType: args.mimeType ?? 'application/octet-stream',
140
- }),
141
- }
142
- );
143
-
144
- expect(response.status).toBe(200);
145
- return (await response.json()) as UploadInitResponse;
146
- }
147
-
148
- describe('createBlobRoutes', () => {
149
- let db: Kysely<SyncBlobDb>;
150
- let tokenSigner: BlobTokenSigner;
151
-
152
- beforeEach(async () => {
153
- db = createDatabase<SyncBlobDb>({
154
- dialect: createBunSqliteDialect({ path: ':memory:' }),
155
- family: 'sqlite',
156
- });
157
- await ensureBlobStorageSchemaSqlite(db);
158
- tokenSigner = createHmacTokenSigner('blob-route-test-secret');
159
- });
160
-
161
- afterEach(async () => {
162
- await db.destroy();
163
- });
164
-
165
- it('rejects unauthenticated upload initiation', async () => {
166
- const app = buildApp({
167
- db,
168
- tokenSigner,
169
- adapter: createDefaultAdapter(db, tokenSigner),
170
- authenticate: async () => null,
171
- });
172
-
173
- const hash = await createHash(new Uint8Array([1, 2, 3]));
174
- const response = await app.request('http://localhost/sync/blobs/upload', {
175
- method: 'POST',
176
- headers: { 'content-type': 'application/json' },
177
- body: JSON.stringify({
178
- hash,
179
- size: 3,
180
- mimeType: 'application/octet-stream',
181
- }),
182
- });
183
-
184
- expect(response.status).toBe(401);
185
- expect(await response.json()).toEqual({ error: 'UNAUTHENTICATED' });
186
- });
187
-
188
- it('rejects invalid direct-upload tokens', async () => {
189
- const app = buildApp({
190
- db,
191
- tokenSigner,
192
- adapter: createDefaultAdapter(db, tokenSigner),
193
- });
194
-
195
- const response = await app.request(
196
- `http://localhost/sync/blobs/${encodeURIComponent(`sha256:${'a'.repeat(64)}`)}/upload?token=invalid-token`,
197
- {
198
- method: 'PUT',
199
- body: new Uint8Array([1, 2, 3]),
200
- }
201
- );
202
-
203
- expect(response.status).toBe(401);
204
- expect(await response.json()).toEqual({ error: 'INVALID_TOKEN' });
205
- });
206
-
207
- it('rejects direct upload when body size does not match metadata', async () => {
208
- const app = buildApp({
209
- db,
210
- tokenSigner,
211
- adapter: createDefaultAdapter(db, tokenSigner),
212
- });
213
-
214
- const content = new Uint8Array([1, 2, 3, 4]);
215
- const hash = await createHash(content);
216
- const init = await initiateUpload({
217
- app,
218
- hash,
219
- size: content.length,
220
- });
221
-
222
- const firstUpload = await app.request(init.uploadUrl!, {
223
- method: init.uploadMethod ?? 'PUT',
224
- body: content,
225
- });
226
- expect(firstUpload.status).toBe(200);
227
-
228
- const complete = await app.request(
229
- `http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
230
- {
231
- method: 'POST',
232
- headers: { [ACTOR_HEADER]: ACTOR_ID },
233
- }
234
- );
235
- expect(complete.status).toBe(200);
236
-
237
- const token = await signBlobToken({
238
- signer: tokenSigner,
239
- hash,
240
- action: 'upload',
241
- });
242
-
243
- const response = await app.request(
244
- `http://localhost/sync/blobs/${encodeURIComponent(hash)}/upload?token=${encodeURIComponent(token)}`,
245
- {
246
- method: 'PUT',
247
- body: new Uint8Array([1, 2, 3]),
248
- }
249
- );
250
-
251
- expect(response.status).toBe(400);
252
- const payload = (await response.json()) as { error: string };
253
- expect(payload.error).toBe('SIZE_MISMATCH');
254
- });
255
-
256
- it('rejects direct upload when body hash does not match route hash', async () => {
257
- const app = buildApp({
258
- db,
259
- tokenSigner,
260
- adapter: createDefaultAdapter(db, tokenSigner),
261
- });
262
-
263
- const expected = new Uint8Array([1, 2, 3, 4]);
264
- const hash = await createHash(expected);
265
- await initiateUpload({
266
- app,
267
- hash,
268
- size: expected.length,
269
- });
270
-
271
- const token = await signBlobToken({
272
- signer: tokenSigner,
273
- hash,
274
- action: 'upload',
275
- });
276
-
277
- const response = await app.request(
278
- `http://localhost/sync/blobs/${encodeURIComponent(hash)}/upload?token=${encodeURIComponent(token)}`,
279
- {
280
- method: 'PUT',
281
- body: new Uint8Array([9, 9, 9, 9]),
282
- }
283
- );
284
-
285
- expect(response.status).toBe(400);
286
- const payload = (await response.json()) as { error: string };
287
- expect(payload.error).toBe('HASH_MISMATCH');
288
- });
289
-
290
- it('returns 404 for invalid hash format and 403 for forbidden actor access', async () => {
291
- const app = buildApp({
292
- db,
293
- tokenSigner,
294
- adapter: createDefaultAdapter(db, tokenSigner),
295
- canAccessBlob: async () => false,
296
- });
297
-
298
- const invalidHashResponse = await app.request(
299
- `http://localhost/sync/blobs/${INVALID_HASH}/url`,
300
- {
301
- headers: { [ACTOR_HEADER]: ACTOR_ID },
302
- }
303
- );
304
- expect(invalidHashResponse.status).toBe(404);
305
- expect(await invalidHashResponse.json()).toEqual({ error: 'NOT_FOUND' });
306
-
307
- const validHash = `sha256:${'b'.repeat(64)}`;
308
- const forbiddenResponse = await app.request(
309
- `http://localhost/sync/blobs/${encodeURIComponent(validHash)}/url`,
310
- {
311
- headers: { [ACTOR_HEADER]: ACTOR_ID },
312
- }
313
- );
314
- expect(forbiddenResponse.status).toBe(403);
315
- expect(await forbiddenResponse.json()).toEqual({ error: 'FORBIDDEN' });
316
- });
317
-
318
- it('uploads and downloads blobs through adapter put/get branches', async () => {
319
- const app = buildApp({
320
- db,
321
- tokenSigner,
322
- adapter: createDefaultAdapter(db, tokenSigner),
323
- });
324
-
325
- const content = new TextEncoder().encode('adapter-route-content');
326
- const hash = await createHash(content);
327
- const init = await initiateUpload({
328
- app,
329
- hash,
330
- size: content.length,
331
- mimeType: 'text/plain',
332
- });
333
-
334
- expect(init.exists).toBe(false);
335
- expect(typeof init.uploadUrl).toBe('string');
336
-
337
- const uploadResponse = await app.request(init.uploadUrl!, {
338
- method: init.uploadMethod ?? 'PUT',
339
- headers: { 'content-type': 'text/plain' },
340
- body: content,
341
- });
342
- expect(uploadResponse.status).toBe(200);
343
-
344
- const completeResponse = await app.request(
345
- `http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
346
- {
347
- method: 'POST',
348
- headers: { [ACTOR_HEADER]: ACTOR_ID },
349
- }
350
- );
351
- expect(completeResponse.status).toBe(200);
352
- expect((await completeResponse.json()) as CompleteResponse).toEqual({
353
- ok: true,
354
- metadata: expect.anything(),
355
- });
356
-
357
- const downloadUrlResponse = await app.request(
358
- `http://localhost/sync/blobs/${encodeURIComponent(hash)}/url`,
359
- {
360
- headers: { [ACTOR_HEADER]: ACTOR_ID },
361
- }
362
- );
363
- expect(downloadUrlResponse.status).toBe(200);
364
- const { url } = (await downloadUrlResponse.json()) as UrlResponse;
365
-
366
- const downloadResponse = await app.request(url);
367
- expect(downloadResponse.status).toBe(200);
368
- expect(new Uint8Array(await downloadResponse.arrayBuffer())).toEqual(
369
- content
370
- );
371
- });
372
-
373
- it('uploads and downloads blobs through DB fallback branches when adapter lacks put/get', async () => {
374
- const app = buildApp({
375
- db,
376
- tokenSigner,
377
- adapter: createFallbackAdapter(db, tokenSigner),
378
- });
379
-
380
- const content = new TextEncoder().encode('database-fallback-content');
381
- const hash = await createHash(content);
382
- const init = await initiateUpload({
383
- app,
384
- hash,
385
- size: content.length,
386
- mimeType: 'text/plain',
387
- });
388
-
389
- const uploadResponse = await app.request(init.uploadUrl!, {
390
- method: init.uploadMethod ?? 'PUT',
391
- headers: { 'content-type': 'text/plain' },
392
- body: content,
393
- });
394
- expect(uploadResponse.status).toBe(200);
395
-
396
- const completeResponse = await app.request(
397
- `http://localhost/sync/blobs/${encodeURIComponent(hash)}/complete`,
398
- {
399
- method: 'POST',
400
- headers: { [ACTOR_HEADER]: ACTOR_ID },
401
- }
402
- );
403
- expect(completeResponse.status).toBe(200);
404
- expect((await completeResponse.json()) as CompleteResponse).toEqual({
405
- ok: true,
406
- metadata: expect.anything(),
407
- });
408
-
409
- const urlResponse = await app.request(
410
- `http://localhost/sync/blobs/${encodeURIComponent(hash)}/url`,
411
- {
412
- headers: { [ACTOR_HEADER]: ACTOR_ID },
413
- }
414
- );
415
- expect(urlResponse.status).toBe(200);
416
- const payload = (await urlResponse.json()) as UrlResponse;
417
-
418
- const downloadResponse = await app.request(payload.url);
419
- expect(downloadResponse.status).toBe(200);
420
- expect(new Uint8Array(await downloadResponse.arrayBuffer())).toEqual(
421
- content
422
- );
423
- });
424
- });
@@ -1,297 +0,0 @@
1
- import { describe, expect, it } from 'bun:test';
2
- import { Hono } from 'hono';
3
- import { defineWebSocketHelper, WSContext, type WSEvents } from 'hono/ws';
4
- import { createConsoleGatewayRoutes } from '../console';
5
-
6
- const CONSOLE_TOKEN = 'gateway-token';
7
-
8
- function isRecord(value: unknown): value is Record<string, unknown> {
9
- return typeof value === 'object' && value !== null && !Array.isArray(value);
10
- }
11
-
12
- class MockDownstreamSocket {
13
- url: string;
14
- onopen: ((event: Event) => void) | null = null;
15
- onmessage: ((event: MessageEvent) => void) | null = null;
16
- onerror: ((event: Event) => void) | null = null;
17
- closeCalls = 0;
18
- sent: string[] = [];
19
-
20
- constructor(url: string) {
21
- this.url = url;
22
- }
23
-
24
- emitOpen() {
25
- this.onopen?.(new Event('open'));
26
- }
27
-
28
- emitJson(payload: Record<string, unknown>) {
29
- this.onmessage?.(
30
- new MessageEvent('message', { data: JSON.stringify(payload) })
31
- );
32
- }
33
-
34
- emitError() {
35
- this.onerror?.(new Event('error'));
36
- }
37
-
38
- close() {
39
- this.closeCalls += 1;
40
- }
41
-
42
- send(data: string) {
43
- this.sent.push(data);
44
- }
45
- }
46
-
47
- function createGatewayLiveHarness() {
48
- const downstreamSockets: MockDownstreamSocket[] = [];
49
- let capturedEvents: WSEvents | null = null;
50
-
51
- const app = new Hono();
52
- const upgradeWebSocket = defineWebSocketHelper(async (_c, events) => {
53
- capturedEvents = events;
54
- return new Response(null, { status: 200 });
55
- });
56
-
57
- app.route(
58
- '/console',
59
- createConsoleGatewayRoutes({
60
- instances: [
61
- {
62
- instanceId: 'alpha',
63
- label: 'Alpha',
64
- baseUrl: 'https://alpha.example.test/api/alpha',
65
- },
66
- {
67
- instanceId: 'beta',
68
- label: 'Beta',
69
- baseUrl: 'https://beta.example.test/api/beta',
70
- },
71
- ],
72
- authenticate: async (c) => {
73
- const authHeader = c.req.header('Authorization');
74
- if (authHeader === `Bearer ${CONSOLE_TOKEN}`) {
75
- return { consoleUserId: 'gateway-user' };
76
- }
77
- return null;
78
- },
79
- websocket: {
80
- enabled: true,
81
- upgradeWebSocket,
82
- heartbeatIntervalMs: 60000,
83
- createWebSocket: (url) => {
84
- const socket = new MockDownstreamSocket(url);
85
- downstreamSockets.push(socket);
86
- return socket;
87
- },
88
- },
89
- })
90
- );
91
-
92
- return {
93
- app,
94
- downstreamSockets,
95
- getEvents: () => capturedEvents,
96
- };
97
- }
98
-
99
- function createUpstreamSocketHarness() {
100
- const messages: Array<Record<string, unknown>> = [];
101
- const closes: Array<{ code?: number; reason?: string }> = [];
102
-
103
- const ws = new WSContext({
104
- readyState: 1,
105
- send(data) {
106
- if (typeof data !== 'string') {
107
- return;
108
- }
109
-
110
- const parsed = JSON.parse(data);
111
- if (isRecord(parsed)) {
112
- messages.push(parsed);
113
- }
114
- },
115
- close(code, reason) {
116
- closes.push({ code, reason });
117
- },
118
- });
119
-
120
- return {
121
- ws,
122
- messages,
123
- closes,
124
- };
125
- }
126
-
127
- describe('createConsoleGatewayRoutes live fan-in', () => {
128
- it('fans in downstream websocket events and emits instance degradation markers', async () => {
129
- const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
130
-
131
- const response = await app.request(
132
- 'http://localhost/console/events/live?instanceIds=alpha,beta&partitionId=tenant-a&replayLimit=42',
133
- {
134
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
135
- }
136
- );
137
- expect(response.status).toBe(200);
138
-
139
- const events = getEvents();
140
- if (!events?.onOpen || !events.onError) {
141
- throw new Error('Expected websocket lifecycle handlers to be captured.');
142
- }
143
-
144
- const upstream = createUpstreamSocketHarness();
145
- events.onOpen(new Event('open'), upstream.ws);
146
-
147
- expect(downstreamSockets).toHaveLength(2);
148
-
149
- const alphaSocket = downstreamSockets.find((socket) =>
150
- socket.url.includes('alpha.example.test')
151
- );
152
- const betaSocket = downstreamSockets.find((socket) =>
153
- socket.url.includes('beta.example.test')
154
- );
155
-
156
- if (!alphaSocket || !betaSocket) {
157
- throw new Error(
158
- 'Expected both alpha and beta downstream websocket links.'
159
- );
160
- }
161
-
162
- const alphaUrl = new URL(alphaSocket.url);
163
- expect(alphaUrl.pathname).toBe('/api/alpha/console/events/live');
164
- expect(alphaUrl.searchParams.get('token')).toBeNull();
165
- expect(alphaUrl.searchParams.get('partitionId')).toBe('tenant-a');
166
- expect(alphaUrl.searchParams.get('replayLimit')).toBe('42');
167
-
168
- alphaSocket.emitOpen();
169
- betaSocket.emitOpen();
170
- expect(alphaSocket.sent[0]).toBe(
171
- JSON.stringify({ type: 'auth', token: CONSOLE_TOKEN })
172
- );
173
- expect(betaSocket.sent[0]).toBe(
174
- JSON.stringify({ type: 'auth', token: CONSOLE_TOKEN })
175
- );
176
-
177
- const connectedEvent = upstream.messages.find(
178
- (message) => message.type === 'connected'
179
- );
180
- expect(connectedEvent?.instanceCount).toBe(2);
181
-
182
- alphaSocket.emitJson({
183
- type: 'push',
184
- timestamp: '2026-02-17T11:00:00.000Z',
185
- data: {
186
- partitionId: 'tenant-a',
187
- requestId: 'alpha-req-1',
188
- },
189
- });
190
-
191
- const pushEvent = upstream.messages.find(
192
- (message) => message.type === 'push'
193
- );
194
- expect(pushEvent?.instanceId).toBe('alpha');
195
- if (!isRecord(pushEvent?.data)) {
196
- throw new Error('Expected push event to include object data payload.');
197
- }
198
- expect(pushEvent.data.instanceId).toBe('alpha');
199
- expect(pushEvent.data.partitionId).toBe('tenant-a');
200
-
201
- betaSocket.emitError();
202
- const degradedEvent = upstream.messages.find(
203
- (message) => message.type === 'instance_error'
204
- );
205
- expect(degradedEvent?.instanceId).toBe('beta');
206
-
207
- events.onError(new Event('error'), upstream.ws);
208
- expect(downstreamSockets.every((socket) => socket.closeCalls === 1)).toBe(
209
- true
210
- );
211
- });
212
-
213
- it('accepts auth over first websocket message when headers are missing', async () => {
214
- const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
215
-
216
- const response = await app.request('http://localhost/console/events/live');
217
- expect(response.status).toBe(200);
218
-
219
- const events = getEvents();
220
- if (!events?.onOpen || !events.onMessage) {
221
- throw new Error('Expected websocket onOpen handler to be captured.');
222
- }
223
-
224
- const upstream = createUpstreamSocketHarness();
225
- events.onOpen(new Event('open'), upstream.ws);
226
- expect(downstreamSockets).toHaveLength(0);
227
-
228
- await events.onMessage(
229
- new MessageEvent('message', {
230
- data: JSON.stringify({ type: 'auth', token: CONSOLE_TOKEN }),
231
- }),
232
- upstream.ws
233
- );
234
-
235
- expect(upstream.messages[0]?.type).toBe('connected');
236
- expect(downstreamSockets).toHaveLength(2);
237
- });
238
-
239
- it('closes the upstream socket when websocket auth token is invalid', async () => {
240
- const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
241
-
242
- const response = await app.request('http://localhost/console/events/live');
243
- expect(response.status).toBe(200);
244
-
245
- const events = getEvents();
246
- if (!events?.onOpen || !events.onMessage) {
247
- throw new Error('Expected websocket handlers to be captured.');
248
- }
249
-
250
- const upstream = createUpstreamSocketHarness();
251
- events.onOpen(new Event('open'), upstream.ws);
252
- await events.onMessage(
253
- new MessageEvent('message', {
254
- data: JSON.stringify({ type: 'auth', token: 'bad-token' }),
255
- }),
256
- upstream.ws
257
- );
258
-
259
- const errorEvent = upstream.messages[0];
260
- expect(errorEvent?.type).toBe('error');
261
- expect(errorEvent?.message).toBe('UNAUTHENTICATED');
262
- expect(upstream.closes).toEqual([
263
- { code: 4001, reason: 'Unauthenticated' },
264
- ]);
265
- expect(downstreamSockets).toHaveLength(0);
266
- });
267
-
268
- it('closes the upstream socket when no instances match the filter', async () => {
269
- const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
270
-
271
- const response = await app.request(
272
- 'http://localhost/console/events/live?instanceId=missing',
273
- {
274
- headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
275
- }
276
- );
277
- expect(response.status).toBe(200);
278
-
279
- const events = getEvents();
280
- if (!events?.onOpen) {
281
- throw new Error('Expected websocket onOpen handler to be captured.');
282
- }
283
-
284
- const upstream = createUpstreamSocketHarness();
285
- events.onOpen(new Event('open'), upstream.ws);
286
-
287
- const errorEvent = upstream.messages[0];
288
- expect(errorEvent?.type).toBe('error');
289
- expect(errorEvent?.message).toBe(
290
- 'No enabled instances matched the provided instance filter.'
291
- );
292
- expect(upstream.closes).toEqual([
293
- { code: 4004, reason: 'No instances selected' },
294
- ]);
295
- expect(downstreamSockets).toHaveLength(0);
296
- });
297
- });