@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
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # Evolu Relay for Node.js and SQLite
1
+ # Evolu for Node.js
2
+
3
+ This package provides Evolu for [Node.js](https://nodejs.org) 24+.
2
4
 
3
5
  ## Documentation
4
6
 
@@ -10,4 +12,4 @@ The Evolu community is on [GitHub Discussions](https://github.com/evoluhq/evolu/
10
12
 
11
13
  To chat with other community members, you can join the [Evolu Discord](https://discord.gg/2J8yyyyxtZ).
12
14
 
13
- [![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/evoluhq.svg?style=social&label=Follow%20%40evoluhq)](https://twitter.com/evoluhq)
15
+ [![X](https://img.shields.io/twitter/url/https/x.com/evoluhq.svg?style=social&label=Follow%20%40evoluhq)](https://x.com/evoluhq)
@@ -0,0 +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"}
@@ -0,0 +1,3 @@
1
+ import { type CreateSqliteDriver } from "@evolu/common";
2
+ export declare const createBetterSqliteDriver: CreateSqliteDriver;
3
+ //# sourceMappingURL=Sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Sqlite.d.ts","sourceRoot":"","sources":["../../src/Sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,kBAAkB,EAGxB,MAAM,eAAe,CAAC;AAGvB,eAAO,MAAM,wBAAwB,EAAE,kBAiDpC,CAAC"}
@@ -0,0 +1,38 @@
1
+ import { createPreparedStatementsCache, lazyVoid, ok, } from "@evolu/common";
2
+ import BetterSQLite, {} from "better-sqlite3";
3
+ export const createBetterSqliteDriver = (name, options) => () => {
4
+ const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`;
5
+ const stack = new globalThis.DisposableStack();
6
+ const db = stack.adopt(new BetterSQLite(filename), (db) => {
7
+ db.close();
8
+ });
9
+ const cache = stack.use(createPreparedStatementsCache((sql) => db.prepare(sql),
10
+ // Not needed.
11
+ // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement
12
+ lazyVoid));
13
+ const driver = {
14
+ exec: (query) => {
15
+ // Always prepare is recommended for better-sqlite3
16
+ const prepared = cache.get(query, true);
17
+ if (prepared.reader) {
18
+ const rows = prepared.all(query.parameters);
19
+ return { rows, changes: 0 };
20
+ }
21
+ const changes = prepared.run(query.parameters).changes;
22
+ return { rows: [], changes };
23
+ },
24
+ export: () => {
25
+ const file = db.serialize();
26
+ const { buffer } = file;
27
+ if (buffer instanceof ArrayBuffer) {
28
+ return new Uint8Array(buffer, file.byteOffset, file.byteLength);
29
+ }
30
+ // Ensure export uses transferable ArrayBuffer backing.
31
+ return new Uint8Array(file);
32
+ },
33
+ [Symbol.dispose]: () => {
34
+ stack.dispose();
35
+ },
36
+ };
37
+ return ok(driver);
38
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Node.js-specific Task utilities.
3
+ *
4
+ * @module
5
+ */
6
+ import { type CreateRun, type RunDeps } from "@evolu/common";
7
+ /**
8
+ * A promise that resolves when a termination signal is received.
9
+ *
10
+ * Resolves on `SIGINT` (Ctrl-C), `SIGTERM` (OS/k8s/Docker termination),
11
+ * `SIGHUP` (console close/terminal disconnect), or `SIGBREAK` (Windows
12
+ * Ctrl-Break).
13
+ *
14
+ * @group Node.js Run
15
+ */
16
+ export type Shutdown = Promise<void>;
17
+ export interface ShutdownDep {
18
+ readonly shutdown: Shutdown;
19
+ }
20
+ /**
21
+ * Creates {@link Run} for Node.js with global error handling and graceful
22
+ * shutdown.
23
+ *
24
+ * Registers `uncaughtException` and `unhandledRejection` handlers that log
25
+ * errors and initiate graceful shutdown. Adds a `shutdown` promise to deps that
26
+ * resolves on termination signals (`SIGINT`, `SIGTERM`, `SIGHUP`). Handlers are
27
+ * removed when the Run is disposed.
28
+ *
29
+ * ### Example
30
+ *
31
+ * ```ts
32
+ * const deps = { ...createRelayDeps(), console };
33
+ *
34
+ * await using run = createRun(deps);
35
+ * await using stack = new AsyncDisposableStack();
36
+ *
37
+ * stack.use(await run.orThrow(startRelay({ port: 4000 })));
38
+ *
39
+ * await run.deps.shutdown;
40
+ * ```
41
+ *
42
+ * @group Node.js Run
43
+ */
44
+ export declare const createRun: CreateRun<RunDeps & ShutdownDep>;
45
+ //# sourceMappingURL=Task.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Task.d.ts","sourceRoot":"","sources":["../../src/Task.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAGL,KAAK,SAAS,EAEd,KAAK,OAAO,EACb,MAAM,eAAe,CAAC;AAEvB;;;;;;;;GAQG;AACH,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAErC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,SAAS,EAAE,SAAS,CAAC,OAAO,GAAG,WAAW,CAuCtD,CAAC"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Node.js-specific Task utilities.
3
+ *
4
+ * @module
5
+ */
6
+ import { createRun as createCommonRun, createUnknownError, } from "@evolu/common";
7
+ /**
8
+ * Creates {@link Run} for Node.js with global error handling and graceful
9
+ * shutdown.
10
+ *
11
+ * Registers `uncaughtException` and `unhandledRejection` handlers that log
12
+ * errors and initiate graceful shutdown. Adds a `shutdown` promise to deps that
13
+ * resolves on termination signals (`SIGINT`, `SIGTERM`, `SIGHUP`). Handlers are
14
+ * removed when the Run is disposed.
15
+ *
16
+ * ### Example
17
+ *
18
+ * ```ts
19
+ * const deps = { ...createRelayDeps(), console };
20
+ *
21
+ * await using run = createRun(deps);
22
+ * await using stack = new AsyncDisposableStack();
23
+ *
24
+ * stack.use(await run.orThrow(startRelay({ port: 4000 })));
25
+ *
26
+ * await run.deps.shutdown;
27
+ * ```
28
+ *
29
+ * @group Node.js Run
30
+ */
31
+ export const createRun = (deps) => {
32
+ const { promise: shutdown, resolve: resolveShutdown } = Promise.withResolvers();
33
+ const run = createCommonRun({ ...deps, shutdown });
34
+ const console = run.deps.console.child("global");
35
+ const handleError = (source) => (error) => {
36
+ console.error(source, createUnknownError(error));
37
+ process.exitCode = 1;
38
+ // Resolve shutdown so `await run.deps.shutdown` unblocks
39
+ // and allows the stack to be disposed.
40
+ resolveShutdown();
41
+ };
42
+ const handleUncaughtException = handleError("uncaughtException");
43
+ const handleUnhandledRejection = handleError("unhandledRejection");
44
+ process.on("uncaughtException", handleUncaughtException);
45
+ process.on("unhandledRejection", handleUnhandledRejection);
46
+ process.on("SIGINT", resolveShutdown); // Ctrl-C (all platforms)
47
+ process.on("SIGTERM", resolveShutdown); // OS/k8s/Docker termination (Unix)
48
+ process.on("SIGHUP", resolveShutdown); // Console close (Windows), terminal disconnect (Unix)
49
+ process.on("SIGBREAK", resolveShutdown); // Ctrl-Break (Windows)
50
+ run.onAbort(() => {
51
+ process.off("uncaughtException", handleUncaughtException);
52
+ process.off("unhandledRejection", handleUnhandledRejection);
53
+ process.off("SIGINT", resolveShutdown);
54
+ process.off("SIGTERM", resolveShutdown);
55
+ process.off("SIGHUP", resolveShutdown);
56
+ process.off("SIGBREAK", resolveShutdown);
57
+ });
58
+ return run;
59
+ };
@@ -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
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=Worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Worker.d.ts","sourceRoot":"","sources":["../../src/Worker.ts"],"names":[],"mappings":""}
@@ -0,0 +1,12 @@
1
+ export {};
2
+ // TODO: Implement Node.js Worker API
3
+ //
4
+ // This module should provide Node.js implementations of the common Worker API:
5
+ // - createWorker
6
+ // - createWorkerSelf (with onError hooking process.on('uncaughtException') and
7
+ // process.on('unhandledRejection'))
8
+ // - createMessageChannel
9
+ // - createMessagePort
10
+ //
11
+ // Node.js uses worker_threads module for Worker/MessageChannel/MessagePort.
12
+ // Error handling uses process events instead of globalThis.onerror.
@@ -0,0 +1,6 @@
1
+ export * from "./Sqlite.js";
2
+ export * from "./Crypto.js";
3
+ export * from "./local-first/Relay.js";
4
+ export * from "./Task.js";
5
+ export * from "./WebSocket.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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;AAC1B,cAAc,gBAAgB,CAAC"}
@@ -0,0 +1,5 @@
1
+ export * from "./Sqlite.js";
2
+ export * from "./Crypto.js";
3
+ export * from "./local-first/Relay.js";
4
+ export * from "./Task.js";
5
+ export * from "./WebSocket.js";
@@ -0,0 +1,55 @@
1
+ import { type CreateSqliteDriverDep, type RandomDep, type Task, type TimingSafeEqualDep } from "@evolu/common";
2
+ import { type Relay, type 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
+ export type RelayDeps = CreateSqliteDriverDep & RandomDep & TimingSafeEqualDep;
8
+ /** Dependencies for {@link startRelay} using better-sqlite3. */
9
+ export declare const createRelayDeps: () => RelayDeps;
10
+ /**
11
+ * Starts an Evolu relay server using Node.js.
12
+ *
13
+ * Use {@link createRelayDeps} to create dependencies for better-sqlite3, or
14
+ * provide a custom SQLite driver implementation.
15
+ *
16
+ * ### Example
17
+ *
18
+ * ```ts
19
+ * // Ensure the database is created in a predictable location for Docker.
20
+ * mkdirSync("data", { recursive: true });
21
+ * process.chdir("data");
22
+ *
23
+ * const console = createConsole({
24
+ * // level: "debug",
25
+ * formatter: createConsoleFormatter()({
26
+ * timestampFormat: "relative",
27
+ * }),
28
+ * });
29
+ *
30
+ * const deps = { ...createRelayDeps(), console };
31
+ *
32
+ * await using run = createRun(deps);
33
+ * await using stack = new AsyncDisposableStack();
34
+ *
35
+ * stack.use(
36
+ * await run.orThrow(
37
+ * startRelay({
38
+ * port: 4000,
39
+ *
40
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
41
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
42
+ *
43
+ * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
44
+ * const maxBytes = 1024 * 1024; // 1MB
45
+ * return requiredBytes <= maxBytes;
46
+ * },
47
+ * }),
48
+ * ),
49
+ * );
50
+ *
51
+ * await run.deps.shutdown;
52
+ * ```
53
+ */
54
+ export declare const startRelay: ({ port, name, isOwnerAllowed, isOwnerWithinQuota, }: NodeJsRelayConfig) => Task<Relay, never, RelayDeps>;
55
+ //# sourceMappingURL=Relay.d.ts.map
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,270 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { AbortError, assert, callback, createRandom, createRelation, createSqlite, Name, ok, OwnerId, Uint8Array, } from "@evolu/common";
54
+ import { applyProtocolMessageAsRelay, createBaseSqliteStorageTables, createRelaySqliteStorage, createRelayStorageTables, defaultProtocolMessageMaxSize, parseOwnerIdFromOwnerWebSocketTransportUrl, } from "@evolu/common/local-first";
55
+ import { once } from "events";
56
+ import { existsSync } from "fs";
57
+ import { createServer } from "http";
58
+ import { WebSocket, WebSocketServer } from "ws";
59
+ import { createTimingSafeEqual } from "../Crypto.js";
60
+ import { createBetterSqliteDriver } from "../Sqlite.js";
61
+ /** Dependencies for {@link startRelay} using better-sqlite3. */
62
+ export const createRelayDeps = () => ({
63
+ createSqliteDriver: createBetterSqliteDriver,
64
+ random: createRandom(),
65
+ timingSafeEqual: createTimingSafeEqual(),
66
+ });
67
+ /**
68
+ * Starts an Evolu relay server using Node.js.
69
+ *
70
+ * Use {@link createRelayDeps} to create dependencies for better-sqlite3, or
71
+ * provide a custom SQLite driver implementation.
72
+ *
73
+ * ### Example
74
+ *
75
+ * ```ts
76
+ * // Ensure the database is created in a predictable location for Docker.
77
+ * mkdirSync("data", { recursive: true });
78
+ * process.chdir("data");
79
+ *
80
+ * const console = createConsole({
81
+ * // level: "debug",
82
+ * formatter: createConsoleFormatter()({
83
+ * timestampFormat: "relative",
84
+ * }),
85
+ * });
86
+ *
87
+ * const deps = { ...createRelayDeps(), console };
88
+ *
89
+ * await using run = createRun(deps);
90
+ * await using stack = new AsyncDisposableStack();
91
+ *
92
+ * stack.use(
93
+ * await run.orThrow(
94
+ * startRelay({
95
+ * port: 4000,
96
+ *
97
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
98
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
99
+ *
100
+ * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
101
+ * const maxBytes = 1024 * 1024; // 1MB
102
+ * return requiredBytes <= maxBytes;
103
+ * },
104
+ * }),
105
+ * ),
106
+ * );
107
+ *
108
+ * await run.deps.shutdown;
109
+ * ```
110
+ */
111
+ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, }) => async (run) => {
112
+ const env_1 = { stack: [], error: void 0, hasError: false };
113
+ try {
114
+ const stack = __addDisposableResource(env_1, new AsyncDisposableStack(), true);
115
+ const console = run.deps.console.child("relay");
116
+ stack.defer(() => {
117
+ console.info("Shutdown complete");
118
+ });
119
+ const dbFileExists = existsSync(`${name}.db`);
120
+ const sqlite = stack.use(await run.orThrow(createSqlite(name)));
121
+ const deps = { ...run.deps, sqlite };
122
+ if (!dbFileExists) {
123
+ createBaseSqliteStorageTables(deps);
124
+ createRelayStorageTables(deps);
125
+ }
126
+ const server = stack.use(createServer());
127
+ server.once("close", () => {
128
+ console.info("HTTP server closed");
129
+ });
130
+ const wss = stack.adopt(new WebSocketServer({
131
+ maxPayload: defaultProtocolMessageMaxSize,
132
+ noServer: true,
133
+ }), (wss) => new Promise((resolve) => {
134
+ wss.close(() => {
135
+ console.info("WebSocketServer closed");
136
+ resolve();
137
+ });
138
+ }));
139
+ const ownerSocketRelation = createRelation();
140
+ const relayRun = stack.use(run.create().addDeps({
141
+ storage: createRelaySqliteStorage(deps)({
142
+ isOwnerWithinQuota,
143
+ }),
144
+ }));
145
+ server.on("upgrade", (request, socket, head) => {
146
+ socket.on("error", console.debug);
147
+ const completeUpgrade = () => {
148
+ socket.removeListener("error", console.debug);
149
+ wss.handleUpgrade(request, socket, head, (ws) => {
150
+ wss.emit("connection", ws, request);
151
+ });
152
+ };
153
+ if (!isOwnerAllowed) {
154
+ completeUpgrade();
155
+ return;
156
+ }
157
+ const respondAndDestroy = (statusCode) => {
158
+ if (socket.destroyed)
159
+ return;
160
+ socket.write(`HTTP/1.1 ${statusCode} ${HttpStatusTextByCode[statusCode]}\r\n\r\n`);
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);
170
+ return;
171
+ }
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
+ }
189
+ socket.destroy();
190
+ return;
191
+ }
192
+ if (!result.value) {
193
+ console.debug("unauthorized owner", ownerId);
194
+ respondAndDestroy(401);
195
+ return;
196
+ }
197
+ completeUpgrade();
198
+ });
199
+ });
200
+ wss.on("connection", (ws) => {
201
+ console.debug("on connection", wss.clients.size);
202
+ const options = {
203
+ subscribe: (ownerId) => {
204
+ ownerSocketRelation.add(ownerId, ws);
205
+ console.debug("subscribe", ownerId, ownerSocketRelation.bCountForA(ownerId));
206
+ },
207
+ unsubscribe: (ownerId) => {
208
+ ownerSocketRelation.remove(ownerId, ws);
209
+ console.debug("unsubscribe", ownerId, ownerSocketRelation.bCountForA(ownerId));
210
+ },
211
+ broadcast: (ownerId, message) => {
212
+ for (const socket of ownerSocketRelation.iterateB(ownerId)) {
213
+ if (socket !== ws && socket.readyState === WebSocket.OPEN) {
214
+ socket.send(message, { binary: true });
215
+ }
216
+ }
217
+ console.debug("broadcast", ownerId, ownerSocketRelation.bCountForA(ownerId));
218
+ },
219
+ };
220
+ ws.on("message", (message) => {
221
+ if (!Uint8Array.is(message))
222
+ return;
223
+ void (async () => {
224
+ const response = await relayRun(applyProtocolMessageAsRelay(message, options));
225
+ if (!response.ok) {
226
+ console.error(response);
227
+ return;
228
+ }
229
+ ws.send(response.value.message, { binary: true });
230
+ })();
231
+ });
232
+ ws.on("close", () => {
233
+ ownerSocketRelation.removeByB(ws);
234
+ console.debug("ws close", wss.clients.size);
235
+ });
236
+ });
237
+ stack.defer(() => {
238
+ console.info("Shutting down...");
239
+ for (const client of wss.clients) {
240
+ if (client.readyState === WebSocket.OPEN) {
241
+ client.close(1000, "Evolu Relay shutting down");
242
+ }
243
+ }
244
+ });
245
+ server.listen(port);
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
+ });
255
+ }
256
+ catch (e_1) {
257
+ env_1.error = e_1;
258
+ env_1.hasError = true;
259
+ }
260
+ finally {
261
+ const result_1 = __disposeResources(env_1);
262
+ if (result_1)
263
+ await result_1;
264
+ }
265
+ };
266
+ const HttpStatusTextByCode = {
267
+ 400: "Bad Request",
268
+ 401: "Unauthorized",
269
+ 503: "Service Unavailable",
270
+ };