wire-mesh 3.3.0 → 3.5.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 CHANGED
@@ -34,14 +34,15 @@ Every flag the CLI accepts, exactly as `wire-mesh --help` prints it. An unknown
34
34
  Usage: wire-mesh [options]
35
35
 
36
36
  Options:
37
- --bind <host:port> Address to listen on. Port 0 asks the OS for a free port. Use 127.0.0.1:8787 to accept local connections only. (default: 0.0.0.0:8787)
38
- --tls-cert <path> PEM certificate file, to serve wss:// and https://. Requires --tls-key.
39
- --tls-key <path> PEM private key file for --tls-cert. Requires --tls-cert.
40
- --mailbox-dir <path> Directory to hold other devices' logs in while they are offline, which makes this node an announcer as well as a relay. Bounded by fixed limits. Off unless given.
41
- --webtransport <host:port> Also serve WebTransport on this UDP address, with a self-signed certificate renewed on a schedule that a browser accepts by pinning its hash, and print the address to give a console. Needs the optional @fails-components/webtransport dependency. Off unless given.
42
- --state-dir <path> Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport.
43
- -h, --help Print this help and exit.
44
- -v, --version Print the version and exit.
37
+ --bind <host:port> Address to listen on. Port 0 asks the OS for a free port. Use 127.0.0.1:8787 to accept local connections only. (default: 0.0.0.0:8787)
38
+ --tls-cert <path> PEM certificate file, to serve wss:// and https://. Requires --tls-key.
39
+ --tls-key <path> PEM private key file for --tls-cert. Requires --tls-cert.
40
+ --mailbox-dir <path> Directory to hold other devices' logs in while they are offline, which makes this node an announcer as well as a relay. Bounded by fixed limits. Off unless given.
41
+ --webtransport <host:port> Also serve WebTransport on this UDP address, with a self-signed certificate renewed on a schedule that a browser accepts by pinning its hash, and print the address to give a console. Needs the optional @fails-components/webtransport dependency. Off unless given.
42
+ --state-dir <path> Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport.
43
+ --certificate-lifetime <seconds> How long each WebTransport certificate is valid, in seconds. The node rotates to the next certificate every half lifetime, ending the sessions open on the server it replaces, so a shorter lifetime rotates more often. At most the WebTransport limit for a pinned certificate, which is also the default. Needs --webtransport.
44
+ -h, --help Print this help and exit.
45
+ -v, --version Print the version and exit.
45
46
  ```
46
47
 
47
48
  **Default bind address is `0.0.0.0:8787`, not loopback.** Unlike a typical dev-server tool, whose loopback-only default assumes only the machine itself needs to reach it, this package's whole purpose is LAN reachability — a phone on the same network, a laptop in the next room. `--bind` overrides the address if you want to restrict it (loopback-only, a specific interface, a different port).
@@ -55,7 +56,7 @@ curl http://<host>:8787/health
55
56
 
56
57
  With `--webtransport <host:port>` the node also serves WebTransport on a UDP port, for a browser that has no way to trust a certificate for this node's LAN address. The node mints self-signed ECDSA certificates, each valid for under two weeks, and prints an address of the form `https://<host>:<port>#sha256=<hash>[,<hash>...]` for each address of this machine a client can reach (when bound to a wildcard, one per non-internal IPv4 address). Give one of them to a console (the Node field takes it as typed): a browser accepts the certificate whose hash matches any in the list, with no certificate authority and no internet.
57
58
 
58
- The list holds the certificate served now and the ones that will replace it. The node rotates to the next certificate every half lifetime, by replacing its WebTransport server, so open sessions on that server end and a console reconnects, and an address stays usable for about three of those periods after it was copied. Without `--state-dir` the certificates live in memory, so a restart starts a new set and every address already handed out stops working; with it the certificates, private keys included, are kept in that directory (created readable by this user only) and a restart resumes them. The server is the optional `@fails-components/webtransport` dependency with its native binary; where it is not installed the flag fails at startup rather than serving without it. The prebuilt binary needs glibc 2.38 or newer on Linux.
59
+ The list holds the certificate served now and the ones that will replace it. The node rotates to the next certificate every half lifetime, by replacing its WebTransport server, so open sessions on that server end and a console reconnects, and an address stays usable for about three of those periods after it was copied, and a console that reconnects is told the current list by the node, so one that keeps reconnecting never needs a new address. Without `--state-dir` the certificates live in memory, so a restart starts a new set and every address already handed out stops working; with it the certificates, private keys included, are kept in that directory (created readable by this user only) and a restart resumes them. The server is the optional `@fails-components/webtransport` dependency with its native binary; where it is not installed the flag fails at startup rather than serving without it. The prebuilt binary needs glibc 2.38 or newer on Linux.
59
60
 
60
61
  With `--mailbox-dir` the node also serves the announcer role and `/health` says so (`"roles":["relay","announcer"]`). It then holds other devices' `core/data` logs in that directory while those devices are offline, so a peer that reconnects catches up from the node. Only the device that owns a log can write to it: the node takes a log's entries from the connection whose verified advert names that device and from no one else. Any client that has gossiped an advert can read a log. Entries are opaque bytes to the node, and the applications that write them sign and encrypt their own. The number of logs, the bytes in each and the size of an entry are bounded (`src/mailbox-limits.ts`), and a frame that would exceed a bound is refused whole; nothing is evicted, so a full log stays full until the directory is cleared.
61
62
 
package/dist/server.cjs CHANGED
@@ -16,6 +16,12 @@ let wire_mesh_core_domain_pinned_address = require("wire-mesh-core/domain/pinned
16
16
  let wire_mesh_core_adapters_memory_storage = require("wire-mesh-core/adapters/memory-storage");
17
17
  require("reflect-metadata");
18
18
  let _peculiar_x509 = require("@peculiar/x509");
19
+ //#region src/adapters/certificate-limits.ts
20
+ const MILLISECONDS_PER_SECOND = 1e3;
21
+ const MILLISECONDS_PER_DAY = 86400 * MILLISECONDS_PER_SECOND;
22
+ /** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
23
+ const PINNED_CERTIFICATE_LIFETIME_MS = 14 * MILLISECONDS_PER_DAY - 1 * MILLISECONDS_PER_DAY;
24
+ //#endregion
19
25
  //#region src/cli-options.ts
20
26
  /** The address the server binds when --bind is not given. */
21
27
  const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
@@ -53,6 +59,11 @@ const FLAGS = {
53
59
  valueName: "path",
54
60
  description: "Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport."
55
61
  },
62
+ "certificate-lifetime": {
63
+ type: "string",
64
+ valueName: "seconds",
65
+ description: "How long each WebTransport certificate is valid, in seconds. The node rotates to the next certificate every half lifetime, ending the sessions open on the server it replaces, so a shorter lifetime rotates more often. At most the WebTransport limit for a pinned certificate, which is also the default. Needs --webtransport."
66
+ },
56
67
  help: {
57
68
  type: "boolean",
58
69
  short: "h",
@@ -86,6 +97,12 @@ function helpText() {
86
97
  ...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
87
98
  ].join("\n");
88
99
  }
100
+ function validatedCertificateLifetimeMs(seconds) {
101
+ if (seconds === void 0) return;
102
+ const lifetimeMs = Number(seconds) * MILLISECONDS_PER_SECOND;
103
+ if (!/^\d+$/.test(seconds) || lifetimeMs === 0 || lifetimeMs > 11232e5) throw new CliUsageError(`--certificate-lifetime expects a whole number of seconds from 1 to ${String(PINNED_CERTIFICATE_LIFETIME_MS / MILLISECONDS_PER_SECOND)}, got "${seconds}"`);
104
+ return lifetimeMs;
105
+ }
89
106
  function validatedBindAddress(flag, address) {
90
107
  const separator = address.lastIndexOf(":");
91
108
  const host = address.slice(0, separator);
@@ -113,6 +130,7 @@ function parseCliArguments(argv) {
113
130
  const keyPath = values["tls-key"];
114
131
  if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
115
132
  if (values["state-dir"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--state-dir needs --webtransport");
133
+ if (values["certificate-lifetime"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--certificate-lifetime needs --webtransport");
116
134
  return {
117
135
  kind: "serve",
118
136
  bindAddress: validatedBindAddress("bind", values.bind),
@@ -122,7 +140,8 @@ function parseCliArguments(argv) {
122
140
  },
123
141
  mailboxDir: values["mailbox-dir"],
124
142
  webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
125
- stateDir: values["state-dir"]
143
+ stateDir: values["state-dir"],
144
+ certificateLifetimeMs: validatedCertificateLifetimeMs(values["certificate-lifetime"])
126
145
  };
127
146
  }
128
147
  //#endregion
@@ -136,13 +155,8 @@ const nodeMailboxLimits = {
136
155
  };
137
156
  //#endregion
138
157
  //#region src/adapters/pinned-certificate.ts
139
- const MILLISECONDS_PER_DAY = 864e5;
140
- const MAX_PINNED_CERTIFICATE_LIFETIME_DAYS = 14;
141
- const CLOCK_SKEW_ALLOWANCE_DAYS = 1;
142
158
  const SERIAL_NUMBER_BYTES = 16;
143
159
  const PEM_LINE_LENGTH = 64;
144
- /** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
145
- const PINNED_CERTIFICATE_LIFETIME_MS = MAX_PINNED_CERTIFICATE_LIFETIME_DAYS * MILLISECONDS_PER_DAY - CLOCK_SKEW_ALLOWANCE_DAYS * MILLISECONDS_PER_DAY;
146
160
  const SIGNING_ALGORITHM = {
147
161
  name: "ECDSA",
148
162
  namedCurve: "P-256",
@@ -247,6 +261,11 @@ async function advanceCertificateSchedule(storage, now, options = {}) {
247
261
  /** The path a client opens its WebTransport session on. */
248
262
  const WEBTRANSPORT_PATH = "/wire-mesh";
249
263
  const SECRET_BYTES = 32;
264
+ /** How long to wait before trying a failed rotation again: the port may still be held by the server just stopped. */
265
+ const ROTATION_RETRY_MS = 1e3;
266
+ /** The application close code and reason sent when a certificate rotation ends a session; the code is arbitrary, and 0 is what a normal close carries. */
267
+ const SESSION_CLOSE_CODE = 0;
268
+ const ROTATION_CLOSE_REASON = "certificate rotated";
250
269
  const WILDCARD_HOSTS = ["0.0.0.0", "::"];
251
270
  /** The hosts a client can dial for a listener bound to `host`: the host itself, or for a wildcard every non-internal IPv4 address of this machine. */
252
271
  function reachableHosts(host) {
@@ -271,7 +290,7 @@ async function loadHttp3Server() {
271
290
  }
272
291
  }
273
292
  function isIncomingSession(value) {
274
- return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
293
+ return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "closed" in value && value.closed instanceof Promise && "close" in value && typeof value.close === "function" && "createUnidirectionalStream" in value && typeof value.createUnidirectionalStream === "function" && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
275
294
  }
276
295
  /**
277
296
  * A WebTransport Transport: each session carries one connection, on the first bidirectional stream the client opens, framed as length-prefixed CBOR (core's byte-stream-connection). A server binds a UDP port and serves a self-signed ECDSA certificate it renews on a schedule, which a browser accepts by pinning its hash (`serverCertificateHashes`) with no certificate authority involved.
@@ -309,37 +328,77 @@ function createWebTransportTransport(options = {}) {
309
328
  const bound = started.address();
310
329
  if (bound === null) throw new Error("the WebTransport server bound no address");
311
330
  port = bound.port;
331
+ const liveSessions = /* @__PURE__ */ new Set();
332
+ const drainState = { draining: false };
333
+ const endSession = (session) => {
334
+ session.close({
335
+ closeCode: SESSION_CLOSE_CODE,
336
+ reason: ROTATION_CLOSE_REASON
337
+ });
338
+ };
312
339
  (async () => {
313
340
  for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
314
341
  if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
315
342
  await session.ready;
343
+ if (drainState.draining) {
344
+ endSession(session);
345
+ return;
346
+ }
347
+ liveSessions.add(session);
348
+ session.closed.catch(() => void 0).finally(() => {
349
+ liveSessions.delete(session);
350
+ });
351
+ const announcer = (await session.createUnidirectionalStream()).getWriter();
352
+ await announcer.write((0, wire_mesh_core_domain_pinned_address.encodeCertificateHashes)(schedule.advertised.map((certificate) => certificate.sha256Hex)));
353
+ await announcer.close();
316
354
  const reader = session.incomingBidirectionalStreams.getReader();
317
355
  const first = await reader.read();
318
356
  reader.releaseLock();
319
357
  if (!first.done) onConnection((0, wire_mesh_core_adapters_byte_stream_connection.connectionFromByteStream)(first.value));
320
358
  })().catch(reportError);
321
359
  })().catch(reportError);
322
- return started;
360
+ return {
361
+ server: started,
362
+ drain: async () => {
363
+ drainState.draining = true;
364
+ const open = [...liveSessions];
365
+ for (const session of open) endSession(session);
366
+ await Promise.allSettled(open.map(async (session) => session.closed));
367
+ }
368
+ };
323
369
  };
324
- let server = await startServer();
370
+ let running = await startServer();
325
371
  const advertise = () => reachableHosts(host).map((reachable) => (0, wire_mesh_core_domain_pinned_address.formatPinnedAddress)(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
372
+ /** Drains and stops the running server, if there is one: cleared first, so a rotation that failed after this point is retried by starting a server rather than by retiring one that is already gone. */
373
+ const retire = async () => {
374
+ const current = running;
375
+ running = void 0;
376
+ if (current === void 0) return;
377
+ await current.drain();
378
+ current.server.stopServer();
379
+ await current.server.closed;
380
+ };
326
381
  let rotation;
327
382
  let closed = false;
328
- const armRotation = () => {
383
+ const untilRotation = () => Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0);
384
+ const armRotation = (delayMs) => {
329
385
  rotation = setTimeout(() => {
330
386
  (async () => {
331
387
  schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
332
- server.stopServer();
333
- await server.closed;
388
+ await retire();
334
389
  if (closed) return;
335
- server = await startServer();
390
+ running = await startServer();
336
391
  options.onCertificateRenewed?.(advertise());
337
- armRotation();
338
- })().catch(reportError);
339
- }, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
392
+ })().then(() => {
393
+ if (!closed) armRotation(untilRotation());
394
+ }, (error) => {
395
+ reportError(error);
396
+ if (!closed) armRotation(ROTATION_RETRY_MS);
397
+ });
398
+ }, delayMs);
340
399
  rotation.unref();
341
400
  };
342
- armRotation();
401
+ armRotation(untilRotation());
343
402
  return {
344
403
  get address() {
345
404
  return `${host}:${String(port)}`;
@@ -350,8 +409,7 @@ function createWebTransportTransport(options = {}) {
350
409
  close: async () => {
351
410
  closed = true;
352
411
  clearTimeout(rotation);
353
- server.stopServer();
354
- await server.closed;
412
+ await retire();
355
413
  },
356
414
  unref: () => {
357
415
  rotation?.unref();
@@ -495,6 +553,7 @@ async function main(argv) {
495
553
  mode: STATE_DIR_MODE
496
554
  });
497
555
  const webTransport = await createWebTransportTransport({
556
+ ...command.certificateLifetimeMs !== void 0 ? { certificateLifetimeMs: command.certificateLifetimeMs } : {},
498
557
  ...command.stateDir !== void 0 ? { storage: (0, wire_mesh_core_adapters_node_fs_storage.createNodeFsStorage)({ dir: command.stateDir }) } : {},
499
558
  onError: (error) => {
500
559
  logError(`wire-mesh: WebTransport: ${String(error)}`);
package/dist/server.mjs CHANGED
@@ -11,10 +11,16 @@ import { createNodeFsStorage } from "wire-mesh-core/adapters/node-fs-storage";
11
11
  import { randomBytes } from "node:crypto";
12
12
  import { networkInterfaces } from "node:os";
13
13
  import { connectionFromByteStream } from "wire-mesh-core/adapters/byte-stream-connection";
14
- import { formatPinnedAddress } from "wire-mesh-core/domain/pinned-address";
14
+ import { encodeCertificateHashes, formatPinnedAddress } from "wire-mesh-core/domain/pinned-address";
15
15
  import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage";
16
16
  import "reflect-metadata";
17
17
  import { BasicConstraintsExtension, ExtendedKeyUsage, ExtendedKeyUsageExtension, KeyUsageFlags, KeyUsagesExtension, X509CertificateGenerator } from "@peculiar/x509";
18
+ //#region src/adapters/certificate-limits.ts
19
+ const MILLISECONDS_PER_SECOND = 1e3;
20
+ const MILLISECONDS_PER_DAY = 86400 * MILLISECONDS_PER_SECOND;
21
+ /** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
22
+ const PINNED_CERTIFICATE_LIFETIME_MS = 14 * MILLISECONDS_PER_DAY - 1 * MILLISECONDS_PER_DAY;
23
+ //#endregion
18
24
  //#region src/cli-options.ts
19
25
  /** The address the server binds when --bind is not given. */
20
26
  const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
@@ -52,6 +58,11 @@ const FLAGS = {
52
58
  valueName: "path",
53
59
  description: "Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport."
54
60
  },
61
+ "certificate-lifetime": {
62
+ type: "string",
63
+ valueName: "seconds",
64
+ description: "How long each WebTransport certificate is valid, in seconds. The node rotates to the next certificate every half lifetime, ending the sessions open on the server it replaces, so a shorter lifetime rotates more often. At most the WebTransport limit for a pinned certificate, which is also the default. Needs --webtransport."
65
+ },
55
66
  help: {
56
67
  type: "boolean",
57
68
  short: "h",
@@ -85,6 +96,12 @@ function helpText() {
85
96
  ...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
86
97
  ].join("\n");
87
98
  }
99
+ function validatedCertificateLifetimeMs(seconds) {
100
+ if (seconds === void 0) return;
101
+ const lifetimeMs = Number(seconds) * MILLISECONDS_PER_SECOND;
102
+ if (!/^\d+$/.test(seconds) || lifetimeMs === 0 || lifetimeMs > 11232e5) throw new CliUsageError(`--certificate-lifetime expects a whole number of seconds from 1 to ${String(PINNED_CERTIFICATE_LIFETIME_MS / MILLISECONDS_PER_SECOND)}, got "${seconds}"`);
103
+ return lifetimeMs;
104
+ }
88
105
  function validatedBindAddress(flag, address) {
89
106
  const separator = address.lastIndexOf(":");
90
107
  const host = address.slice(0, separator);
@@ -112,6 +129,7 @@ function parseCliArguments(argv) {
112
129
  const keyPath = values["tls-key"];
113
130
  if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
114
131
  if (values["state-dir"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--state-dir needs --webtransport");
132
+ if (values["certificate-lifetime"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--certificate-lifetime needs --webtransport");
115
133
  return {
116
134
  kind: "serve",
117
135
  bindAddress: validatedBindAddress("bind", values.bind),
@@ -121,7 +139,8 @@ function parseCliArguments(argv) {
121
139
  },
122
140
  mailboxDir: values["mailbox-dir"],
123
141
  webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
124
- stateDir: values["state-dir"]
142
+ stateDir: values["state-dir"],
143
+ certificateLifetimeMs: validatedCertificateLifetimeMs(values["certificate-lifetime"])
125
144
  };
126
145
  }
127
146
  //#endregion
@@ -135,13 +154,8 @@ const nodeMailboxLimits = {
135
154
  };
136
155
  //#endregion
137
156
  //#region src/adapters/pinned-certificate.ts
138
- const MILLISECONDS_PER_DAY = 864e5;
139
- const MAX_PINNED_CERTIFICATE_LIFETIME_DAYS = 14;
140
- const CLOCK_SKEW_ALLOWANCE_DAYS = 1;
141
157
  const SERIAL_NUMBER_BYTES = 16;
142
158
  const PEM_LINE_LENGTH = 64;
143
- /** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
144
- const PINNED_CERTIFICATE_LIFETIME_MS = MAX_PINNED_CERTIFICATE_LIFETIME_DAYS * MILLISECONDS_PER_DAY - CLOCK_SKEW_ALLOWANCE_DAYS * MILLISECONDS_PER_DAY;
145
159
  const SIGNING_ALGORITHM = {
146
160
  name: "ECDSA",
147
161
  namedCurve: "P-256",
@@ -246,6 +260,11 @@ async function advanceCertificateSchedule(storage, now, options = {}) {
246
260
  /** The path a client opens its WebTransport session on. */
247
261
  const WEBTRANSPORT_PATH = "/wire-mesh";
248
262
  const SECRET_BYTES = 32;
263
+ /** How long to wait before trying a failed rotation again: the port may still be held by the server just stopped. */
264
+ const ROTATION_RETRY_MS = 1e3;
265
+ /** The application close code and reason sent when a certificate rotation ends a session; the code is arbitrary, and 0 is what a normal close carries. */
266
+ const SESSION_CLOSE_CODE = 0;
267
+ const ROTATION_CLOSE_REASON = "certificate rotated";
249
268
  const WILDCARD_HOSTS = ["0.0.0.0", "::"];
250
269
  /** The hosts a client can dial for a listener bound to `host`: the host itself, or for a wildcard every non-internal IPv4 address of this machine. */
251
270
  function reachableHosts(host) {
@@ -270,7 +289,7 @@ async function loadHttp3Server() {
270
289
  }
271
290
  }
272
291
  function isIncomingSession(value) {
273
- return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
292
+ return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "closed" in value && value.closed instanceof Promise && "close" in value && typeof value.close === "function" && "createUnidirectionalStream" in value && typeof value.createUnidirectionalStream === "function" && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
274
293
  }
275
294
  /**
276
295
  * A WebTransport Transport: each session carries one connection, on the first bidirectional stream the client opens, framed as length-prefixed CBOR (core's byte-stream-connection). A server binds a UDP port and serves a self-signed ECDSA certificate it renews on a schedule, which a browser accepts by pinning its hash (`serverCertificateHashes`) with no certificate authority involved.
@@ -308,37 +327,77 @@ function createWebTransportTransport(options = {}) {
308
327
  const bound = started.address();
309
328
  if (bound === null) throw new Error("the WebTransport server bound no address");
310
329
  port = bound.port;
330
+ const liveSessions = /* @__PURE__ */ new Set();
331
+ const drainState = { draining: false };
332
+ const endSession = (session) => {
333
+ session.close({
334
+ closeCode: SESSION_CLOSE_CODE,
335
+ reason: ROTATION_CLOSE_REASON
336
+ });
337
+ };
311
338
  (async () => {
312
339
  for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
313
340
  if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
314
341
  await session.ready;
342
+ if (drainState.draining) {
343
+ endSession(session);
344
+ return;
345
+ }
346
+ liveSessions.add(session);
347
+ session.closed.catch(() => void 0).finally(() => {
348
+ liveSessions.delete(session);
349
+ });
350
+ const announcer = (await session.createUnidirectionalStream()).getWriter();
351
+ await announcer.write(encodeCertificateHashes(schedule.advertised.map((certificate) => certificate.sha256Hex)));
352
+ await announcer.close();
315
353
  const reader = session.incomingBidirectionalStreams.getReader();
316
354
  const first = await reader.read();
317
355
  reader.releaseLock();
318
356
  if (!first.done) onConnection(connectionFromByteStream(first.value));
319
357
  })().catch(reportError);
320
358
  })().catch(reportError);
321
- return started;
359
+ return {
360
+ server: started,
361
+ drain: async () => {
362
+ drainState.draining = true;
363
+ const open = [...liveSessions];
364
+ for (const session of open) endSession(session);
365
+ await Promise.allSettled(open.map(async (session) => session.closed));
366
+ }
367
+ };
322
368
  };
323
- let server = await startServer();
369
+ let running = await startServer();
324
370
  const advertise = () => reachableHosts(host).map((reachable) => formatPinnedAddress(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
371
+ /** Drains and stops the running server, if there is one: cleared first, so a rotation that failed after this point is retried by starting a server rather than by retiring one that is already gone. */
372
+ const retire = async () => {
373
+ const current = running;
374
+ running = void 0;
375
+ if (current === void 0) return;
376
+ await current.drain();
377
+ current.server.stopServer();
378
+ await current.server.closed;
379
+ };
325
380
  let rotation;
326
381
  let closed = false;
327
- const armRotation = () => {
382
+ const untilRotation = () => Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0);
383
+ const armRotation = (delayMs) => {
328
384
  rotation = setTimeout(() => {
329
385
  (async () => {
330
386
  schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
331
- server.stopServer();
332
- await server.closed;
387
+ await retire();
333
388
  if (closed) return;
334
- server = await startServer();
389
+ running = await startServer();
335
390
  options.onCertificateRenewed?.(advertise());
336
- armRotation();
337
- })().catch(reportError);
338
- }, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
391
+ })().then(() => {
392
+ if (!closed) armRotation(untilRotation());
393
+ }, (error) => {
394
+ reportError(error);
395
+ if (!closed) armRotation(ROTATION_RETRY_MS);
396
+ });
397
+ }, delayMs);
339
398
  rotation.unref();
340
399
  };
341
- armRotation();
400
+ armRotation(untilRotation());
342
401
  return {
343
402
  get address() {
344
403
  return `${host}:${String(port)}`;
@@ -349,8 +408,7 @@ function createWebTransportTransport(options = {}) {
349
408
  close: async () => {
350
409
  closed = true;
351
410
  clearTimeout(rotation);
352
- server.stopServer();
353
- await server.closed;
411
+ await retire();
354
412
  },
355
413
  unref: () => {
356
414
  rotation?.unref();
@@ -494,6 +552,7 @@ async function main(argv) {
494
552
  mode: STATE_DIR_MODE
495
553
  });
496
554
  const webTransport = await createWebTransportTransport({
555
+ ...command.certificateLifetimeMs !== void 0 ? { certificateLifetimeMs: command.certificateLifetimeMs } : {},
497
556
  ...command.stateDir !== void 0 ? { storage: createNodeFsStorage({ dir: command.stateDir }) } : {},
498
557
  onError: (error) => {
499
558
  logError(`wire-mesh: WebTransport: ${String(error)}`);