@evolu/nodejs 2.4.0 → 3.0.0-next.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 (41) hide show
  1. package/README.md +4 -2
  2. package/dist/src/Crypto.d.ts.map +1 -0
  3. package/dist/src/Sqlite.d.ts +3 -0
  4. package/dist/src/Sqlite.d.ts.map +1 -0
  5. package/dist/src/Sqlite.js +38 -0
  6. package/dist/src/Task.d.ts +45 -0
  7. package/dist/src/Task.d.ts.map +1 -0
  8. package/dist/src/Task.js +59 -0
  9. package/dist/src/WebSocket.d.ts +19 -0
  10. package/dist/src/WebSocket.d.ts.map +1 -0
  11. package/dist/src/WebSocket.js +46 -0
  12. package/dist/src/Worker.d.ts +2 -0
  13. package/dist/src/Worker.d.ts.map +1 -0
  14. package/dist/src/Worker.js +12 -0
  15. package/dist/src/index.d.ts +6 -0
  16. package/dist/src/index.d.ts.map +1 -0
  17. package/dist/src/index.js +5 -0
  18. package/dist/src/local-first/Relay.d.ts +55 -0
  19. package/dist/src/local-first/Relay.d.ts.map +1 -0
  20. package/dist/src/local-first/Relay.js +270 -0
  21. package/package.json +21 -17
  22. package/src/Crypto.ts +1 -1
  23. package/src/Sqlite.ts +60 -0
  24. package/src/Task.ts +93 -0
  25. package/src/WebSocket.ts +69 -0
  26. package/src/Worker.ts +11 -0
  27. package/src/index.ts +4 -1
  28. package/src/local-first/Relay.ts +201 -137
  29. package/dist/BetterSqliteDriver.d.ts +0 -3
  30. package/dist/BetterSqliteDriver.d.ts.map +0 -1
  31. package/dist/BetterSqliteDriver.js +0 -31
  32. package/dist/Crypto.d.ts.map +0 -1
  33. package/dist/index.d.ts +0 -3
  34. package/dist/index.d.ts.map +0 -1
  35. package/dist/index.js +0 -2
  36. package/dist/local-first/Relay.d.ts +0 -22
  37. package/dist/local-first/Relay.d.ts.map +0 -1
  38. package/dist/local-first/Relay.js +0 -165
  39. package/src/BetterSqliteDriver.ts +0 -47
  40. /package/dist/{Crypto.d.ts → src/Crypto.d.ts} +0 -0
  41. /package/dist/{Crypto.js → src/Crypto.js} +0 -0
@@ -1,120 +1,154 @@
1
1
  import {
2
- ConsoleDep,
3
- createRelation,
2
+ AbortError,
3
+ assert,
4
+ callback,
4
5
  createRandom,
6
+ createRelation,
5
7
  createSqlite,
6
- CreateSqliteDriverDep,
7
- isAsync,
8
+ type CreateSqliteDriverDep,
9
+ Name,
8
10
  ok,
9
11
  OwnerId,
10
- RandomDep,
11
- Result,
12
- SimpleName,
13
- SqliteError,
14
- TimingSafeEqualDep,
12
+ type RandomDep,
13
+ type Task,
14
+ type TimingSafeEqualDep,
15
15
  Uint8Array,
16
16
  } from "@evolu/common";
17
17
  import {
18
18
  applyProtocolMessageAsRelay,
19
- ApplyProtocolMessageAsRelayOptions,
19
+ type ApplyProtocolMessageAsRelayOptions,
20
20
  createBaseSqliteStorageTables,
21
- createRelayLogger,
22
21
  createRelaySqliteStorage,
23
22
  createRelayStorageTables,
24
23
  defaultProtocolMessageMaxSize,
25
24
  parseOwnerIdFromOwnerWebSocketTransportUrl,
26
- Relay,
27
- RelayConfig,
25
+ type Relay,
26
+ type RelayConfig,
28
27
  } from "@evolu/common/local-first";
28
+ import { once } from "events";
29
29
  import { existsSync } from "fs";
30
30
  import { createServer } from "http";
31
31
  import { WebSocket, WebSocketServer } from "ws";
32
- import { createBetterSqliteDriver } from "../BetterSqliteDriver.js";
33
32
  import { createTimingSafeEqual } from "../Crypto.js";
33
+ import { createBetterSqliteDriver } from "../Sqlite.js";
34
34
 
35
35
  export interface NodeJsRelayConfig extends RelayConfig {
36
36
  /** The port number for the HTTP server. */
37
37
  readonly port?: number;
38
38
  }
39
39
 
40
- /**
41
- * Creates an Evolu relay server.
42
- *
43
- * This implementation uses Node.js and better-sqlite3. Additional relay
44
- * implementations will be provided for other platforms (Bun, Deno, Cloudflare
45
- * Workers, Vercel Edge, etc.).
46
- */
47
- export const createNodeJsRelay =
48
- (deps: ConsoleDep) =>
49
- (config: NodeJsRelayConfig): Promise<Result<Relay, SqliteError>> =>
50
- createNodeJsRelayWithSqliteDriver({
51
- ...deps,
52
- createSqliteDriver: createBetterSqliteDriver,
53
- })(config);
40
+ export type RelayDeps = CreateSqliteDriverDep & RandomDep & TimingSafeEqualDep;
41
+
42
+ /** Dependencies for {@link startRelay} using better-sqlite3. */
43
+ export const createRelayDeps = (): RelayDeps => ({
44
+ createSqliteDriver: createBetterSqliteDriver,
45
+ random: createRandom(),
46
+ timingSafeEqual: createTimingSafeEqual(),
47
+ });
54
48
 
55
49
  /**
56
- * Creates an Evolu relay server with a custom SQLite driver.
50
+ * Starts an Evolu relay server using Node.js.
51
+ *
52
+ * Use {@link createRelayDeps} to create dependencies for better-sqlite3, or
53
+ * provide a custom SQLite driver implementation.
54
+ *
55
+ * ### Example
57
56
  *
58
- * Use this when you need to provide a different SQLite driver implementation
59
- * (e.g., using alternative SQLite libraries).
57
+ * ```ts
58
+ * // Ensure the database is created in a predictable location for Docker.
59
+ * mkdirSync("data", { recursive: true });
60
+ * process.chdir("data");
61
+ *
62
+ * const console = createConsole({
63
+ * // level: "debug",
64
+ * formatter: createConsoleFormatter()({
65
+ * timestampFormat: "relative",
66
+ * }),
67
+ * });
68
+ *
69
+ * const deps = { ...createRelayDeps(), console };
70
+ *
71
+ * await using run = createRun(deps);
72
+ * await using stack = new AsyncDisposableStack();
73
+ *
74
+ * stack.use(
75
+ * await run.orThrow(
76
+ * startRelay({
77
+ * port: 4000,
78
+ *
79
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
80
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
81
+ *
82
+ * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
83
+ * const maxBytes = 1024 * 1024; // 1MB
84
+ * return requiredBytes <= maxBytes;
85
+ * },
86
+ * }),
87
+ * ),
88
+ * );
89
+ *
90
+ * await run.deps.shutdown;
91
+ * ```
60
92
  */
61
- export const createNodeJsRelayWithSqliteDriver =
62
- (deps: ConsoleDep & CreateSqliteDriverDep) =>
63
- (config: NodeJsRelayConfig): Promise<Result<Relay, SqliteError>> =>
64
- createNodeJsRelayWithDeps({
65
- ...deps,
66
- random: createRandom(),
67
- timingSafeEqual: createTimingSafeEqual(),
68
- })(config);
69
-
70
- const createNodeJsRelayWithDeps =
71
- (deps: ConsoleDep & CreateSqliteDriverDep & RandomDep & TimingSafeEqualDep) =>
72
- async ({
93
+ export const startRelay =
94
+ ({
73
95
  port = 443,
74
- name = SimpleName.orThrow("evolu-relay"),
75
- enableLogging = false,
96
+ name = Name.orThrow("evolu-relay"),
76
97
  isOwnerAllowed,
77
98
  isOwnerWithinQuota,
78
- }: NodeJsRelayConfig): Promise<Result<Relay, SqliteError>> => {
79
- const log = createRelayLogger(deps);
80
- log.started(enableLogging, port);
99
+ }: NodeJsRelayConfig): Task<Relay, never, RelayDeps> =>
100
+ async (run) => {
101
+ await using stack = new AsyncDisposableStack();
102
+ const console = run.deps.console.child("relay");
81
103
 
82
- const dbFileExists = existsSync(`${name}.db`);
83
-
84
- const sqlite = await createSqlite(deps)(name);
85
- if (!sqlite.ok) return sqlite;
104
+ stack.defer(() => {
105
+ console.info("Shutdown complete");
106
+ });
86
107
 
87
- const depsWithSqlite = { ...deps, sqlite: sqlite.value };
108
+ const dbFileExists = existsSync(`${name}.db`);
109
+ const sqlite = stack.use(await run.orThrow(createSqlite(name)));
110
+ const deps = { ...run.deps, sqlite };
88
111
 
89
112
  if (!dbFileExists) {
90
- {
91
- const result = createBaseSqliteStorageTables(depsWithSqlite);
92
- if (!result.ok) return result;
93
- }
94
- {
95
- const result = createRelayStorageTables(depsWithSqlite);
96
- if (!result.ok) return result;
97
- }
113
+ createBaseSqliteStorageTables(deps);
114
+ createRelayStorageTables(deps);
98
115
  }
99
116
 
100
- const storage = createRelaySqliteStorage(depsWithSqlite)({
101
- onStorageError: log.storageError,
102
- isOwnerWithinQuota,
117
+ const server = stack.use(createServer());
118
+ server.once("close", () => {
119
+ console.info("HTTP server closed");
103
120
  });
104
121
 
105
- const server = createServer();
106
- const wss = new WebSocketServer({
107
- maxPayload: defaultProtocolMessageMaxSize,
108
- noServer: true,
109
- });
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
+ );
110
135
 
111
136
  const ownerSocketRelation = createRelation<OwnerId, WebSocket>();
112
137
 
138
+ const relayRun = stack.use(
139
+ run.create().addDeps({
140
+ storage: createRelaySqliteStorage(deps)({
141
+ isOwnerWithinQuota,
142
+ }),
143
+ }),
144
+ );
145
+
113
146
  server.on("upgrade", (request, socket, head) => {
114
- socket.on("error", log.upgradeSocketError);
147
+ socket.on("error", console.debug);
115
148
 
116
149
  const completeUpgrade = () => {
117
- socket.removeListener("error", log.upgradeSocketError);
150
+ socket.removeListener("error", console.debug);
151
+
118
152
  wss.handleUpgrade(request, socket, head, (ws) => {
119
153
  wss.emit("connection", ws, request);
120
154
  });
@@ -125,122 +159,152 @@ const createNodeJsRelayWithDeps =
125
159
  return;
126
160
  }
127
161
 
128
- const ownerId = parseOwnerIdFromOwnerWebSocketTransportUrl(
129
- request.url ?? "",
130
- );
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;
131
176
 
132
177
  if (!ownerId) {
133
- log.invalidOrMissingOwnerIdInUrl(request.url);
134
- socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
135
- socket.destroy();
178
+ console.debug("invalid or missing ownerId in URL", requestUrl);
179
+ respondAndDestroy(400);
136
180
  return;
137
181
  }
138
182
 
139
- void (async () => {
140
- const result = isOwnerAllowed(ownerId);
141
- const isAllowed = isAsync(result) ? await result : result;
142
- if (!isAllowed) {
143
- log.unauthorizedOwner(ownerId);
144
- 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
+ }
145
209
  socket.destroy();
146
210
  return;
147
211
  }
212
+
213
+ if (!result.value) {
214
+ console.debug("unauthorized owner", ownerId);
215
+ respondAndDestroy(401);
216
+ return;
217
+ }
218
+
148
219
  completeUpgrade();
149
- })();
220
+ });
150
221
  });
151
222
 
152
223
  wss.on("connection", (ws) => {
153
- log.connectionEstablished(wss.clients.size);
154
-
155
- ws.on("error", (error) => {
156
- log.connectionWebSocketError(error);
157
- });
224
+ console.debug("on connection", wss.clients.size);
158
225
 
159
226
  const options: ApplyProtocolMessageAsRelayOptions = {
160
227
  subscribe: (ownerId) => {
161
228
  ownerSocketRelation.add(ownerId, ws);
162
- log.relayOptionSubscribe(
229
+ console.debug(
230
+ "subscribe",
163
231
  ownerId,
164
- () => ownerSocketRelation.getB(ownerId)?.size ?? 0,
232
+ ownerSocketRelation.bCountForA(ownerId),
165
233
  );
166
234
  },
167
235
 
168
236
  unsubscribe: (ownerId) => {
169
237
  ownerSocketRelation.remove(ownerId, ws);
170
- log.relayOptionUnsubscribe(
238
+ console.debug(
239
+ "unsubscribe",
171
240
  ownerId,
172
- () => ownerSocketRelation.getB(ownerId)?.size ?? 0,
241
+ ownerSocketRelation.bCountForA(ownerId),
173
242
  );
174
243
  },
175
244
 
176
245
  broadcast: (ownerId, message) => {
177
- const sockets = ownerSocketRelation.getB(ownerId);
178
- if (!sockets) return;
179
-
180
- let broadcastCount = 0;
181
- for (const socket of sockets) {
246
+ for (const socket of ownerSocketRelation.iterateB(ownerId)) {
182
247
  if (socket !== ws && socket.readyState === WebSocket.OPEN) {
183
248
  socket.send(message, { binary: true });
184
- broadcastCount++;
185
249
  }
186
250
  }
187
251
 
188
- log.relayOptionBroadcast(ownerId, broadcastCount, sockets.size);
252
+ console.debug(
253
+ "broadcast",
254
+ ownerId,
255
+ ownerSocketRelation.bCountForA(ownerId),
256
+ );
189
257
  },
190
258
  };
191
259
 
192
260
  ws.on("message", (message) => {
193
261
  if (!Uint8Array.is(message)) return;
194
- log.messageLength(message.length);
195
262
 
196
- applyProtocolMessageAsRelay({ storage })(message, options)
197
- .then((response) => {
198
- if (!response.ok) {
199
- log.applyProtocolMessageAsRelayError(response.error);
200
- return;
201
- }
202
- ws.send(response.value.message, { binary: true });
203
- log.responseLength(response.value.message.length);
204
- })
205
- .catch(log.applyProtocolMessageAsRelayUnknownError);
263
+ void (async () => {
264
+ const response = await relayRun(
265
+ applyProtocolMessageAsRelay(message, options),
266
+ );
267
+ if (!response.ok) {
268
+ console.error(response);
269
+ return;
270
+ }
271
+ ws.send(response.value.message, { binary: true });
272
+ })();
206
273
  });
207
274
 
208
275
  ws.on("close", () => {
209
- ownerSocketRelation.deleteB(ws);
210
- log.connectionClosed(wss.clients.size);
276
+ ownerSocketRelation.removeByB(ws);
277
+ console.debug("ws close", wss.clients.size);
211
278
  });
212
279
  });
213
280
 
214
- server.listen(port);
215
-
216
- const dispose = () => {
217
- log.shuttingDown();
218
-
219
- wss.clients.forEach((client) => {
281
+ stack.defer(() => {
282
+ console.info("Shutting down...");
283
+ for (const client of wss.clients) {
220
284
  if (client.readyState === WebSocket.OPEN) {
221
285
  client.close(1000, "Evolu Relay shutting down");
222
286
  }
223
- });
287
+ }
288
+ });
224
289
 
225
- wss.close(() => {
226
- log.webSocketServerDisposed();
227
- });
290
+ server.listen(port);
291
+ await once(server, "listening");
228
292
 
229
- server.close(() => {
230
- log.httpServerDisposed();
231
- });
232
- };
293
+ const address = server.address();
294
+ assert(address && typeof address !== "string", "Expected TCP address");
233
295
 
234
- let isDisposed = false;
296
+ const moved = stack.move();
235
297
 
236
- const relay: Relay = {
237
- [Symbol.dispose]: () => {
238
- if (isDisposed) return;
239
- isDisposed = true;
240
- sqlite.value[Symbol.dispose]();
241
- dispose();
242
- },
243
- };
298
+ console.info(`Started on port ${address.port}`);
244
299
 
245
- return ok(relay);
300
+ return ok({
301
+ port: address.port,
302
+ [Symbol.asyncDispose]: () => moved.disposeAsync(),
303
+ });
246
304
  };
305
+
306
+ const HttpStatusTextByCode = {
307
+ 400: "Bad Request",
308
+ 401: "Unauthorized",
309
+ 503: "Service Unavailable",
310
+ } as const;
@@ -1,3 +0,0 @@
1
- import { CreateSqliteDriver } from "@evolu/common";
2
- export declare const createBetterSqliteDriver: CreateSqliteDriver;
3
- //# sourceMappingURL=BetterSqliteDriver.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"BetterSqliteDriver.d.ts","sourceRoot":"","sources":["../src/BetterSqliteDriver.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,kBAAkB,EAGnB,MAAM,eAAe,CAAC;AAGvB,eAAO,MAAM,wBAAwB,EAAE,kBAqCtC,CAAC"}
@@ -1,31 +0,0 @@
1
- import { constVoid, createPreparedStatementsCache, } from "@evolu/common";
2
- import BetterSQLite from "better-sqlite3";
3
- export const createBetterSqliteDriver = (name, options) => {
4
- const filename = options?.memory ? ":memory:" : `${name}.db`;
5
- const db = new BetterSQLite(filename);
6
- let isDisposed = false;
7
- const cache = createPreparedStatementsCache((sql) => db.prepare(sql),
8
- // Not needed.
9
- // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement
10
- constVoid);
11
- const driver = {
12
- exec: (query, isMutation) => {
13
- // Always prepare is recommended for better-sqlite3
14
- const prepared = cache.get(query, true);
15
- const rows = isMutation
16
- ? []
17
- : prepared.all(query.parameters);
18
- const changes = isMutation ? prepared.run(query.parameters).changes : 0;
19
- return { rows, changes };
20
- },
21
- export: () => db.serialize(),
22
- [Symbol.dispose]: () => {
23
- if (isDisposed)
24
- return;
25
- isDisposed = true;
26
- cache[Symbol.dispose]();
27
- db.close();
28
- },
29
- };
30
- return Promise.resolve(driver);
31
- };
@@ -1 +0,0 @@
1
- {"version":3,"file":"Crypto.d.ts","sourceRoot":"","sources":["../src/Crypto.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD,eAAO,MAAM,qBAAqB,QAAO,eAAkC,CAAC"}
package/dist/index.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export * from "./BetterSqliteDriver.js";
2
- export * from "./local-first/Relay.js";
3
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC"}
package/dist/index.js DELETED
@@ -1,2 +0,0 @@
1
- export * from "./BetterSqliteDriver.js";
2
- export * from "./local-first/Relay.js";
@@ -1,22 +0,0 @@
1
- import { ConsoleDep, CreateSqliteDriverDep, Result, SqliteError } from "@evolu/common";
2
- import { Relay, RelayConfig } from "@evolu/common/local-first";
3
- export interface NodeJsRelayConfig extends RelayConfig {
4
- /** The port number for the HTTP server. */
5
- readonly port?: number;
6
- }
7
- /**
8
- * Creates an Evolu relay server.
9
- *
10
- * This implementation uses Node.js and better-sqlite3. Additional relay
11
- * implementations will be provided for other platforms (Bun, Deno, Cloudflare
12
- * Workers, Vercel Edge, etc.).
13
- */
14
- export declare const createNodeJsRelay: (deps: ConsoleDep) => (config: NodeJsRelayConfig) => Promise<Result<Relay, SqliteError>>;
15
- /**
16
- * Creates an Evolu relay server with a custom SQLite driver.
17
- *
18
- * Use this when you need to provide a different SQLite driver implementation
19
- * (e.g., using alternative SQLite libraries).
20
- */
21
- export declare const createNodeJsRelayWithSqliteDriver: (deps: ConsoleDep & CreateSqliteDriverDep) => (config: NodeJsRelayConfig) => Promise<Result<Relay, SqliteError>>;
22
- //# sourceMappingURL=Relay.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Relay.d.ts","sourceRoot":"","sources":["../../src/local-first/Relay.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EAIV,qBAAqB,EAKrB,MAAM,EAEN,WAAW,EAGZ,MAAM,eAAe,CAAC;AACvB,OAAO,EASL,KAAK,EACL,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAOnC,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACpD,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAC3B,MAAM,UAAU,MAChB,QAAQ,iBAAiB,KAAG,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,WAAW,CAAC,CAInD,CAAC;AAEf;;;;;GAKG;AACH,eAAO,MAAM,iCAAiC,GAC3C,MAAM,UAAU,GAAG,qBAAqB,MACxC,QAAQ,iBAAiB,KAAG,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,WAAW,CAAC,CAKnD,CAAC"}