@evolu/nodejs 3.0.0-next.3 → 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,5 +1,5 @@
1
1
  import type { BroadcastChannel, CreateBroadcastChannel } from "@evolu/common";
2
- import { assertNotDisposed } from "@evolu/common";
2
+ import { disposable } from "@evolu/common";
3
3
 
4
4
  /** Creates a {@link BroadcastChannel} from a Node.js BroadcastChannel. */
5
5
  export const createBroadcastChannel: CreateBroadcastChannel = <
@@ -10,34 +10,36 @@ export const createBroadcastChannel: CreateBroadcastChannel = <
10
10
  ): BroadcastChannel<Input, Output> => {
11
11
  const nativeBroadcastChannel = new globalThis.BroadcastChannel(name);
12
12
  using disposer = new DisposableStack();
13
+ let disposed = false;
13
14
 
14
15
  disposer.defer(() => {
16
+ disposed = true;
15
17
  nativeBroadcastChannel.onmessage = null;
16
18
  nativeBroadcastChannel.close();
17
19
  });
18
20
 
19
- const disposables = disposer.move();
20
21
  let onMessageHandler: ((message: Output) => void) | null = null;
21
22
 
22
- return {
23
- postMessage: (message) => {
24
- assertNotDisposed(disposables);
25
- nativeBroadcastChannel.postMessage(message);
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
+ },
26
40
  },
27
- get onMessage() {
28
- return disposables.disposed ? null : onMessageHandler;
29
- },
30
- set onMessage(fn) {
31
- if (disposables.disposed) return;
32
- onMessageHandler = fn;
33
- nativeBroadcastChannel.onmessage = fn
34
- ? (event: MessageEvent<Output>) => {
35
- fn(event.data);
36
- }
37
- : null;
38
- },
39
- [Symbol.dispose]: () => disposables.dispose(),
40
- };
41
+ disposer,
42
+ );
41
43
  };
42
44
 
43
45
  // TODO: Implement Node.js Worker API
package/src/index.ts CHANGED
@@ -1,6 +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";
6
- export * from "./Worker.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 disposer = new AsyncDisposableStack();
71
+ * await runMain(deps)(
72
+ * createRelay({
73
+ * port: 4000,
73
74
  *
74
- * disposer.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"),
@@ -101,12 +94,8 @@ export const startRelay =
101
94
  await using disposer = new AsyncDisposableStack();
102
95
  const console = run.deps.console.child("relay");
103
96
 
104
- disposer.defer(() => {
105
- console.info("Shutdown complete");
106
- });
107
-
108
97
  const dbFileExists = existsSync(`${name}.db`);
109
- const sqlite = disposer.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) {
@@ -135,14 +124,8 @@ export const startRelay =
135
124
 
136
125
  const ownerSocketRelation = createRelation<OwnerId, WebSocket>();
137
126
 
138
- const relayRun = disposer.use(
139
- run.create({
140
- ...run.deps,
141
- storage: createRelaySqliteStorage(deps)({
142
- isOwnerWithinQuota,
143
- }),
144
- }),
145
- );
127
+ const storage = createRelaySqliteStorage(deps)({ isOwnerWithinQuota });
128
+ const relayRun = disposer.use(run.create({ storage }));
146
129
 
147
130
  server.on("upgrade", (request, socket, head) => {
148
131
  socket.on("error", console.debug);
@@ -181,33 +164,43 @@ export const startRelay =
181
164
  return;
182
165
  }
183
166
 
184
- const authorizationFiber = relayRun(
185
- callback<boolean, unknown>(({ ok, err, signal }) => {
186
- void Promise.try(() => isOwnerAllowed(ownerId, { signal })).then(
187
- ok,
188
- err,
189
- );
190
- }),
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
+ ),
191
179
  );
192
180
 
193
181
  const abortAuthorization = () => {
194
- authorizationFiber.abort("WebSocket upgrade request socket closed");
182
+ authorizationFiber.abort({
183
+ type: "WebSocketUpgradeSocketClosed",
184
+ });
195
185
  };
196
186
 
197
187
  socket.once("close", abortAuthorization);
198
188
  socket.once("error", abortAuthorization);
199
189
 
200
- void authorizationFiber.then((result) => {
190
+ void (async () => {
191
+ const result = await authorizationFiber;
192
+
201
193
  socket.removeListener("close", abortAuthorization);
202
194
  socket.removeListener("error", abortAuthorization);
203
195
 
204
196
  if (!result.ok) {
205
- if (!AbortError.is(result.error)) {
206
- console.error("isOwnerAllowed failed", ownerId, result.error);
207
- respondAndDestroy(503);
197
+ if (result.error.type === "AbortError") {
198
+ socket.destroy();
208
199
  return;
209
200
  }
210
- socket.destroy();
201
+
202
+ console.error(result.error.error);
203
+ respondAndDestroy(503);
211
204
  return;
212
205
  }
213
206
 
@@ -218,7 +211,7 @@ export const startRelay =
218
211
  }
219
212
 
220
213
  completeUpgrade();
221
- });
214
+ })();
222
215
  });
223
216
 
224
217
  wss.on("connection", (ws) => {
@@ -262,13 +255,16 @@ export const startRelay =
262
255
  if (!Uint8Array.is(message)) return;
263
256
 
264
257
  void (async () => {
265
- const response = await relayRun(
258
+ const response = await relayRun.abortable(
266
259
  applyProtocolMessageAsRelay(message, options),
267
260
  );
261
+
268
262
  if (!response.ok) {
263
+ if (response.error.type === "AbortError") return;
269
264
  console.error(response);
270
265
  return;
271
266
  }
267
+
272
268
  ws.send(response.value.message, { binary: true });
273
269
  })();
274
270
  });
@@ -280,7 +276,6 @@ export const startRelay =
280
276
  });
281
277
 
282
278
  disposer.defer(() => {
283
- console.info("Shutting down...");
284
279
  for (const client of wss.clients) {
285
280
  if (client.readyState === WebSocket.OPEN) {
286
281
  client.close(1000, "Evolu Relay shutting down");