@evolu/nodejs 2.4.0 → 3.0.0-next.0
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/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 +5 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +4 -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 +243 -0
- package/package.json +22 -20
- package/src/Crypto.ts +1 -1
- package/src/Sqlite.ts +60 -0
- package/src/Task.ts +93 -0
- package/src/Worker.ts +11 -0
- package/src/index.ts +3 -1
- package/src/local-first/Relay.ts +134 -120
- package/LICENSE +0 -21
- 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 @@
|
|
|
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"}
|
|
@@ -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>
|
|
41
|
+
* // isOwnerAllowed: (_ownerId) => 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,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"}
|
|
@@ -0,0 +1,243 @@
|
|
|
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 { createRandom, createRelation, createSqlite, isPromiseLike, Name, ok, OwnerId, Uint8Array, } from "@evolu/common";
|
|
54
|
+
import { applyProtocolMessageAsRelay, createBaseSqliteStorageTables, createRelaySqliteStorage, createRelayStorageTables, defaultProtocolMessageMaxSize, parseOwnerIdFromOwnerWebSocketTransportUrl, } from "@evolu/common/local-first";
|
|
55
|
+
import { existsSync } from "fs";
|
|
56
|
+
import { createServer } from "http";
|
|
57
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
58
|
+
import { createTimingSafeEqual } from "../Crypto.js";
|
|
59
|
+
import { createBetterSqliteDriver } from "../Sqlite.js";
|
|
60
|
+
/** Dependencies for {@link startRelay} using better-sqlite3. */
|
|
61
|
+
export const createRelayDeps = () => ({
|
|
62
|
+
createSqliteDriver: createBetterSqliteDriver,
|
|
63
|
+
random: createRandom(),
|
|
64
|
+
timingSafeEqual: createTimingSafeEqual(),
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Starts an Evolu relay server using Node.js.
|
|
68
|
+
*
|
|
69
|
+
* Use {@link createRelayDeps} to create dependencies for better-sqlite3, or
|
|
70
|
+
* provide a custom SQLite driver implementation.
|
|
71
|
+
*
|
|
72
|
+
* ### Example
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* // Ensure the database is created in a predictable location for Docker.
|
|
76
|
+
* mkdirSync("data", { recursive: true });
|
|
77
|
+
* process.chdir("data");
|
|
78
|
+
*
|
|
79
|
+
* const console = createConsole({
|
|
80
|
+
* // level: "debug",
|
|
81
|
+
* formatter: createConsoleFormatter()({
|
|
82
|
+
* timestampFormat: "relative",
|
|
83
|
+
* }),
|
|
84
|
+
* });
|
|
85
|
+
*
|
|
86
|
+
* const deps = { ...createRelayDeps(), console };
|
|
87
|
+
*
|
|
88
|
+
* await using run = createRun(deps);
|
|
89
|
+
* await using stack = new AsyncDisposableStack();
|
|
90
|
+
*
|
|
91
|
+
* stack.use(
|
|
92
|
+
* await run.orThrow(
|
|
93
|
+
* startRelay({
|
|
94
|
+
* port: 4000,
|
|
95
|
+
*
|
|
96
|
+
* // Note: Relay requires URL in format ws://host:port/<ownerId>
|
|
97
|
+
* // isOwnerAllowed: (_ownerId) => true,
|
|
98
|
+
*
|
|
99
|
+
* isOwnerWithinQuota: (_ownerId, requiredBytes) => {
|
|
100
|
+
* const maxBytes = 1024 * 1024; // 1MB
|
|
101
|
+
* return requiredBytes <= maxBytes;
|
|
102
|
+
* },
|
|
103
|
+
* }),
|
|
104
|
+
* ),
|
|
105
|
+
* );
|
|
106
|
+
*
|
|
107
|
+
* await run.deps.shutdown;
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, }) => async (run) => {
|
|
111
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
112
|
+
try {
|
|
113
|
+
const stack = __addDisposableResource(env_1, new AsyncDisposableStack(), true);
|
|
114
|
+
const console = run.deps.console.child("relay");
|
|
115
|
+
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 deps = { ...run.deps, sqlite };
|
|
121
|
+
if (!dbFileExists) {
|
|
122
|
+
createBaseSqliteStorageTables(deps);
|
|
123
|
+
createRelayStorageTables(deps);
|
|
124
|
+
}
|
|
125
|
+
const relayRun = run.create().addDeps({
|
|
126
|
+
storage: createRelaySqliteStorage(deps)({
|
|
127
|
+
isOwnerWithinQuota,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
const server = createServer();
|
|
131
|
+
const wss = new WebSocketServer({
|
|
132
|
+
maxPayload: defaultProtocolMessageMaxSize,
|
|
133
|
+
noServer: true,
|
|
134
|
+
});
|
|
135
|
+
const ownerSocketRelation = createRelation();
|
|
136
|
+
server.on("upgrade", (request, socket, head) => {
|
|
137
|
+
socket.on("error", console.debug);
|
|
138
|
+
const completeUpgrade = () => {
|
|
139
|
+
socket.removeListener("error", console.debug);
|
|
140
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
141
|
+
wss.emit("connection", ws, request);
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
if (!isOwnerAllowed) {
|
|
145
|
+
completeUpgrade();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
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");
|
|
152
|
+
socket.destroy();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
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");
|
|
161
|
+
socket.destroy();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
completeUpgrade();
|
|
165
|
+
})();
|
|
166
|
+
});
|
|
167
|
+
wss.on("connection", (ws) => {
|
|
168
|
+
console.debug("on connection", wss.clients.size);
|
|
169
|
+
const options = {
|
|
170
|
+
subscribe: (ownerId) => {
|
|
171
|
+
ownerSocketRelation.add(ownerId, ws);
|
|
172
|
+
console.debug("subscribe", ownerId, ownerSocketRelation.bCountForA(ownerId));
|
|
173
|
+
},
|
|
174
|
+
unsubscribe: (ownerId) => {
|
|
175
|
+
ownerSocketRelation.remove(ownerId, ws);
|
|
176
|
+
console.debug("unsubscribe", ownerId, ownerSocketRelation.bCountForA(ownerId));
|
|
177
|
+
},
|
|
178
|
+
broadcast: (ownerId, message) => {
|
|
179
|
+
for (const socket of ownerSocketRelation.iterateB(ownerId)) {
|
|
180
|
+
if (socket !== ws && socket.readyState === WebSocket.OPEN) {
|
|
181
|
+
socket.send(message, { binary: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
console.debug("broadcast", ownerId, ownerSocketRelation.bCountForA(ownerId));
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
ws.on("message", (message) => {
|
|
188
|
+
if (!Uint8Array.is(message))
|
|
189
|
+
return;
|
|
190
|
+
void (async () => {
|
|
191
|
+
const response = await relayRun(applyProtocolMessageAsRelay(message, options));
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
console.error(response);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
ws.send(response.value.message, { binary: true });
|
|
197
|
+
})();
|
|
198
|
+
});
|
|
199
|
+
ws.on("close", () => {
|
|
200
|
+
ownerSocketRelation.removeByB(ws);
|
|
201
|
+
console.debug("ws close", wss.clients.size);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
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
|
+
stack.defer(() => {
|
|
222
|
+
console.info("Shutting down...");
|
|
223
|
+
for (const client of wss.clients) {
|
|
224
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
225
|
+
client.close(1000, "Evolu Relay shutting down");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
stack.use(relayRun);
|
|
230
|
+
server.listen(port);
|
|
231
|
+
console.info(`Started on port ${port}`);
|
|
232
|
+
return ok(stack.move());
|
|
233
|
+
}
|
|
234
|
+
catch (e_1) {
|
|
235
|
+
env_1.error = e_1;
|
|
236
|
+
env_1.hasError = true;
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
const result_1 = __disposeResources(env_1);
|
|
240
|
+
if (result_1)
|
|
241
|
+
await result_1;
|
|
242
|
+
}
|
|
243
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/nodejs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0-next.0",
|
|
4
4
|
"description": "Evolu for Node.js",
|
|
5
5
|
"author": "Daniel Steigerwald <daniel@steigerwald.cz>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,41 +10,43 @@
|
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://evolu.dev",
|
|
12
12
|
"type": "module",
|
|
13
|
-
"types": "./dist/index.d.ts",
|
|
13
|
+
"types": "./dist/src/index.d.ts",
|
|
14
14
|
"exports": {
|
|
15
|
-
".":
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/src/index.d.ts",
|
|
17
|
+
"import": "./dist/src/index.js",
|
|
18
|
+
"default": "./dist/src/index.js"
|
|
19
|
+
}
|
|
16
20
|
},
|
|
17
21
|
"files": [
|
|
18
|
-
"dist/**",
|
|
22
|
+
"dist/src/**",
|
|
19
23
|
"src/**",
|
|
20
24
|
"README.md"
|
|
21
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc --build tsconfig.build.json",
|
|
28
|
+
"prepack": "npm run build",
|
|
29
|
+
"format": "prettier --write \"src/*.{ts,tsx,md}\""
|
|
30
|
+
},
|
|
22
31
|
"dependencies": {
|
|
23
|
-
"better-sqlite3": "^12.
|
|
24
|
-
"ws": "^8.
|
|
32
|
+
"better-sqlite3": "^12.6.2",
|
|
33
|
+
"ws": "^8.19.0"
|
|
25
34
|
},
|
|
26
35
|
"devDependencies": {
|
|
36
|
+
"@evolu/common": "8.0.0-next.0",
|
|
37
|
+
"@evolu/typescript-config": "*",
|
|
27
38
|
"@types/better-sqlite3": "^7.6.13",
|
|
28
|
-
"@types/node": "^
|
|
39
|
+
"@types/node": "^24.10.9",
|
|
29
40
|
"@types/ws": "^8.18.1",
|
|
30
|
-
"typescript": "^5.9.
|
|
31
|
-
"vitest": "^4.0.4",
|
|
32
|
-
"@evolu/common": "7.4.0",
|
|
33
|
-
"@evolu/tsconfig": "0.0.2"
|
|
41
|
+
"typescript": "^5.9.3"
|
|
34
42
|
},
|
|
35
43
|
"peerDependencies": {
|
|
36
|
-
"@evolu/common": "^
|
|
44
|
+
"@evolu/common": "^8.0.0-next.0"
|
|
37
45
|
},
|
|
38
46
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
47
|
+
"node": ">=24.0.0"
|
|
40
48
|
},
|
|
41
49
|
"publishConfig": {
|
|
42
50
|
"access": "public"
|
|
43
|
-
},
|
|
44
|
-
"scripts": {
|
|
45
|
-
"dev": "tsc --watch",
|
|
46
|
-
"build": "rimraf dist && tsc",
|
|
47
|
-
"clean": "rimraf .turbo node_modules dist",
|
|
48
|
-
"format": "prettier --write \"src/*.{ts,tsx,md}\""
|
|
49
51
|
}
|
|
50
|
-
}
|
|
52
|
+
}
|
package/src/Crypto.ts
CHANGED
package/src/Sqlite.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPreparedStatementsCache,
|
|
3
|
+
lazyVoid,
|
|
4
|
+
ok,
|
|
5
|
+
type CreateSqliteDriver,
|
|
6
|
+
type SqliteDriver,
|
|
7
|
+
type SqliteRow,
|
|
8
|
+
} from "@evolu/common";
|
|
9
|
+
import BetterSQLite, { type Statement } from "better-sqlite3";
|
|
10
|
+
|
|
11
|
+
export const createBetterSqliteDriver: CreateSqliteDriver =
|
|
12
|
+
(name, options) => () => {
|
|
13
|
+
const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`;
|
|
14
|
+
const stack = new globalThis.DisposableStack();
|
|
15
|
+
const db = stack.adopt(new BetterSQLite(filename), (db) => {
|
|
16
|
+
db.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const cache = stack.use(
|
|
20
|
+
createPreparedStatementsCache<Statement>(
|
|
21
|
+
(sql) => db.prepare(sql),
|
|
22
|
+
// Not needed.
|
|
23
|
+
// https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement
|
|
24
|
+
lazyVoid,
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const driver: SqliteDriver = {
|
|
29
|
+
exec: (query) => {
|
|
30
|
+
// Always prepare is recommended for better-sqlite3
|
|
31
|
+
const prepared = cache.get(query, true);
|
|
32
|
+
|
|
33
|
+
if (prepared.reader) {
|
|
34
|
+
const rows = prepared.all(query.parameters) as Array<SqliteRow>;
|
|
35
|
+
return { rows, changes: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const changes = prepared.run(query.parameters).changes;
|
|
39
|
+
return { rows: [], changes };
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
export: () => {
|
|
43
|
+
const file = db.serialize();
|
|
44
|
+
const { buffer } = file;
|
|
45
|
+
|
|
46
|
+
if (buffer instanceof ArrayBuffer) {
|
|
47
|
+
return new Uint8Array(buffer, file.byteOffset, file.byteLength);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ensure export uses transferable ArrayBuffer backing.
|
|
51
|
+
return new Uint8Array(file);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
[Symbol.dispose]: () => {
|
|
55
|
+
stack.dispose();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return ok(driver);
|
|
60
|
+
};
|