clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

release/0.5.0
lofi-node / src / iroh / node.ts
node.ts 5.4K
1// Typed adapter over the vendored iroh-js addon, preserving the contract the2// tunnel and fixtures were built against: IrohNode open/ticket/connect/accept3// /close, IrohConn sendMsg/recvMsg/close (recvMsg → null at end of stream).4//5// One bi-stream per connection carries the framed messages (lofi_ext6// writeFrame/readFrame). The bi-stream is LAZY: QUIC acceptBi only resolves7// when the dialer's first bytes arrive, so accept() must return at the8// connection level or one silent dialer stalls the accept loop; first9// sendMsg/recvMsg resolves the stream instead. accept() resolves null when10// the endpoint closes — the acceptor loop's clean exit (proven at gate 0).1112import type { BiStream, Connection, Endpoint, IrohAddon } from "../native/addon.ts";13import type { RelayConfig } from "../config.ts";1415const ALPN = [...new TextEncoder().encode("lofi-node/0")];1617export class IrohConn {18 #addon: IrohAddon;19 #conn: Connection;20 #biFactory: () => Promise<BiStream>;21 #bi: Promise<BiStream> | null = null;22 #sendChain: Promise<void> = Promise.resolve();23 #sendFailed = false;24 #closed = false;2526 constructor(addon: IrohAddon, conn: Connection, biFactory: () => Promise<BiStream>) {27 this.#addon = addon;28 this.#conn = conn;29 this.#biFactory = biFactory;30 }3132 #stream(): Promise<BiStream> {33 return (this.#bi ??= this.#biFactory());34 }3536 /** Post one framed message. Returns void (contract): frames are chained in37 * call order; a write failure marks the conn dead and closes it, which the38 * peer and our recv loop observe as end-of-stream. */39 sendMsg(bytes: Uint8Array): void {40 if (this.#closed || this.#sendFailed) throw new Error("conn is closed");41 this.#sendChain = this.#sendChain42 .then(async () => {43 const bi = await this.#stream();44 await this.#addon.writeFrame(bi.send, bytes);45 })46 .catch(() => {47 this.#sendFailed = true;48 this.close().catch(() => {});49 });50 }5152 /** Await the next framed message; null once the stream ends. */53 async recvMsg(): Promise<Uint8Array | null> {54 if (this.#closed) return null;55 try {56 const bi = await this.#stream();57 const frame = await this.#addon.readFrame(bi.recv, null);58 return frame === null ? null : new Uint8Array(frame);59 } catch {60 // Mid-frame death or closed conn: end-of-stream for the caller.61 return null;62 }63 }6465 get closed(): boolean {66 return this.#closed;67 }6869 stats(): { rtt: number | null; paths: number } {70 try {71 return { rtt: this.#conn.rtt(), paths: this.#conn.paths().length };72 } catch {73 return { rtt: null, paths: 0 };74 }75 }7677 /** Idempotent. Flushes queued frames (bounded) before closing. */78 async close(): Promise<void> {79 if (this.#closed) return;80 this.#closed = true;81 await Promise.race([this.#sendChain, new Promise((r) => setTimeout(r, 3000))]);82 if (this.#bi !== null) {83 try {84 const bi = await this.#bi;85 await bi.send.finish();86 } catch {87 // stream never opened or already finished/reset88 }89 }90 try {91 this.#conn.close(0n, []);92 } catch {93 // already closed94 }95 }96}9798export class IrohNode {99 #addon: IrohAddon;100 #endpoint: Endpoint;101 #closed = false;102103 private constructor(addon: IrohAddon, endpoint: Endpoint) {104 this.#addon = addon;105 this.#endpoint = endpoint;106 }107108 static async open(109 addon: IrohAddon,110 secretKey: Uint8Array,111 relay: RelayConfig = "n0",112 ): Promise<IrohNode> {113 if (secretKey.length !== 32) throw new Error("iroh secret key must be 32 bytes");114 const builder = addon.Endpoint.builder();115 // Every mode starts from the n0 preset for its discovery + crypto116 // provider; the relay election then narrows the relay map.117 if (relay === "disabled") {118 builder.applyN0DisableRelay();119 } else {120 builder.applyN0();121 if (relay !== "n0") builder.relayMode(addon.RelayMode.customFromUrls(relay.urls));122 }123 builder.secretKey([...secretKey]);124 builder.alpns([ALPN]);125 return new IrohNode(addon, await builder.bind());126 }127128 idString(): string {129 return this.#endpoint.id().toString();130 }131132 /** Pairing ticket (upstream EndpointTicket string). Waits briefly for the133 * endpoint to learn dialable addresses (direct or relay). */134 async ticket(): Promise<string> {135 for (let i = 0; i < 100; i++) {136 const addr = this.#endpoint.addr();137 if (addr.directAddresses().length > 0 || addr.relayUrl() !== null) {138 return this.#addon.EndpointTicket.fromAddr(addr).toString();139 }140 await new Promise((r) => setTimeout(r, 100));141 }142 throw new Error("endpoint has no dialable addresses after 10s");143 }144145 /** Dial a peer by its ticket. */146 async connect(ticket: string): Promise<IrohConn> {147 const addr = this.#addon.EndpointTicket.fromString(ticket).endpointAddr();148 const conn = await this.#endpoint.connect(addr, ALPN);149 return new IrohConn(this.#addon, conn, () => conn.openBi());150 }151152 /** Await the next inbound connection; null when the endpoint closes (the153 * acceptor loop's clean exit). */154 async accept(): Promise<IrohConn | null> {155 const incoming = await this.#endpoint.acceptNext();156 if (incoming === null) return null;157 const conn = await (await incoming.accept()).connect();158 return new IrohConn(this.#addon, conn, () => conn.acceptBi());159 }160161 async close(): Promise<void> {162 if (this.#closed) return;163 this.#closed = true;164 await this.#endpoint.close();165 }166}