@evolu/nodejs 3.0.0-next.0 → 3.0.0-next.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Evolu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,3 +1,4 @@
1
1
  import type { TimingSafeEqual } from "@evolu/common";
2
+ /** Creates the Node.js implementation of {@link TimingSafeEqual}. */
2
3
  export declare const createTimingSafeEqual: () => TimingSafeEqual;
3
4
  //# sourceMappingURL=Crypto.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Crypto.d.ts","sourceRoot":"","sources":["../../src/Crypto.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,eAAO,MAAM,qBAAqB,QAAO,eAAkC,CAAC"}
1
+ {"version":3,"file":"Crypto.d.ts","sourceRoot":"","sources":["../../src/Crypto.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,qEAAqE;AACrE,eAAO,MAAM,qBAAqB,QAAO,eAAkC,CAAC"}
@@ -1,2 +1,3 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
+ /** Creates the Node.js implementation of {@link TimingSafeEqual}. */
2
3
  export const createTimingSafeEqual = () => timingSafeEqual;
@@ -0,0 +1,19 @@
1
+ import { type ClientRequest, type IncomingMessage } from "http";
2
+ /**
3
+ * A raw WebSocket upgrade request prepared for tests by
4
+ * {@link testSetupWebSocketUpgradeRequest}.
5
+ */
6
+ export interface TestSetupWebSocketUpgradeRequest extends AsyncDisposable {
7
+ readonly req: ClientRequest;
8
+ }
9
+ /**
10
+ * Creates a raw WebSocket upgrade request test setup for tests that need manual
11
+ * request control.
12
+ */
13
+ export declare const testSetupWebSocketUpgradeRequest: (port: number, path: string) => TestSetupWebSocketUpgradeRequest;
14
+ /**
15
+ * Sends a raw WebSocket upgrade request and resolves with the rejection
16
+ * response.
17
+ */
18
+ export declare const testSendWebSocketUpgradeRequest: (port: number, path: string) => Promise<IncomingMessage>;
19
+ //# sourceMappingURL=WebSocket.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WebSocket.d.ts","sourceRoot":"","sources":["../../src/WebSocket.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,MAAM,CAAC;AAEzE;;;GAGG;AACH,MAAM,WAAW,gCAAiC,SAAQ,eAAe;IACvE,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;CAC7B;AAED;;;GAGG;AACH,eAAO,MAAM,gCAAgC,GAC3C,MAAM,MAAM,EACZ,MAAM,MAAM,KACX,gCAWF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,+BAA+B,GAC1C,MAAM,MAAM,EACZ,MAAM,MAAM,KACX,OAAO,CAAC,eAAe,CAezB,CAAC"}
@@ -0,0 +1,46 @@
1
+ import { request } from "http";
2
+ /**
3
+ * Creates a raw WebSocket upgrade request test setup for tests that need manual
4
+ * request control.
5
+ */
6
+ export const testSetupWebSocketUpgradeRequest = (port, path) => {
7
+ const req = createWebSocketUpgradeRequest(port, path);
8
+ req.on("error", () => undefined);
9
+ return {
10
+ req,
11
+ [Symbol.asyncDispose]: () => {
12
+ req.destroy();
13
+ return Promise.resolve();
14
+ },
15
+ };
16
+ };
17
+ /**
18
+ * Sends a raw WebSocket upgrade request and resolves with the rejection
19
+ * response.
20
+ */
21
+ export const testSendWebSocketUpgradeRequest = async (port, path) => {
22
+ const req = createWebSocketUpgradeRequest(port, path);
23
+ return new Promise((resolve, reject) => {
24
+ req.once("response", (response) => {
25
+ response.resume();
26
+ resolve(response);
27
+ });
28
+ req.once("upgrade", (_response, socket) => {
29
+ socket.destroy();
30
+ reject(new Error("Expected HTTP upgrade rejection"));
31
+ });
32
+ req.once("error", reject);
33
+ req.end();
34
+ });
35
+ };
36
+ const createWebSocketUpgradeRequest = (port, path) => request({
37
+ host: "127.0.0.1",
38
+ port,
39
+ path,
40
+ headers: {
41
+ Connection: "Upgrade",
42
+ Upgrade: "websocket",
43
+ "Sec-WebSocket-Version": "13",
44
+ "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
45
+ },
46
+ });
@@ -2,4 +2,5 @@ export * from "./Sqlite.js";
2
2
  export * from "./Crypto.js";
3
3
  export * from "./local-first/Relay.js";
4
4
  export * from "./Task.js";
5
+ export * from "./WebSocket.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,wBAAwB,CAAC;AACvC,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,wBAAwB,CAAC;AACvC,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC"}
package/dist/src/index.js CHANGED
@@ -2,3 +2,4 @@ export * from "./Sqlite.js";
2
2
  export * from "./Crypto.js";
3
3
  export * from "./local-first/Relay.js";
4
4
  export * from "./Task.js";
5
+ export * from "./WebSocket.js";
@@ -37,8 +37,8 @@ export declare const createRelayDeps: () => RelayDeps;
37
37
  * startRelay({
38
38
  * port: 4000,
39
39
  *
40
- * // Note: Relay requires URL in format ws://host:port/<ownerId>
41
- * // isOwnerAllowed: (_ownerId) => true,
40
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
41
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
42
42
  *
43
43
  * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
44
44
  * const maxBytes = 1024 * 1024; // 1MB
@@ -1 +1 @@
1
- {"version":3,"file":"Relay.d.ts","sourceRoot":"","sources":["../../../src/local-first/Relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,qBAAqB,EAK1B,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,kBAAkB,EAExB,MAAM,eAAe,CAAC;AACvB,OAAO,EAQL,KAAK,KAAK,EACV,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AAOnC,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACpD,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,SAAS,GAAG,qBAAqB,GAAG,SAAS,GAAG,kBAAkB,CAAC;AAE/E,gEAAgE;AAChE,eAAO,MAAM,eAAe,QAAO,SAIjC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,eAAO,MAAM,UAAU,GACpB,qDAKE,iBAAiB,KAAG,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,CAoKlD,CAAC"}
1
+ {"version":3,"file":"Relay.d.ts","sourceRoot":"","sources":["../../../src/local-first/Relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,qBAAqB,EAI1B,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,kBAAkB,EAExB,MAAM,eAAe,CAAC;AACvB,OAAO,EAQL,KAAK,KAAK,EACV,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AAQnC,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACpD,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,SAAS,GAAG,qBAAqB,GAAG,SAAS,GAAG,kBAAkB,CAAC;AAE/E,gEAAgE;AAChE,eAAO,MAAM,eAAe,QAAO,SAIjC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,eAAO,MAAM,UAAU,GACpB,qDAKE,iBAAiB,KAAG,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,CA6MlD,CAAC"}
@@ -50,8 +50,9 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
50
  var e = new Error(message);
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
- import { createRandom, createRelation, createSqlite, isPromiseLike, Name, ok, OwnerId, Uint8Array, } from "@evolu/common";
53
+ import { AbortError, assert, callback, createRandom, createRelation, createSqlite, Name, ok, OwnerId, Uint8Array, } from "@evolu/common";
54
54
  import { applyProtocolMessageAsRelay, createBaseSqliteStorageTables, createRelaySqliteStorage, createRelayStorageTables, defaultProtocolMessageMaxSize, parseOwnerIdFromOwnerWebSocketTransportUrl, } from "@evolu/common/local-first";
55
+ import { once } from "events";
55
56
  import { existsSync } from "fs";
56
57
  import { createServer } from "http";
57
58
  import { WebSocket, WebSocketServer } from "ws";
@@ -93,8 +94,8 @@ export const createRelayDeps = () => ({
93
94
  * startRelay({
94
95
  * port: 4000,
95
96
  *
96
- * // Note: Relay requires URL in format ws://host:port/<ownerId>
97
- * // isOwnerAllowed: (_ownerId) => true,
97
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
98
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
98
99
  *
99
100
  * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
100
101
  * const maxBytes = 1024 * 1024; // 1MB
@@ -112,27 +113,35 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
112
113
  try {
113
114
  const stack = __addDisposableResource(env_1, new AsyncDisposableStack(), true);
114
115
  const console = run.deps.console.child("relay");
116
+ stack.defer(() => {
117
+ console.info("Shutdown complete");
118
+ });
115
119
  const dbFileExists = existsSync(`${name}.db`);
116
- const sqliteResult = await run(createSqlite(name));
117
- if (!sqliteResult.ok)
118
- return sqliteResult;
119
- const sqlite = stack.use(sqliteResult.value);
120
+ const sqlite = stack.use(await run.orThrow(createSqlite(name)));
120
121
  const deps = { ...run.deps, sqlite };
121
122
  if (!dbFileExists) {
122
123
  createBaseSqliteStorageTables(deps);
123
124
  createRelayStorageTables(deps);
124
125
  }
125
- const relayRun = run.create().addDeps({
126
- storage: createRelaySqliteStorage(deps)({
127
- isOwnerWithinQuota,
128
- }),
126
+ const server = stack.use(createServer());
127
+ server.once("close", () => {
128
+ console.info("HTTP server closed");
129
129
  });
130
- const server = createServer();
131
- const wss = new WebSocketServer({
130
+ const wss = stack.adopt(new WebSocketServer({
132
131
  maxPayload: defaultProtocolMessageMaxSize,
133
132
  noServer: true,
134
- });
133
+ }), (wss) => new Promise((resolve) => {
134
+ wss.close(() => {
135
+ console.info("WebSocketServer closed");
136
+ resolve();
137
+ });
138
+ }));
135
139
  const ownerSocketRelation = createRelation();
140
+ const relayRun = stack.use(run.create().addDeps({
141
+ storage: createRelaySqliteStorage(deps)({
142
+ isOwnerWithinQuota,
143
+ }),
144
+ }));
136
145
  server.on("upgrade", (request, socket, head) => {
137
146
  socket.on("error", console.debug);
138
147
  const completeUpgrade = () => {
@@ -145,24 +154,48 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
145
154
  completeUpgrade();
146
155
  return;
147
156
  }
148
- const ownerId = parseOwnerIdFromOwnerWebSocketTransportUrl(request.url ?? "");
149
- if (!ownerId) {
150
- console.debug("invalid or missing ownerId in URL", request.url);
151
- socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
157
+ const respondAndDestroy = (statusCode) => {
158
+ if (socket.destroyed)
159
+ return;
160
+ socket.write(`HTTP/1.1 ${statusCode} ${HttpStatusTextByCode[statusCode]}\r\n\r\n`);
152
161
  socket.destroy();
162
+ };
163
+ const requestUrl = request.url;
164
+ const ownerId = requestUrl
165
+ ? parseOwnerIdFromOwnerWebSocketTransportUrl(requestUrl)
166
+ : undefined;
167
+ if (!ownerId) {
168
+ console.debug("invalid or missing ownerId in URL", requestUrl);
169
+ respondAndDestroy(400);
153
170
  return;
154
171
  }
155
- void (async () => {
156
- const result = isOwnerAllowed(ownerId);
157
- const isAllowed = isPromiseLike(result) ? await result : result;
158
- if (!isAllowed) {
159
- console.debug("unauthorized owner", ownerId);
160
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
172
+ const authorizationFiber = relayRun(callback(({ ok, err, signal }) => {
173
+ void Promise.try(() => isOwnerAllowed(ownerId, { signal })).then(ok, err);
174
+ }));
175
+ const abortAuthorization = () => {
176
+ authorizationFiber.abort("WebSocket upgrade request socket closed");
177
+ };
178
+ socket.once("close", abortAuthorization);
179
+ socket.once("error", abortAuthorization);
180
+ void authorizationFiber.then((result) => {
181
+ socket.removeListener("close", abortAuthorization);
182
+ socket.removeListener("error", abortAuthorization);
183
+ if (!result.ok) {
184
+ if (!AbortError.is(result.error)) {
185
+ console.error("isOwnerAllowed failed", ownerId, result.error);
186
+ respondAndDestroy(503);
187
+ return;
188
+ }
161
189
  socket.destroy();
162
190
  return;
163
191
  }
192
+ if (!result.value) {
193
+ console.debug("unauthorized owner", ownerId);
194
+ respondAndDestroy(401);
195
+ return;
196
+ }
164
197
  completeUpgrade();
165
- })();
198
+ });
166
199
  });
167
200
  wss.on("connection", (ws) => {
168
201
  console.debug("on connection", wss.clients.size);
@@ -201,23 +234,6 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
201
234
  console.debug("ws close", wss.clients.size);
202
235
  });
203
236
  });
204
- // Cleanup runs in LIFO order: clients → WebSocketServer → HTTP server
205
- stack.defer(() => {
206
- console.info("Shutdown complete");
207
- });
208
- stack.defer(() => {
209
- server.close(() => {
210
- console.info("HTTP server closed");
211
- });
212
- });
213
- stack.defer(() => {
214
- // wss.close() emits 'close' when all clients have disconnected
215
- // https://github.com/websockets/ws/blob/master/doc/ws.md#serverclosecallback
216
- wss.close(() => {
217
- console.info("WebSocketServer closed");
218
- ok();
219
- });
220
- });
221
237
  stack.defer(() => {
222
238
  console.info("Shutting down...");
223
239
  for (const client of wss.clients) {
@@ -226,10 +242,16 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
226
242
  }
227
243
  }
228
244
  });
229
- stack.use(relayRun);
230
245
  server.listen(port);
231
- console.info(`Started on port ${port}`);
232
- return ok(stack.move());
246
+ await once(server, "listening");
247
+ const address = server.address();
248
+ assert(address && typeof address !== "string", "Expected TCP address");
249
+ const moved = stack.move();
250
+ console.info(`Started on port ${address.port}`);
251
+ return ok({
252
+ port: address.port,
253
+ [Symbol.asyncDispose]: () => moved.disposeAsync(),
254
+ });
233
255
  }
234
256
  catch (e_1) {
235
257
  env_1.error = e_1;
@@ -241,3 +263,8 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
241
263
  await result_1;
242
264
  }
243
265
  };
266
+ const HttpStatusTextByCode = {
267
+ 400: "Bad Request",
268
+ 401: "Unauthorized",
269
+ 503: "Service Unavailable",
270
+ };
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@evolu/nodejs",
3
- "version": "3.0.0-next.0",
3
+ "version": "3.0.0-next.2",
4
4
  "description": "Evolu for Node.js",
5
5
  "author": "Daniel Steigerwald <daniel@steigerwald.cz>",
6
6
  "license": "MIT",
7
- "repository": "evoluhq/evolu",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/evoluhq/evolu.git"
10
+ },
8
11
  "bugs": {
9
12
  "url": "https://github.com/evoluhq/evolu/issues"
10
13
  },
@@ -23,22 +26,17 @@
23
26
  "src/**",
24
27
  "README.md"
25
28
  ],
26
- "scripts": {
27
- "build": "tsc --build tsconfig.build.json",
28
- "prepack": "npm run build",
29
- "format": "prettier --write \"src/*.{ts,tsx,md}\""
30
- },
31
29
  "dependencies": {
32
30
  "better-sqlite3": "^12.6.2",
33
31
  "ws": "^8.19.0"
34
32
  },
35
33
  "devDependencies": {
36
- "@evolu/common": "8.0.0-next.0",
37
- "@evolu/typescript-config": "*",
38
34
  "@types/better-sqlite3": "^7.6.13",
39
35
  "@types/node": "^24.10.9",
40
36
  "@types/ws": "^8.18.1",
41
- "typescript": "^5.9.3"
37
+ "typescript": "^6",
38
+ "@evolu/common": "8.0.0-next.2",
39
+ "@evolu/typescript-config": "0.0.2"
42
40
  },
43
41
  "peerDependencies": {
44
42
  "@evolu/common": "^8.0.0-next.0"
@@ -48,5 +46,9 @@
48
46
  },
49
47
  "publishConfig": {
50
48
  "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc --build tsconfig.build.json",
52
+ "format": "prettier --write \"src/*.{ts,tsx,md}\""
51
53
  }
52
- }
54
+ }
package/src/Crypto.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { TimingSafeEqual } from "@evolu/common";
2
2
  import { timingSafeEqual } from "node:crypto";
3
3
 
4
+ /** Creates the Node.js implementation of {@link TimingSafeEqual}. */
4
5
  export const createTimingSafeEqual = (): TimingSafeEqual => timingSafeEqual;
@@ -0,0 +1,69 @@
1
+ import { request, type ClientRequest, type IncomingMessage } from "http";
2
+
3
+ /**
4
+ * A raw WebSocket upgrade request prepared for tests by
5
+ * {@link testSetupWebSocketUpgradeRequest}.
6
+ */
7
+ export interface TestSetupWebSocketUpgradeRequest extends AsyncDisposable {
8
+ readonly req: ClientRequest;
9
+ }
10
+
11
+ /**
12
+ * Creates a raw WebSocket upgrade request test setup for tests that need manual
13
+ * request control.
14
+ */
15
+ export const testSetupWebSocketUpgradeRequest = (
16
+ port: number,
17
+ path: string,
18
+ ): TestSetupWebSocketUpgradeRequest => {
19
+ const req = createWebSocketUpgradeRequest(port, path);
20
+ req.on("error", () => undefined);
21
+
22
+ return {
23
+ req,
24
+ [Symbol.asyncDispose]: () => {
25
+ req.destroy();
26
+ return Promise.resolve();
27
+ },
28
+ };
29
+ };
30
+
31
+ /**
32
+ * Sends a raw WebSocket upgrade request and resolves with the rejection
33
+ * response.
34
+ */
35
+ export const testSendWebSocketUpgradeRequest = async (
36
+ port: number,
37
+ path: string,
38
+ ): Promise<IncomingMessage> => {
39
+ const req = createWebSocketUpgradeRequest(port, path);
40
+
41
+ return new Promise((resolve, reject) => {
42
+ req.once("response", (response) => {
43
+ response.resume();
44
+ resolve(response);
45
+ });
46
+ req.once("upgrade", (_response, socket) => {
47
+ socket.destroy();
48
+ reject(new Error("Expected HTTP upgrade rejection"));
49
+ });
50
+ req.once("error", reject);
51
+ req.end();
52
+ });
53
+ };
54
+
55
+ const createWebSocketUpgradeRequest = (
56
+ port: number,
57
+ path: string,
58
+ ): ClientRequest =>
59
+ request({
60
+ host: "127.0.0.1",
61
+ port,
62
+ path,
63
+ headers: {
64
+ Connection: "Upgrade",
65
+ Upgrade: "websocket",
66
+ "Sec-WebSocket-Version": "13",
67
+ "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
68
+ },
69
+ });
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./Sqlite.js";
2
2
  export * from "./Crypto.js";
3
3
  export * from "./local-first/Relay.js";
4
4
  export * from "./Task.js";
5
+ export * from "./WebSocket.js";
@@ -1,9 +1,11 @@
1
1
  import {
2
+ AbortError,
3
+ assert,
4
+ callback,
2
5
  createRandom,
3
6
  createRelation,
4
7
  createSqlite,
5
8
  type CreateSqliteDriverDep,
6
- isPromiseLike,
7
9
  Name,
8
10
  ok,
9
11
  OwnerId,
@@ -23,6 +25,7 @@ import {
23
25
  type Relay,
24
26
  type RelayConfig,
25
27
  } from "@evolu/common/local-first";
28
+ import { once } from "events";
26
29
  import { existsSync } from "fs";
27
30
  import { createServer } from "http";
28
31
  import { WebSocket, WebSocketServer } from "ws";
@@ -73,8 +76,8 @@ export const createRelayDeps = (): RelayDeps => ({
73
76
  * startRelay({
74
77
  * port: 4000,
75
78
  *
76
- * // Note: Relay requires URL in format ws://host:port/<ownerId>
77
- * // isOwnerAllowed: (_ownerId) => true,
79
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
80
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
78
81
  *
79
82
  * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
80
83
  * const maxBytes = 1024 * 1024; // 1MB
@@ -98,12 +101,12 @@ export const startRelay =
98
101
  await using stack = new AsyncDisposableStack();
99
102
  const console = run.deps.console.child("relay");
100
103
 
101
- const dbFileExists = existsSync(`${name}.db`);
102
-
103
- const sqliteResult = await run(createSqlite(name));
104
- if (!sqliteResult.ok) return sqliteResult;
105
- const sqlite = stack.use(sqliteResult.value);
104
+ stack.defer(() => {
105
+ console.info("Shutdown complete");
106
+ });
106
107
 
108
+ const dbFileExists = existsSync(`${name}.db`);
109
+ const sqlite = stack.use(await run.orThrow(createSqlite(name)));
107
110
  const deps = { ...run.deps, sqlite };
108
111
 
109
112
  if (!dbFileExists) {
@@ -111,19 +114,35 @@ export const startRelay =
111
114
  createRelayStorageTables(deps);
112
115
  }
113
116
 
114
- const relayRun = run.create().addDeps({
115
- storage: createRelaySqliteStorage(deps)({
116
- isOwnerWithinQuota,
117
- }),
117
+ const server = stack.use(createServer());
118
+ server.once("close", () => {
119
+ console.info("HTTP server closed");
118
120
  });
119
121
 
120
- const server = createServer();
121
- const wss = new WebSocketServer({
122
- maxPayload: defaultProtocolMessageMaxSize,
123
- noServer: true,
124
- });
122
+ const wss = stack.adopt(
123
+ new WebSocketServer({
124
+ maxPayload: defaultProtocolMessageMaxSize,
125
+ noServer: true,
126
+ }),
127
+ (wss) =>
128
+ new Promise<void>((resolve) => {
129
+ wss.close(() => {
130
+ console.info("WebSocketServer closed");
131
+ resolve();
132
+ });
133
+ }),
134
+ );
135
+
125
136
  const ownerSocketRelation = createRelation<OwnerId, WebSocket>();
126
137
 
138
+ const relayRun = stack.use(
139
+ run.create().addDeps({
140
+ storage: createRelaySqliteStorage(deps)({
141
+ isOwnerWithinQuota,
142
+ }),
143
+ }),
144
+ );
145
+
127
146
  server.on("upgrade", (request, socket, head) => {
128
147
  socket.on("error", console.debug);
129
148
 
@@ -140,28 +159,65 @@ export const startRelay =
140
159
  return;
141
160
  }
142
161
 
143
- const ownerId = parseOwnerIdFromOwnerWebSocketTransportUrl(
144
- request.url ?? "",
145
- );
162
+ const respondAndDestroy = (
163
+ statusCode: keyof typeof HttpStatusTextByCode,
164
+ ) => {
165
+ if (socket.destroyed) return;
166
+ socket.write(
167
+ `HTTP/1.1 ${statusCode} ${HttpStatusTextByCode[statusCode]}\r\n\r\n`,
168
+ );
169
+ socket.destroy();
170
+ };
171
+
172
+ const requestUrl = request.url;
173
+ const ownerId = requestUrl
174
+ ? parseOwnerIdFromOwnerWebSocketTransportUrl(requestUrl)
175
+ : undefined;
146
176
 
147
177
  if (!ownerId) {
148
- console.debug("invalid or missing ownerId in URL", request.url);
149
- socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
150
- socket.destroy();
178
+ console.debug("invalid or missing ownerId in URL", requestUrl);
179
+ respondAndDestroy(400);
151
180
  return;
152
181
  }
153
182
 
154
- void (async () => {
155
- const result = isOwnerAllowed(ownerId);
156
- const isAllowed = isPromiseLike(result) ? await result : result;
157
- if (!isAllowed) {
158
- console.debug("unauthorized owner", ownerId);
159
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
183
+ const authorizationFiber = relayRun(
184
+ callback<boolean, unknown>(({ ok, err, signal }) => {
185
+ void Promise.try(() => isOwnerAllowed(ownerId, { signal })).then(
186
+ ok,
187
+ err,
188
+ );
189
+ }),
190
+ );
191
+
192
+ const abortAuthorization = () => {
193
+ authorizationFiber.abort("WebSocket upgrade request socket closed");
194
+ };
195
+
196
+ socket.once("close", abortAuthorization);
197
+ socket.once("error", abortAuthorization);
198
+
199
+ void authorizationFiber.then((result) => {
200
+ socket.removeListener("close", abortAuthorization);
201
+ socket.removeListener("error", abortAuthorization);
202
+
203
+ if (!result.ok) {
204
+ if (!AbortError.is(result.error)) {
205
+ console.error("isOwnerAllowed failed", ownerId, result.error);
206
+ respondAndDestroy(503);
207
+ return;
208
+ }
160
209
  socket.destroy();
161
210
  return;
162
211
  }
212
+
213
+ if (!result.value) {
214
+ console.debug("unauthorized owner", ownerId);
215
+ respondAndDestroy(401);
216
+ return;
217
+ }
218
+
163
219
  completeUpgrade();
164
- })();
220
+ });
165
221
  });
166
222
 
167
223
  wss.on("connection", (ws) => {
@@ -222,26 +278,6 @@ export const startRelay =
222
278
  });
223
279
  });
224
280
 
225
- // Cleanup runs in LIFO order: clients → WebSocketServer → HTTP server
226
- stack.defer(() => {
227
- console.info("Shutdown complete");
228
- });
229
-
230
- stack.defer(() => {
231
- server.close(() => {
232
- console.info("HTTP server closed");
233
- });
234
- });
235
-
236
- stack.defer(() => {
237
- // wss.close() emits 'close' when all clients have disconnected
238
- // https://github.com/websockets/ws/blob/master/doc/ws.md#serverclosecallback
239
- wss.close(() => {
240
- console.info("WebSocketServer closed");
241
- ok();
242
- });
243
- });
244
-
245
281
  stack.defer(() => {
246
282
  console.info("Shutting down...");
247
283
  for (const client of wss.clients) {
@@ -251,10 +287,24 @@ export const startRelay =
251
287
  }
252
288
  });
253
289
 
254
- stack.use(relayRun);
255
-
256
290
  server.listen(port);
257
- console.info(`Started on port ${port}`);
291
+ await once(server, "listening");
292
+
293
+ const address = server.address();
294
+ assert(address && typeof address !== "string", "Expected TCP address");
295
+
296
+ const moved = stack.move();
258
297
 
259
- return ok(stack.move());
298
+ console.info(`Started on port ${address.port}`);
299
+
300
+ return ok({
301
+ port: address.port,
302
+ [Symbol.asyncDispose]: () => moved.disposeAsync(),
303
+ });
260
304
  };
305
+
306
+ const HttpStatusTextByCode = {
307
+ 400: "Bad Request",
308
+ 401: "Unauthorized",
309
+ 503: "Service Unavailable",
310
+ } as const;