@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/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/auth.d.ts +22 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +4 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +50 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +105 -0
- package/dist/client.js.map +1 -0
- package/dist/conn.d.ts +60 -0
- package/dist/conn.d.ts.map +1 -0
- package/dist/conn.js +145 -0
- package/dist/conn.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +27 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +35 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +101 -0
- package/dist/server.js.map +1 -0
- package/dist/transport.d.ts +46 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +140 -0
- package/dist/transport.js.map +1 -0
- package/package.json +76 -0
- package/src/auth.ts +25 -0
- package/src/client.ts +165 -0
- package/src/conn.ts +172 -0
- package/src/errors.ts +30 -0
- package/src/index.ts +36 -0
- package/src/server.ts +146 -0
- package/src/transport.ts +173 -0
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
|
+
}
|