@evolu/nodejs 3.0.0-next.2 → 3.0.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/src/Time.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Node.js-specific time utilities.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import {
8
+ assert,
9
+ createTime,
10
+ type Brand,
11
+ Millis,
12
+ type Time,
13
+ } from "@evolu/common";
14
+
15
+ /** {@link Time} with Node.js high-resolution nanosecond readings. */
16
+ export interface NodejsTime extends Time {
17
+ /** Returns a monotonic high-resolution timestamp in nanoseconds. */
18
+ readonly hrtime: () => HrTime;
19
+ }
20
+
21
+ /** Monotonic high-resolution timestamp in nanoseconds. */
22
+ export type HrTime = bigint & Brand<"HrTime">;
23
+
24
+ /** Elapsed nanoseconds measured using {@link HrTime}. */
25
+ export type HrDuration = bigint & Brand<"HrDuration">;
26
+
27
+ /** Creates a {@link NodejsTime} using `process.hrtime.bigint()`. */
28
+ export const createNodejsTime = (): NodejsTime => ({
29
+ ...createTime(),
30
+ hrtime: () => process.hrtime.bigint() as HrTime,
31
+ });
32
+
33
+ /**
34
+ * Returns the elapsed nanoseconds between two high-resolution timestamps.
35
+ *
36
+ * Throws if `end` precedes `start`.
37
+ */
38
+ export const hrDurationBetween = (start: HrTime, end: HrTime): HrDuration => {
39
+ assert(end >= start, "High-resolution end time must not precede start time");
40
+ return (end - start) as HrDuration;
41
+ };
42
+
43
+ /** Converts a high-resolution duration to the nearest millisecond. */
44
+ export const hrDurationToMillis = (duration: HrDuration): Millis =>
45
+ Millis.orThrow(
46
+ Number(
47
+ (duration + nanosecondsPerMillisecond / 2n) / nanosecondsPerMillisecond,
48
+ ),
49
+ );
50
+
51
+ /** Converts milliseconds to an exact high-resolution duration. */
52
+ export const millisToHrDuration = (millis: Millis): HrDuration =>
53
+ (BigInt(millis) * nanosecondsPerMillisecond) as HrDuration;
54
+
55
+ const nanosecondsPerMillisecond = 1_000_000n;
package/src/Worker.ts CHANGED
@@ -1,3 +1,47 @@
1
+ import type { BroadcastChannel, CreateBroadcastChannel } from "@evolu/common";
2
+ import { disposable } from "@evolu/common";
3
+
4
+ /** Creates a {@link BroadcastChannel} from a Node.js BroadcastChannel. */
5
+ export const createBroadcastChannel: CreateBroadcastChannel = <
6
+ Input,
7
+ Output = Input,
8
+ >(
9
+ name: string,
10
+ ): BroadcastChannel<Input, Output> => {
11
+ const nativeBroadcastChannel = new globalThis.BroadcastChannel(name);
12
+ using disposer = new DisposableStack();
13
+ let disposed = false;
14
+
15
+ disposer.defer(() => {
16
+ disposed = true;
17
+ nativeBroadcastChannel.onmessage = null;
18
+ nativeBroadcastChannel.close();
19
+ });
20
+
21
+ let onMessageHandler: ((message: Output) => void) | null = null;
22
+
23
+ return disposable<BroadcastChannel<Input, Output>>(
24
+ {
25
+ postMessage: (message) => {
26
+ nativeBroadcastChannel.postMessage(message);
27
+ },
28
+ get onMessage() {
29
+ return disposed ? null : onMessageHandler;
30
+ },
31
+ set onMessage(fn) {
32
+ if (disposed) return;
33
+ onMessageHandler = fn;
34
+ nativeBroadcastChannel.onmessage = fn
35
+ ? (event: MessageEvent<Output>) => {
36
+ fn(event.data);
37
+ }
38
+ : null;
39
+ },
40
+ },
41
+ disposer,
42
+ );
43
+ };
44
+
1
45
  // TODO: Implement Node.js Worker API
2
46
  //
3
47
  // This module should provide Node.js implementations of the common Worker API:
package/src/index.ts CHANGED
@@ -1,5 +1,14 @@
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";
1
+ /**
2
+ * @module
3
+ * @mergeModuleWith <project>
4
+ */
5
+
6
+ export * from "./Cli.ts";
7
+ export * from "./Crypto.ts";
8
+ export * from "./local-first/Relay.ts";
9
+ export * from "./Platform.ts";
10
+ export * from "./Sqlite.ts";
11
+ export * from "./Task.ts";
12
+ export * from "./Time.ts";
13
+ export * from "./WebSocket.ts";
14
+ export * from "./Worker.ts";
@@ -1,17 +1,17 @@
1
1
  import {
2
- AbortError,
3
2
  assert,
4
- callback,
5
3
  createRandom,
6
4
  createRelation,
7
5
  createSqlite,
8
6
  type CreateSqliteDriverDep,
7
+ daemon,
9
8
  Name,
10
9
  ok,
11
10
  OwnerId,
12
11
  type RandomDep,
13
12
  type Task,
14
13
  type TimingSafeEqualDep,
14
+ tryAsync,
15
15
  Uint8Array,
16
16
  } from "@evolu/common";
17
17
  import {
@@ -29,8 +29,8 @@ 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 { createTimingSafeEqual } from "../Crypto.js";
33
- import { createBetterSqliteDriver } from "../Sqlite.js";
32
+ import { createTimingSafeEqual } from "../Crypto.ts";
33
+ import { createBetterSqliteDriver } from "../Sqlite.ts";
34
34
 
35
35
  export interface NodeJsRelayConfig extends RelayConfig {
36
36
  /** The port number for the HTTP server. */
@@ -39,7 +39,7 @@ export interface NodeJsRelayConfig extends RelayConfig {
39
39
 
40
40
  export type RelayDeps = CreateSqliteDriverDep & RandomDep & TimingSafeEqualDep;
41
41
 
42
- /** Dependencies for {@link startRelay} using better-sqlite3. */
42
+ /** Dependencies for {@link createRelay} using better-sqlite3. */
43
43
  export const createRelayDeps = (): RelayDeps => ({
44
44
  createSqliteDriver: createBetterSqliteDriver,
45
45
  random: createRandom(),
@@ -47,7 +47,7 @@ export const createRelayDeps = (): RelayDeps => ({
47
47
  });
48
48
 
49
49
  /**
50
- * Starts an Evolu relay server using Node.js.
50
+ * Creates an Evolu Relay server resource using Node.js.
51
51
  *
52
52
  * Use {@link createRelayDeps} to create dependencies for better-sqlite3, or
53
53
  * provide a custom SQLite driver implementation.
@@ -68,29 +68,22 @@ export const createRelayDeps = (): RelayDeps => ({
68
68
  *
69
69
  * const deps = { ...createRelayDeps(), console };
70
70
  *
71
- * await using run = createRun(deps);
72
- * await using stack = new AsyncDisposableStack();
71
+ * await runMain(deps)(
72
+ * createRelay({
73
+ * port: 4000,
73
74
  *
74
- * stack.use(
75
- * await run.orThrow(
76
- * startRelay({
77
- * port: 4000,
75
+ * // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
76
+ * // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
78
77
  *
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
- * ),
78
+ * isOwnerWithinQuota: (_ownerId, requiredBytes) => {
79
+ * const maxBytes = 1024 * 1024; // 1MB
80
+ * return requiredBytes <= maxBytes;
81
+ * },
82
+ * }),
88
83
  * );
89
- *
90
- * await run.deps.shutdown;
91
84
  * ```
92
85
  */
93
- export const startRelay =
86
+ export const createRelay =
94
87
  ({
95
88
  port = 443,
96
89
  name = Name.orThrow("evolu-relay"),
@@ -98,15 +91,11 @@ export const startRelay =
98
91
  isOwnerWithinQuota,
99
92
  }: NodeJsRelayConfig): Task<Relay, never, RelayDeps> =>
100
93
  async (run) => {
101
- await using stack = new AsyncDisposableStack();
94
+ await using disposer = new AsyncDisposableStack();
102
95
  const console = run.deps.console.child("relay");
103
96
 
104
- stack.defer(() => {
105
- console.info("Shutdown complete");
106
- });
107
-
108
97
  const dbFileExists = existsSync(`${name}.db`);
109
- const sqlite = stack.use(await run.orThrow(createSqlite(name)));
98
+ const sqlite = disposer.use(await run.ok(createSqlite(name)));
110
99
  const deps = { ...run.deps, sqlite };
111
100
 
112
101
  if (!dbFileExists) {
@@ -114,12 +103,12 @@ export const startRelay =
114
103
  createRelayStorageTables(deps);
115
104
  }
116
105
 
117
- const server = stack.use(createServer());
106
+ const server = disposer.use(createServer());
118
107
  server.once("close", () => {
119
108
  console.info("HTTP server closed");
120
109
  });
121
110
 
122
- const wss = stack.adopt(
111
+ const wss = disposer.adopt(
123
112
  new WebSocketServer({
124
113
  maxPayload: defaultProtocolMessageMaxSize,
125
114
  noServer: true,
@@ -135,13 +124,8 @@ export const startRelay =
135
124
 
136
125
  const ownerSocketRelation = createRelation<OwnerId, WebSocket>();
137
126
 
138
- const relayRun = stack.use(
139
- run.create().addDeps({
140
- storage: createRelaySqliteStorage(deps)({
141
- isOwnerWithinQuota,
142
- }),
143
- }),
144
- );
127
+ const storage = createRelaySqliteStorage(deps)({ isOwnerWithinQuota });
128
+ const relayRun = disposer.use(run.create({ storage }));
145
129
 
146
130
  server.on("upgrade", (request, socket, head) => {
147
131
  socket.on("error", console.debug);
@@ -180,33 +164,43 @@ export const startRelay =
180
164
  return;
181
165
  }
182
166
 
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
- }),
167
+ const authorizationFiber = relayRun.abortable(
168
+ // Use daemon because authorization can call an external service that
169
+ // ignores abort. The daemon runs in the root Run, so aborting the
170
+ // current Run does not make its Fiber wait for the service Promise to
171
+ // settle.
172
+ daemon(
173
+ async (run) =>
174
+ await tryAsync(
175
+ () => isOwnerAllowed(ownerId, { signal: run.signal }),
176
+ (error) => ({ type: "OwnerAuthorizationError", error }) as const,
177
+ ),
178
+ ),
190
179
  );
191
180
 
192
181
  const abortAuthorization = () => {
193
- authorizationFiber.abort("WebSocket upgrade request socket closed");
182
+ authorizationFiber.abort({
183
+ type: "WebSocketUpgradeSocketClosed",
184
+ });
194
185
  };
195
186
 
196
187
  socket.once("close", abortAuthorization);
197
188
  socket.once("error", abortAuthorization);
198
189
 
199
- void authorizationFiber.then((result) => {
190
+ void (async () => {
191
+ const result = await authorizationFiber;
192
+
200
193
  socket.removeListener("close", abortAuthorization);
201
194
  socket.removeListener("error", abortAuthorization);
202
195
 
203
196
  if (!result.ok) {
204
- if (!AbortError.is(result.error)) {
205
- console.error("isOwnerAllowed failed", ownerId, result.error);
206
- respondAndDestroy(503);
197
+ if (result.error.type === "AbortError") {
198
+ socket.destroy();
207
199
  return;
208
200
  }
209
- socket.destroy();
201
+
202
+ console.error(result.error.error);
203
+ respondAndDestroy(503);
210
204
  return;
211
205
  }
212
206
 
@@ -217,7 +211,7 @@ export const startRelay =
217
211
  }
218
212
 
219
213
  completeUpgrade();
220
- });
214
+ })();
221
215
  });
222
216
 
223
217
  wss.on("connection", (ws) => {
@@ -261,13 +255,16 @@ export const startRelay =
261
255
  if (!Uint8Array.is(message)) return;
262
256
 
263
257
  void (async () => {
264
- const response = await relayRun(
258
+ const response = await relayRun.abortable(
265
259
  applyProtocolMessageAsRelay(message, options),
266
260
  );
261
+
267
262
  if (!response.ok) {
263
+ if (response.error.type === "AbortError") return;
268
264
  console.error(response);
269
265
  return;
270
266
  }
267
+
271
268
  ws.send(response.value.message, { binary: true });
272
269
  })();
273
270
  });
@@ -278,8 +275,7 @@ export const startRelay =
278
275
  });
279
276
  });
280
277
 
281
- stack.defer(() => {
282
- console.info("Shutting down...");
278
+ disposer.defer(() => {
283
279
  for (const client of wss.clients) {
284
280
  if (client.readyState === WebSocket.OPEN) {
285
281
  client.close(1000, "Evolu Relay shutting down");
@@ -293,13 +289,13 @@ export const startRelay =
293
289
  const address = server.address();
294
290
  assert(address && typeof address !== "string", "Expected TCP address");
295
291
 
296
- const moved = stack.move();
292
+ const disposables = disposer.move();
297
293
 
298
294
  console.info(`Started on port ${address.port}`);
299
295
 
300
296
  return ok({
301
297
  port: address.port,
302
- [Symbol.asyncDispose]: () => moved.disposeAsync(),
298
+ [Symbol.asyncDispose]: () => disposables.disposeAsync(),
303
299
  });
304
300
  };
305
301