@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.
- package/README.md +4 -2
- package/dist/src/Crypto.d.ts.map +1 -0
- package/dist/src/Sqlite.d.ts +3 -0
- package/dist/src/Sqlite.d.ts.map +1 -0
- package/dist/src/Sqlite.js +38 -0
- package/dist/src/Task.d.ts +45 -0
- package/dist/src/Task.d.ts.map +1 -0
- package/dist/src/Task.js +59 -0
- package/dist/src/WebSocket.d.ts +19 -0
- package/dist/src/WebSocket.d.ts.map +1 -0
- package/dist/src/WebSocket.js +46 -0
- package/dist/src/Worker.d.ts +2 -0
- package/dist/src/Worker.d.ts.map +1 -0
- package/dist/src/Worker.js +12 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +5 -0
- package/dist/src/local-first/Relay.d.ts +55 -0
- package/dist/src/local-first/Relay.d.ts.map +1 -0
- package/dist/src/local-first/Relay.js +270 -0
- package/package.json +21 -17
- package/src/Crypto.ts +1 -1
- package/src/Sqlite.ts +60 -0
- package/src/Task.ts +93 -0
- package/src/WebSocket.ts +69 -0
- package/src/Worker.ts +11 -0
- package/src/index.ts +4 -1
- package/src/local-first/Relay.ts +201 -137
- package/dist/BetterSqliteDriver.d.ts +0 -3
- package/dist/BetterSqliteDriver.d.ts.map +0 -1
- package/dist/BetterSqliteDriver.js +0 -31
- package/dist/Crypto.d.ts.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/local-first/Relay.d.ts +0 -22
- package/dist/local-first/Relay.d.ts.map +0 -1
- package/dist/local-first/Relay.js +0 -165
- package/src/BetterSqliteDriver.ts +0 -47
- /package/dist/{Crypto.d.ts → src/Crypto.d.ts} +0 -0
- /package/dist/{Crypto.js → src/Crypto.js} +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
# Evolu
|
|
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
|
-
[](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 @@
|
|
|
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"}
|
package/dist/src/Task.js
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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,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
|
+
};
|