@syncular/server-hono 0.0.2-2 → 0.0.3-12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server-hono",
3
- "version": "0.0.2-2",
3
+ "version": "0.0.3-12",
4
4
  "description": "Hono adapter for the Syncular server with OpenAPI support",
5
5
  "license": "MIT",
6
6
  "author": "Benjamin Kniffler",
@@ -48,16 +48,16 @@
48
48
  "@hono/standard-validator": "^0.2.2",
49
49
  "@standard-community/standard-json": "^0.3.5",
50
50
  "@standard-community/standard-openapi": "^0.2.9",
51
- "@syncular/core": "0.0.2-2",
52
- "@syncular/server": "0.0.2-2",
51
+ "@syncular/core": "0.0.3-12",
52
+ "@syncular/server": "0.0.3-12",
53
53
  "@types/json-schema": "^7.0.15",
54
54
  "hono-openapi": "^1.2.0",
55
55
  "openapi-types": "^12.1.3"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@syncular/config": "0.0.0",
59
- "@syncular/dialect-pglite": "0.0.2-2",
60
- "@syncular/server-dialect-postgres": "0.0.2-2",
59
+ "@syncular/dialect-pglite": "0.0.3-12",
60
+ "@syncular/server-dialect-postgres": "0.0.3-12",
61
61
  "kysely": "*",
62
62
  "zod": "*"
63
63
  },
@@ -0,0 +1,246 @@
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
+ onmessage: ((event: MessageEvent) => void) | null = null;
15
+ onerror: ((event: Event) => void) | null = null;
16
+ closeCalls = 0;
17
+
18
+ constructor(url: string) {
19
+ this.url = url;
20
+ }
21
+
22
+ emitJson(payload: Record<string, unknown>) {
23
+ this.onmessage?.(
24
+ new MessageEvent('message', { data: JSON.stringify(payload) })
25
+ );
26
+ }
27
+
28
+ emitError() {
29
+ this.onerror?.(new Event('error'));
30
+ }
31
+
32
+ close() {
33
+ this.closeCalls += 1;
34
+ }
35
+ }
36
+
37
+ function createGatewayLiveHarness() {
38
+ const downstreamSockets: MockDownstreamSocket[] = [];
39
+ let capturedEvents: WSEvents | null = null;
40
+
41
+ const app = new Hono();
42
+ const upgradeWebSocket = defineWebSocketHelper(async (_c, events) => {
43
+ capturedEvents = events;
44
+ return new Response(null, { status: 200 });
45
+ });
46
+
47
+ app.route(
48
+ '/console',
49
+ createConsoleGatewayRoutes({
50
+ instances: [
51
+ {
52
+ instanceId: 'alpha',
53
+ label: 'Alpha',
54
+ baseUrl: 'https://alpha.example.test/api/alpha',
55
+ },
56
+ {
57
+ instanceId: 'beta',
58
+ label: 'Beta',
59
+ baseUrl: 'https://beta.example.test/api/beta',
60
+ },
61
+ ],
62
+ authenticate: async (c) => {
63
+ const authHeader = c.req.header('Authorization');
64
+ if (authHeader === `Bearer ${CONSOLE_TOKEN}`) {
65
+ return { consoleUserId: 'gateway-user' };
66
+ }
67
+ return null;
68
+ },
69
+ websocket: {
70
+ enabled: true,
71
+ upgradeWebSocket,
72
+ heartbeatIntervalMs: 60000,
73
+ createWebSocket: (url) => {
74
+ const socket = new MockDownstreamSocket(url);
75
+ downstreamSockets.push(socket);
76
+ return socket;
77
+ },
78
+ },
79
+ })
80
+ );
81
+
82
+ return {
83
+ app,
84
+ downstreamSockets,
85
+ getEvents: () => capturedEvents,
86
+ };
87
+ }
88
+
89
+ function createUpstreamSocketHarness() {
90
+ const messages: Array<Record<string, unknown>> = [];
91
+ const closes: Array<{ code?: number; reason?: string }> = [];
92
+
93
+ const ws = new WSContext({
94
+ readyState: 1,
95
+ send(data) {
96
+ if (typeof data !== 'string') {
97
+ return;
98
+ }
99
+
100
+ const parsed = JSON.parse(data);
101
+ if (isRecord(parsed)) {
102
+ messages.push(parsed);
103
+ }
104
+ },
105
+ close(code, reason) {
106
+ closes.push({ code, reason });
107
+ },
108
+ });
109
+
110
+ return {
111
+ ws,
112
+ messages,
113
+ closes,
114
+ };
115
+ }
116
+
117
+ describe('createConsoleGatewayRoutes live fan-in', () => {
118
+ it('fans in downstream websocket events and emits instance degradation markers', async () => {
119
+ const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
120
+
121
+ const response = await app.request(
122
+ 'http://localhost/console/events/live?instanceIds=alpha,beta&partitionId=tenant-a&replayLimit=42',
123
+ {
124
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
125
+ }
126
+ );
127
+ expect(response.status).toBe(200);
128
+
129
+ const events = getEvents();
130
+ if (!events?.onOpen || !events.onError) {
131
+ throw new Error('Expected websocket lifecycle handlers to be captured.');
132
+ }
133
+
134
+ const upstream = createUpstreamSocketHarness();
135
+ events.onOpen(new Event('open'), upstream.ws);
136
+
137
+ expect(downstreamSockets).toHaveLength(2);
138
+
139
+ const alphaSocket = downstreamSockets.find((socket) =>
140
+ socket.url.includes('alpha.example.test')
141
+ );
142
+ const betaSocket = downstreamSockets.find((socket) =>
143
+ socket.url.includes('beta.example.test')
144
+ );
145
+
146
+ if (!alphaSocket || !betaSocket) {
147
+ throw new Error(
148
+ 'Expected both alpha and beta downstream websocket links.'
149
+ );
150
+ }
151
+
152
+ const alphaUrl = new URL(alphaSocket.url);
153
+ expect(alphaUrl.pathname).toBe('/api/alpha/console/events/live');
154
+ expect(alphaUrl.searchParams.get('token')).toBe(CONSOLE_TOKEN);
155
+ expect(alphaUrl.searchParams.get('partitionId')).toBe('tenant-a');
156
+ expect(alphaUrl.searchParams.get('replayLimit')).toBe('42');
157
+
158
+ const connectedEvent = upstream.messages.find(
159
+ (message) => message.type === 'connected'
160
+ );
161
+ expect(connectedEvent?.instanceCount).toBe(2);
162
+
163
+ alphaSocket.emitJson({
164
+ type: 'push',
165
+ timestamp: '2026-02-17T11:00:00.000Z',
166
+ data: {
167
+ partitionId: 'tenant-a',
168
+ requestId: 'alpha-req-1',
169
+ },
170
+ });
171
+
172
+ const pushEvent = upstream.messages.find(
173
+ (message) => message.type === 'push'
174
+ );
175
+ expect(pushEvent?.instanceId).toBe('alpha');
176
+ if (!isRecord(pushEvent?.data)) {
177
+ throw new Error('Expected push event to include object data payload.');
178
+ }
179
+ expect(pushEvent.data.instanceId).toBe('alpha');
180
+ expect(pushEvent.data.partitionId).toBe('tenant-a');
181
+
182
+ betaSocket.emitError();
183
+ const degradedEvent = upstream.messages.find(
184
+ (message) => message.type === 'instance_error'
185
+ );
186
+ expect(degradedEvent?.instanceId).toBe('beta');
187
+
188
+ events.onError(new Event('error'), upstream.ws);
189
+ expect(downstreamSockets.every((socket) => socket.closeCalls === 1)).toBe(
190
+ true
191
+ );
192
+ });
193
+
194
+ it('closes the upstream socket when auth is missing', async () => {
195
+ const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
196
+
197
+ const response = await app.request('http://localhost/console/events/live');
198
+ expect(response.status).toBe(200);
199
+
200
+ const events = getEvents();
201
+ if (!events?.onOpen) {
202
+ throw new Error('Expected websocket onOpen handler to be captured.');
203
+ }
204
+
205
+ const upstream = createUpstreamSocketHarness();
206
+ events.onOpen(new Event('open'), upstream.ws);
207
+
208
+ const errorEvent = upstream.messages[0];
209
+ expect(errorEvent?.type).toBe('error');
210
+ expect(errorEvent?.message).toBe('UNAUTHENTICATED');
211
+ expect(upstream.closes).toEqual([
212
+ { code: 4001, reason: 'Unauthenticated' },
213
+ ]);
214
+ expect(downstreamSockets).toHaveLength(0);
215
+ });
216
+
217
+ it('closes the upstream socket when no instances match the filter', async () => {
218
+ const { app, downstreamSockets, getEvents } = createGatewayLiveHarness();
219
+
220
+ const response = await app.request(
221
+ 'http://localhost/console/events/live?instanceId=missing',
222
+ {
223
+ headers: { Authorization: `Bearer ${CONSOLE_TOKEN}` },
224
+ }
225
+ );
226
+ expect(response.status).toBe(200);
227
+
228
+ const events = getEvents();
229
+ if (!events?.onOpen) {
230
+ throw new Error('Expected websocket onOpen handler to be captured.');
231
+ }
232
+
233
+ const upstream = createUpstreamSocketHarness();
234
+ events.onOpen(new Event('open'), upstream.ws);
235
+
236
+ const errorEvent = upstream.messages[0];
237
+ expect(errorEvent?.type).toBe('error');
238
+ expect(errorEvent?.message).toBe(
239
+ 'No enabled instances matched the provided instance filter.'
240
+ );
241
+ expect(upstream.closes).toEqual([
242
+ { code: 4004, reason: 'No instances selected' },
243
+ ]);
244
+ expect(downstreamSockets).toHaveLength(0);
245
+ });
246
+ });