@zap-proto/web 0.1.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/client.ts ADDED
@@ -0,0 +1,165 @@
1
+ // Copyright (C) 2025, Lux Industries Inc. All rights reserved.
2
+ // See the file LICENSE for licensing terms.
3
+
4
+ /**
5
+ * client.ts — connect(url, opts): open a ZAP-over-WebSocket RPC connection from
6
+ * the browser (or Node, for tests) and return the bootstrap call surface.
7
+ *
8
+ * The transport is isomorphic: in the browser this uses the native WebSocket
9
+ * global; in Node you inject the `ws` package's WebSocket via
10
+ * {@link ConnectOptions.WebSocketImpl} (Node 22+ also has a built-in global
11
+ * WebSocket, but it cannot set request headers — see the bearer note below).
12
+ *
13
+ * Bearer note: the browser's native WebSocket constructor CANNOT set an
14
+ * Authorization header — the WS protocol gives JS no header control. So:
15
+ * - Node `ws` (tests, SSR): we pass `{ headers: { Authorization } }`, which
16
+ * `ws` forwards on the upgrade request, where serve()'s mintCap reads it.
17
+ * - browser: rely on a cookie sent automatically on the upgrade, or encode
18
+ * the bearer in a subprotocol; the Authorization-header path only applies
19
+ * to a header-capable WebSocketImpl. We feature-detect by arity and fall
20
+ * back to a query param (`?authorization=Bearer%20...`) the server can read.
21
+ */
22
+
23
+ import { Conn } from "./conn.js";
24
+ import { browserWsTransport } from "./transport.js";
25
+ import { WebRpcError } from "./errors.js";
26
+
27
+ /** Options for {@link connect}. */
28
+ export interface ConnectOptions {
29
+ /** Bearer token; forwarded as `Authorization: Bearer <bearer>` on upgrade. */
30
+ bearer?: string;
31
+ /**
32
+ * WebSocket constructor to use. Defaults to globalThis.WebSocket (browser /
33
+ * Node 22+). Inject the `ws` package's WebSocket in Node tests/SSR so the
34
+ * bearer can ride an Authorization header.
35
+ */
36
+ WebSocketImpl?: typeof WebSocket;
37
+ }
38
+
39
+ /**
40
+ * The bootstrap call surface returned by {@link connect}. RootCap is the
41
+ * generated typed wrapper a consumer layers over the raw {@link Conn} call
42
+ * surface — `connect<MyRootCap>(...)` then exposes typed methods on `bootstrap`.
43
+ *
44
+ * On the real @zap-proto/zap foundation the bootstrap is the connection's call
45
+ * surface (method ordinal + ZAP payload), not a Cap'n Proto capability object.
46
+ * The default `RootCap = Conn` gives you raw `bootstrap.call(method, { payload })`;
47
+ * a zapgen-generated client wraps that into named, typed methods.
48
+ */
49
+ export interface Connection<RootCap = Conn> {
50
+ bootstrap: RootCap;
51
+ close(): void;
52
+ }
53
+
54
+ /**
55
+ * Open a ZAP RPC WebSocket to `url`, wait for the connection to open, and
56
+ * return the bootstrap call surface. The default RootCap is the raw {@link Conn}.
57
+ */
58
+ export async function connect<RootCap = Conn>(
59
+ url: string,
60
+ opts: ConnectOptions = {},
61
+ ): Promise<Connection<RootCap>> {
62
+ const Impl = opts.WebSocketImpl ?? (globalThis.WebSocket as typeof WebSocket);
63
+ if (!Impl) {
64
+ throw new WebRpcError(
65
+ "zap-web: no WebSocket implementation (pass opts.WebSocketImpl)",
66
+ );
67
+ }
68
+
69
+ const ws = openSocket(Impl, url, opts.bearer);
70
+
71
+ await new Promise<void>((resolve, reject) => {
72
+ const onOpen = (): void => {
73
+ cleanup();
74
+ resolve();
75
+ };
76
+ const onErr = (): void => {
77
+ cleanup();
78
+ reject(new WebRpcError(`zap-web: failed to connect to ${url}`));
79
+ };
80
+ const onClose = (ev: unknown): void => {
81
+ cleanup();
82
+ const code = (ev as CloseEvent | undefined)?.code;
83
+ reject(
84
+ new WebRpcError(
85
+ `zap-web: upgrade rejected by ${url}`,
86
+ code === 1006 ? 401 : code,
87
+ ),
88
+ );
89
+ };
90
+ const cleanup = (): void => {
91
+ remove(ws, "open", onOpen);
92
+ remove(ws, "error", onErr);
93
+ remove(ws, "close", onClose);
94
+ };
95
+ add(ws, "open", onOpen);
96
+ add(ws, "error", onErr);
97
+ add(ws, "close", onClose);
98
+ });
99
+
100
+ const transport = browserWsTransport(ws);
101
+ const conn = new Conn(transport);
102
+
103
+ return {
104
+ bootstrap: conn as unknown as RootCap,
105
+ close(): void {
106
+ conn.close(1000, "client closed");
107
+ },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Construct a WebSocket, attaching the bearer the best way the impl allows.
113
+ * Node `ws` accepts a 3rd `options.headers` arg; the browser native WebSocket
114
+ * does not, so we fall back to a query param the server can read.
115
+ */
116
+ function openSocket(
117
+ Impl: typeof WebSocket,
118
+ url: string,
119
+ bearer?: string,
120
+ ): WebSocket {
121
+ if (!bearer) return new Impl(url);
122
+
123
+ // Node `ws` WebSocket: third constructor arg is options { headers }.
124
+ // We detect header support by the constructor's declared arity (>= 3).
125
+ if ((Impl as unknown as { length: number }).length >= 3) {
126
+ return new (Impl as unknown as new (
127
+ u: string,
128
+ protocols: undefined,
129
+ options: { headers: Record<string, string> },
130
+ ) => WebSocket)(url, undefined, {
131
+ headers: { Authorization: `Bearer ${bearer}` },
132
+ });
133
+ }
134
+
135
+ // Browser native WebSocket: no header control — encode on the URL instead.
136
+ const u = new URL(url);
137
+ u.searchParams.set("authorization", `Bearer ${bearer}`);
138
+ return new Impl(u.toString());
139
+ }
140
+
141
+ // --- isomorphic add/remove listener (browser EventTarget vs `ws` emitter) ---
142
+
143
+ function add(ws: WebSocket, type: string, fn: (ev?: unknown) => void): void {
144
+ const anyWs = ws as unknown as {
145
+ addEventListener?: (t: string, f: (ev: unknown) => void) => void;
146
+ on?: (t: string, f: (...a: unknown[]) => void) => void;
147
+ };
148
+ if (typeof anyWs.addEventListener === "function") {
149
+ anyWs.addEventListener(type, fn);
150
+ } else if (typeof anyWs.on === "function") {
151
+ anyWs.on(type, fn);
152
+ }
153
+ }
154
+
155
+ function remove(ws: WebSocket, type: string, fn: (ev?: unknown) => void): void {
156
+ const anyWs = ws as unknown as {
157
+ removeEventListener?: (t: string, f: (ev: unknown) => void) => void;
158
+ off?: (t: string, f: (...a: unknown[]) => void) => void;
159
+ };
160
+ if (typeof anyWs.removeEventListener === "function") {
161
+ anyWs.removeEventListener(type, fn);
162
+ } else if (typeof anyWs.off === "function") {
163
+ anyWs.off(type, fn);
164
+ }
165
+ }
package/src/conn.ts ADDED
@@ -0,0 +1,172 @@
1
+ // Copyright (C) 2025, Lux Industries Inc. All rights reserved.
2
+ // See the file LICENSE for licensing terms.
3
+
4
+ /**
5
+ * conn.ts — a transport-agnostic ZAP RPC connection over a {@link WsTransport}.
6
+ *
7
+ * This is the shared core of both serve() and connect(). It mirrors the
8
+ * correlation logic of @zap-proto/zap's ZapClient (Node TCP), but rides a duplex
9
+ * WebSocket transport instead of a TCP socket and works in both directions:
10
+ *
11
+ * - client side: call() ships a request envelope and resolves its Response
12
+ * when the matching promiseID comes back.
13
+ * - server side: an inbound request envelope is decoded into a Call and
14
+ * handed to the registered handler, whose Response is shipped back.
15
+ *
16
+ * Each WS binary message is exactly one ZAP envelope (see transport.ts), so the
17
+ * envelope's own header tag (MSG_TYPE_ROUTER_BASE) distinguishes a request from
18
+ * a response and there is no extra framing. We disambiguate direction by trying
19
+ * the request decode first; a frame that parses as a request with a known
20
+ * method is dispatched as a server call, otherwise it is treated as a response
21
+ * to one of our outstanding promises.
22
+ */
23
+
24
+ import {
25
+ buildRequest,
26
+ buildResponse,
27
+ parseRequest,
28
+ parseResponse,
29
+ NO_TARGET,
30
+ Status,
31
+ type Call,
32
+ type Response,
33
+ } from "@zap-proto/zap";
34
+ import type { WsTransport } from "./transport.js";
35
+ import { WebRpcError } from "./errors.js";
36
+
37
+ /** A server-side request handler: a decoded Call in, a Response out. */
38
+ export type CallHandler = (call: Call) => Promise<Response> | Response;
39
+
40
+ /** Options for an outbound {@link Conn.call}. */
41
+ export interface CallOptions {
42
+ /** Pipeline this call off an earlier call's promiseID (0 = root). */
43
+ target?: number;
44
+ /** Capability buffer to carry on the call (defaults to the conn's cap). */
45
+ cap?: Uint8Array;
46
+ /** Method params, ZAP-encoded. */
47
+ payload?: Uint8Array;
48
+ }
49
+
50
+ const EMPTY = new Uint8Array(0);
51
+
52
+ /**
53
+ * Conn correlates ZAP request/response envelopes over a single duplex
54
+ * WebSocket transport. One Conn is FIFO per direction on the wire, matching the
55
+ * Go node's per-connection dispatch.
56
+ */
57
+ export class Conn {
58
+ private readonly pending = new Map<number, (r: Response) => void>();
59
+ private promiseSeq = 0;
60
+ private closed = false;
61
+ private handler: CallHandler | null = null;
62
+ private readonly cap: Uint8Array;
63
+
64
+ constructor(
65
+ private readonly transport: WsTransport,
66
+ opts: { handler?: CallHandler; cap?: Uint8Array } = {},
67
+ ) {
68
+ this.handler = opts.handler ?? null;
69
+ this.cap = opts.cap ?? EMPTY;
70
+ transport.onMessage((bytes) => this.onFrame(bytes));
71
+ transport.onClose(() => this.fail());
72
+ }
73
+
74
+ /**
75
+ * Ship one Call and await its correlated Response. The promiseID is assigned
76
+ * here unless the caller pipelines via {@link CallOptions.target}.
77
+ */
78
+ call(method: number, opts: CallOptions = {}): Promise<Response> {
79
+ if (this.closed) {
80
+ return Promise.reject(new WebRpcError("zap-web: connection closed"));
81
+ }
82
+ this.promiseSeq = (this.promiseSeq + 1) >>> 0 || 1;
83
+ const promiseID = this.promiseSeq;
84
+ const c: Call = {
85
+ method,
86
+ promiseID,
87
+ target: opts.target ?? NO_TARGET,
88
+ cap: opts.cap ?? this.cap,
89
+ payload: opts.payload ?? EMPTY,
90
+ };
91
+ return new Promise<Response>((resolve, reject) => {
92
+ this.pending.set(promiseID, resolve);
93
+ try {
94
+ this.transport.send(buildRequest(c));
95
+ } catch (err) {
96
+ this.pending.delete(promiseID);
97
+ reject(err instanceof Error ? err : new WebRpcError(String(err)));
98
+ }
99
+ });
100
+ }
101
+
102
+ /** Close the connection and fail all in-flight calls. */
103
+ close(code?: number, reason?: string): void {
104
+ if (this.closed) return;
105
+ this.transport.close(code, reason);
106
+ this.fail();
107
+ }
108
+
109
+ private onFrame(bytes: Uint8Array): void {
110
+ if (this.handler) {
111
+ // Server side: every inbound frame is a request to dispatch.
112
+ this.dispatch(bytes);
113
+ return;
114
+ }
115
+ // Client side: every inbound frame is a response to one of our calls.
116
+ let resp: Response;
117
+ try {
118
+ resp = parseResponse(bytes);
119
+ } catch {
120
+ return; // malformed frame from peer — drop it.
121
+ }
122
+ const resolve = this.pending.get(resp.promiseID);
123
+ if (!resolve) return;
124
+ this.pending.delete(resp.promiseID);
125
+ resolve(resp);
126
+ }
127
+
128
+ private async dispatch(bytes: Uint8Array): Promise<void> {
129
+ let call: Call;
130
+ try {
131
+ call = parseRequest(bytes);
132
+ } catch {
133
+ return; // unparseable request — drop it.
134
+ }
135
+ const handler = this.handler;
136
+ if (!handler) return;
137
+ let resp: Response;
138
+ try {
139
+ resp = await handler(call);
140
+ } catch (err) {
141
+ const msg = err instanceof Error ? err.message : "internal handler error";
142
+ resp = {
143
+ status: Status.Internal,
144
+ promiseID: call.promiseID,
145
+ body: new TextEncoder().encode(JSON.stringify({ error: msg })),
146
+ };
147
+ }
148
+ if (this.closed) return;
149
+ try {
150
+ this.transport.send(
151
+ buildResponse(resp.status, call.promiseID, resp.body),
152
+ );
153
+ } catch {
154
+ // peer went away mid-response — nothing to do.
155
+ }
156
+ }
157
+
158
+ private fail(): void {
159
+ if (this.closed) return;
160
+ this.closed = true;
161
+ for (const [, resolve] of this.pending) {
162
+ resolve({
163
+ status: 0,
164
+ promiseID: 0,
165
+ body: new TextEncoder().encode(
166
+ JSON.stringify({ error: "connection closed" }),
167
+ ),
168
+ });
169
+ }
170
+ this.pending.clear();
171
+ }
172
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Copyright (C) 2025, Lux Industries Inc. All rights reserved.
2
+ // See the file LICENSE for licensing terms.
3
+
4
+ /**
5
+ * errors.ts — error surface for the web RPC layer.
6
+ *
7
+ * Method/wire-level decode failures keep using @zap-proto/zap's ZapParseError (the
8
+ * runtime throws it on a bad magic/version/size); we re-export it so consumers
9
+ * have one import site. WebRpcError is new: it covers transport-level failures
10
+ * that only exist at the WebSocket boundary — a rejected upgrade, a socket that
11
+ * closes mid-call, or a non-OK RPC status surfaced to the caller.
12
+ */
13
+
14
+ export { ZapParseError } from "@zap-proto/zap";
15
+
16
+ /**
17
+ * WebRpcError is raised for WebSocket-transport-level failures: the upgrade was
18
+ * rejected (401/other), the socket closed while a call was in flight, or a
19
+ * call returned a non-OK ZAP status. `status` carries the ZAP/HTTP status when
20
+ * one is known (e.g. 401 for a rejected mint, or the response envelope status).
21
+ */
22
+ export class WebRpcError extends Error {
23
+ readonly status?: number;
24
+
25
+ constructor(message: string, status?: number) {
26
+ super(message);
27
+ this.name = "WebRpcError";
28
+ this.status = status;
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Copyright (C) 2025, Lux Industries Inc. All rights reserved.
2
+ // See the file LICENSE for licensing terms.
3
+
4
+ /**
5
+ * @zap-proto/web — browser-frontend RPC over ZAP.
6
+ *
7
+ * A drop-in tRPC replacement that speaks native ZAP envelopes over WebSocket
8
+ * binary frames instead of JSON-over-HTTP. Layered on @zap-proto/zap (the wire
9
+ * runtime). Service shape comes from .zap schemas via `zapgen --target=ts`, not
10
+ * a Zod/procedure DSL. Binary only — no JSON fallback, no Cap'n Proto.
11
+ *
12
+ * - serve(httpServer, opts) — Node: attach a ZAP RPC endpoint to http.Server.
13
+ * - connect(url, opts) — browser/Node: open a typed RPC connection.
14
+ * - {node,browser}WsTransport — isomorphic WS transport factories.
15
+ * - MintCap — the bearer→ctx auth slot at the upgrade.
16
+ */
17
+
18
+ export { serve } from "./server.js";
19
+ export type { ServeOptions, ServeHandle, RootCap } from "./server.js";
20
+
21
+ export { connect } from "./client.js";
22
+ export type { ConnectOptions, Connection } from "./client.js";
23
+
24
+ export {
25
+ nodeWsTransport,
26
+ browserWsTransport,
27
+ WS_CLOSE_UNSUPPORTED,
28
+ } from "./transport.js";
29
+ export type { WsTransport } from "./transport.js";
30
+
31
+ export { Conn } from "./conn.js";
32
+ export type { CallHandler, CallOptions } from "./conn.js";
33
+
34
+ export type { MintCap } from "./auth.js";
35
+
36
+ export { WebRpcError, ZapParseError } from "./errors.js";
package/src/server.ts ADDED
@@ -0,0 +1,146 @@
1
+ // Copyright (C) 2025, Lux Industries Inc. All rights reserved.
2
+ // See the file LICENSE for licensing terms.
3
+
4
+ /**
5
+ * server.ts — serve(httpServer, opts): attach a ZAP-over-WebSocket RPC endpoint
6
+ * to an existing Node http.Server.
7
+ *
8
+ * Sharing one http.Server is the integration seam: Next.js's
9
+ * `app.getRequestHandler()` and a Remix Node server both run on a plain
10
+ * http.Server, so a single `serve(server, ...)` line adds ZAP RPC alongside the
11
+ * app without a second port. (Same shape as the WebSocket attachments in
12
+ * hanzo/platform's app/platform/server/server.ts.)
13
+ *
14
+ * Auth runs at the upgrade boundary: mintCap(req) is awaited BEFORE the
15
+ * handshake completes. A null mint writes HTTP 401 and destroys the socket — no
16
+ * WebSocket is ever opened for an unauthorized request. A non-null mint becomes
17
+ * the per-connection `ctx`, and rootCap(ctx) yields the CallHandler that
18
+ * dispatches every decoded ZAP Call on that connection.
19
+ *
20
+ * `ws` is a peer dependency (server side only); we import it lazily so the
21
+ * browser bundle never pulls it in.
22
+ */
23
+
24
+ import { createRequire } from "node:module";
25
+ import type { IncomingMessage, Server as HttpServer } from "node:http";
26
+ import type { Socket } from "node:net";
27
+ import type { CallHandler } from "./conn.js";
28
+ import { Conn } from "./conn.js";
29
+ import { nodeWsTransport } from "./transport.js";
30
+ import type { MintCap } from "./auth.js";
31
+
32
+ /**
33
+ * rootCap produces the per-connection dispatch root for a minted ctx.
34
+ *
35
+ * On the real @zap-proto/zap foundation a "service" is a dispatcher over decoded
36
+ * Calls (method ordinal + payload), not a Cap'n Proto Server object — so the
37
+ * bootstrap capability is modelled here as a {@link CallHandler}. Generated
38
+ * zapgen bindings give you the typed param/result views to decode the payload
39
+ * and encode the response body inside that handler.
40
+ */
41
+ export type RootCap<Ctx> = (ctx: Ctx) => CallHandler;
42
+
43
+ /** Options for {@link serve}. */
44
+ export interface ServeOptions<Ctx> {
45
+ /** Upgrade path this endpoint binds to (default "/zap"). */
46
+ path?: string;
47
+ /** Bearer→ctx boundary; return null to reject the upgrade with HTTP 401. */
48
+ mintCap: MintCap<Ctx>;
49
+ /** Produce the bootstrap dispatch handler for the minted ctx. */
50
+ rootCap: RootCap<Ctx>;
51
+ /** Optional sink for connection/dispatch errors. */
52
+ onError?: (err: unknown) => void;
53
+ }
54
+
55
+ /** A live ZAP-over-WebSocket endpoint attached to an http.Server. */
56
+ export interface ServeHandle {
57
+ /** Close the endpoint and every live connection. */
58
+ close(): Promise<void>;
59
+ }
60
+
61
+ /**
62
+ * Attach a ZAP RPC endpoint to `httpServer`. Returns a handle whose close()
63
+ * tears down the WebSocket server and all open connections.
64
+ */
65
+ export function serve<Ctx>(
66
+ httpServer: HttpServer,
67
+ opts: ServeOptions<Ctx>,
68
+ ): ServeHandle {
69
+ const path = opts.path ?? "/zap";
70
+ const onError = opts.onError ?? (() => {});
71
+
72
+ // Lazy require so browser bundles never resolve `ws`. createRequire keeps
73
+ // this ESM-safe (no top-level `require`) and server-only (node:module never
74
+ // ends up in a browser bundle, since serve() is the Node entry).
75
+ const req = createRequire(import.meta.url);
76
+ const { WebSocketServer } = req("ws") as typeof import("ws");
77
+ const wss = new WebSocketServer({ noServer: true });
78
+ const conns = new Set<Conn>();
79
+
80
+ const onUpgrade = (
81
+ req: IncomingMessage,
82
+ socket: Socket,
83
+ head: Buffer,
84
+ ): void => {
85
+ let url: URL;
86
+ try {
87
+ url = new URL(req.url ?? "/", "http://localhost");
88
+ } catch {
89
+ return; // not ours; let other upgrade listeners handle it.
90
+ }
91
+ if (url.pathname !== path) return; // path mismatch — not our endpoint.
92
+
93
+ void (async () => {
94
+ let ctx: Ctx | null;
95
+ try {
96
+ ctx = await opts.mintCap(req);
97
+ } catch (err) {
98
+ onError(err);
99
+ reject(socket, 500, "Internal Server Error");
100
+ return;
101
+ }
102
+ if (ctx === null) {
103
+ reject(socket, 401, "Unauthorized");
104
+ return;
105
+ }
106
+ const minted = ctx;
107
+ wss.handleUpgrade(req, socket, head, (ws) => {
108
+ const transport = nodeWsTransport(ws);
109
+ let handler: CallHandler;
110
+ try {
111
+ handler = opts.rootCap(minted);
112
+ } catch (err) {
113
+ onError(err);
114
+ transport.close(1011, "rootCap failed");
115
+ return;
116
+ }
117
+ const conn = new Conn(transport, { handler });
118
+ conns.add(conn);
119
+ transport.onClose(() => conns.delete(conn));
120
+ ws.on("error", onError);
121
+ });
122
+ })();
123
+ };
124
+
125
+ httpServer.on("upgrade", onUpgrade);
126
+
127
+ return {
128
+ async close(): Promise<void> {
129
+ httpServer.removeListener("upgrade", onUpgrade);
130
+ for (const conn of conns) conn.close(1001, "server shutting down");
131
+ conns.clear();
132
+ await new Promise<void>((resolve) => wss.close(() => resolve()));
133
+ },
134
+ };
135
+ }
136
+
137
+ /** Write a bare HTTP error response on the raw upgrade socket and destroy it. */
138
+ function reject(socket: Socket, code: number, text: string): void {
139
+ socket.write(
140
+ `HTTP/1.1 ${code} ${text}\r\n` +
141
+ "Connection: close\r\n" +
142
+ "Content-Length: 0\r\n" +
143
+ "\r\n",
144
+ );
145
+ socket.destroy();
146
+ }