@irisrun/channel-rest 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.
@@ -0,0 +1,20 @@
1
+ import type { Json, TurnOutcome } from "@irisrun/core";
2
+ export type StreamEvent = {
3
+ type: "record";
4
+ record: Json;
5
+ } | {
6
+ type: "delta";
7
+ text: string;
8
+ } | {
9
+ type: "outcome";
10
+ sessionId: string;
11
+ status: TurnOutcome<Json>["status"];
12
+ output?: Json;
13
+ wait?: Json;
14
+ current?: number;
15
+ continuationToken?: string;
16
+ } | {
17
+ type: "error";
18
+ message: string;
19
+ };
20
+ export declare function toOutcomeEvent<S extends Json>(sessionId: string, outcome: TurnOutcome<S>, token?: string): StreamEvent;
package/dist/events.js ADDED
@@ -0,0 +1,18 @@
1
+ // Mirrors the buffered `turnResponse()` mapping, plus the rotated token. `token`
2
+ // is omitted only when the channel did not (re)issue one.
3
+ export function toOutcomeEvent(sessionId, outcome, token) {
4
+ const ev = {
5
+ type: "outcome",
6
+ sessionId,
7
+ status: outcome.status,
8
+ };
9
+ if (outcome.status === "finished" && outcome.output !== undefined)
10
+ ev.output = outcome.output;
11
+ if (outcome.status === "parked")
12
+ ev.wait = outcome.wait;
13
+ if (outcome.status === "contended")
14
+ ev.current = outcome.current;
15
+ if (token !== undefined)
16
+ ev.continuationToken = token;
17
+ return ev;
18
+ }
@@ -0,0 +1,7 @@
1
+ export declare const PACKAGE = "@irisrun/channel-rest";
2
+ export { makeRestChannel } from "./server.js";
3
+ export type { RestChannel, RestChannelOptions, TurnInputs } from "./server.js";
4
+ export type { StreamEvent } from "./events.js";
5
+ export { toOutcomeEvent } from "./events.js";
6
+ export { acceptKey, decodeFrames, encodeTextFrame, encodeCloseFrame, encodePongFrame, makeWsFramer, } from "./ws.js";
7
+ export type { WsFrame, WsFramerCallbacks } from "./ws.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // @irisrun/channel-rest — public surface (host; in-process node:http REST channel).
2
+ export const PACKAGE = "@irisrun/channel-rest";
3
+ export { makeRestChannel } from "./server.js";
4
+ export { toOutcomeEvent } from "./events.js";
5
+ export { acceptKey, decodeFrames, encodeTextFrame, encodeCloseFrame, encodePongFrame, makeWsFramer, } from "./ws.js";
@@ -0,0 +1,25 @@
1
+ import { type Server, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { type HostAdapter } from "@irisrun/host";
3
+ import type { Program, PerformerRegistry, LogicalClock, Json } from "@irisrun/core";
4
+ import { type StreamEvent } from "./events.js";
5
+ export interface TurnInputs<S extends Json> {
6
+ program: Program<S>;
7
+ performers: PerformerRegistry;
8
+ clock: LogicalClock;
9
+ defDigest: string;
10
+ snapshotThreshold?: number;
11
+ holderId?: string;
12
+ }
13
+ export interface RestChannelOptions<S extends Json> {
14
+ adapter: HostAdapter;
15
+ makeTurnInputs: (sessionId: string, body: Json, emit?: (ev: StreamEvent) => void) => TurnInputs<S> | Promise<TurnInputs<S>>;
16
+ mintSessionId?: () => string;
17
+ mintToken?: () => string;
18
+ webHandler?: (req: IncomingMessage, res: ServerResponse) => boolean;
19
+ }
20
+ export interface RestChannel {
21
+ server: Server;
22
+ listen(port?: number, host?: string): Promise<string>;
23
+ close(): Promise<void>;
24
+ }
25
+ export declare function makeRestChannel<S extends Json>(opts: RestChannelOptions<S>): RestChannel;
package/dist/server.js ADDED
@@ -0,0 +1,316 @@
1
+ // makeRestChannel (ADR-0009, Spec 05 B3): an in-process node:http server speaking
2
+ // the TWO-IDENTIFIER protocol. The channel MINTS the sessionId and OWNS/ISSUES the
3
+ // continuationToken — the client presents it on the next call. Every turn ROTATES
4
+ // the token; a missing/stale/malformed token is refused with a LOUD 4xx, never a
5
+ // silent 200 (no-silent-failures). In-process for the suite; a real external HTTP
6
+ // deploy is a manual smoke. Host-side (node:http + node:crypto); core stays pure.
7
+ import { createServer } from "node:http";
8
+ import { randomUUID } from "node:crypto";
9
+ import { runTurnOn } from "@irisrun/host";
10
+ import { toOutcomeEvent } from "./events.js";
11
+ import { wantsStream, openSse } from "./sse.js";
12
+ import { writeHandshake, refuseUpgrade, makeWsFramer, encodeTextFrame, encodeCloseFrame, encodePongFrame, } from "./ws.js";
13
+ const SESSION_MESSAGE = /^\/v1\/session\/([^/]+)\/message$/;
14
+ function send(res, status, body) {
15
+ const text = JSON.stringify(body);
16
+ res.writeHead(status, { "content-type": "application/json" });
17
+ res.end(text);
18
+ }
19
+ async function readBody(req) {
20
+ const chunks = [];
21
+ for await (const c of req)
22
+ chunks.push(c);
23
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
24
+ if (raw === "")
25
+ return { ok: true, body: {} }; // an empty body is the empty object
26
+ try {
27
+ return { ok: true, body: JSON.parse(raw) };
28
+ }
29
+ catch {
30
+ return { ok: false }; // malformed JSON → loud 4xx upstream
31
+ }
32
+ }
33
+ function turnResponse(sessionId, token, outcome) {
34
+ const base = { sessionId, continuationToken: token, status: outcome.status };
35
+ if (outcome.status === "finished" && outcome.output !== undefined)
36
+ base.output = outcome.output;
37
+ if (outcome.status === "parked")
38
+ base.wait = outcome.wait;
39
+ if (outcome.status === "contended")
40
+ base.current = outcome.current;
41
+ return base;
42
+ }
43
+ export function makeRestChannel(opts) {
44
+ const mintSessionId = opts.mintSessionId ?? (() => randomUUID());
45
+ const mintToken = opts.mintToken ?? (() => randomUUID());
46
+ // The channel OWNS the current continuationToken per session (in-process here;
47
+ // a real deploy would persist it). It rotates on every committed turn.
48
+ const tokens = new Map();
49
+ // Sessions with a turn in flight — enforces the token's SINGLE-USE invariant
50
+ // under concurrency (the token rotates only after the turn commits, so without
51
+ // this a second concurrent request presenting the same valid token would slip
52
+ // past the check before rotation; ADR-0009 advertises single-use, so we honor it).
53
+ const inFlight = new Set();
54
+ // Rotate the continuationToken ONLY on a COMMITTED turn (single-use). A
55
+ // `contended` turn journaled nothing — the lease was held elsewhere — so the
56
+ // prior token stays valid and the client retries it (no rotation). A START turn
57
+ // (priorToken === null) always issues a fresh token. Used by all three paths
58
+ // (buffered/SSE/WS) so token discipline is identical across them.
59
+ const issueToken = (sessionId, outcome, priorToken) => {
60
+ if (priorToken !== null && outcome.status === "contended")
61
+ return priorToken;
62
+ const token = mintToken();
63
+ tokens.set(sessionId, token);
64
+ return token;
65
+ };
66
+ const runTurn = async (sessionId, body, emit) => {
67
+ const inputs = await opts.makeTurnInputs(sessionId, body, emit);
68
+ // onRecord is the per-REQUEST journal-timeline tap (only on a streaming
69
+ // request). It rides RunTurnOnOptions, NOT TurnInputs (per-session-static).
70
+ const onRecord = emit
71
+ ? (r) => emit({ type: "record", record: r })
72
+ : undefined;
73
+ return runTurnOn(opts.adapter, {
74
+ sessionId,
75
+ ...inputs,
76
+ ...(onRecord ? { onRecord } : {}),
77
+ });
78
+ };
79
+ // Drive one turn into an SSE stream: records + deltas, then a terminal outcome
80
+ // carrying the rotated token. Validation (loud 4xx) already ran in the handler
81
+ // BEFORE this opens the stream, so a refusal is never a half-open SSE.
82
+ const streamTurn = async (res, sessionId, body, priorToken) => {
83
+ const sse = openSse(res);
84
+ const emit = (ev) => sse.emit(ev);
85
+ let outcome;
86
+ try {
87
+ outcome = await runTurn(sessionId, body, emit);
88
+ }
89
+ catch (err) {
90
+ // The turn threw AFTER the stream opened — surface it in-band, loudly.
91
+ emit({ type: "error", message: err instanceof Error ? err.message : String(err) });
92
+ sse.end();
93
+ return;
94
+ }
95
+ emit(toOutcomeEvent(sessionId, outcome, issueToken(sessionId, outcome, priorToken)));
96
+ sse.end();
97
+ };
98
+ const handler = async (req, res) => {
99
+ const rawUrl = req.url ?? "";
100
+ const url = rawUrl.split("?")[0];
101
+ const stream = wantsStream(req, rawUrl);
102
+ // The optional web channel claims its GET routes BEFORE the POST-only guard.
103
+ // It returns false for non-asset paths, so `/v1/*` POST falls through unchanged.
104
+ if (opts.webHandler && opts.webHandler(req, res))
105
+ return;
106
+ if (req.method !== "POST") {
107
+ send(res, 405, { error: `method ${req.method} not allowed` });
108
+ return;
109
+ }
110
+ const parsed = await readBody(req);
111
+ if (!parsed.ok) {
112
+ send(res, 400, { error: "malformed JSON body" });
113
+ return;
114
+ }
115
+ const body = parsed.body;
116
+ // --- start: MINT a session + the first continuationToken --------------
117
+ if (url === "/v1/session") {
118
+ const sessionId = mintSessionId();
119
+ if (stream) {
120
+ await streamTurn(res, sessionId, body, null); // START → always issues a fresh token
121
+ return;
122
+ }
123
+ const outcome = await runTurn(sessionId, body);
124
+ const token = issueToken(sessionId, outcome, null);
125
+ send(res, 200, turnResponse(sessionId, token, outcome));
126
+ return;
127
+ }
128
+ // --- continue: REQUIRE the matching token, then ROTATE it -------------
129
+ const m = SESSION_MESSAGE.exec(url);
130
+ if (m) {
131
+ const sessionId = decodeURIComponent(m[1]);
132
+ if (!tokens.has(sessionId)) {
133
+ send(res, 404, { error: `unknown session '${sessionId}'` });
134
+ return;
135
+ }
136
+ const headerToken = req.headers["x-continuation-token"];
137
+ const presented = typeof body.continuationToken === "string"
138
+ ? body.continuationToken
139
+ : typeof headerToken === "string"
140
+ ? headerToken
141
+ : null;
142
+ if (presented === null || presented === "") {
143
+ send(res, 400, { error: "missing continuationToken" });
144
+ return;
145
+ }
146
+ if (presented !== tokens.get(sessionId)) {
147
+ send(res, 409, { error: "stale or invalid continuationToken" });
148
+ return;
149
+ }
150
+ // The token check above and this in-flight claim run in ONE event-loop
151
+ // callback with no interleaving, so a SECOND concurrent request presenting
152
+ // the same valid token is refused HERE — before the token rotates — instead
153
+ // of slipping past the check. The flag also keeps the token usable across a
154
+ // failed turn: we rotate ONLY on success, in the finally we just release.
155
+ if (inFlight.has(sessionId)) {
156
+ send(res, 409, { error: "a turn is already in flight for this session" });
157
+ return;
158
+ }
159
+ inFlight.add(sessionId);
160
+ try {
161
+ if (stream) {
162
+ await streamTurn(res, sessionId, body, presented); // CONTINUE → rotate only if committed
163
+ }
164
+ else {
165
+ const outcome = await runTurn(sessionId, body);
166
+ const next = issueToken(sessionId, outcome, presented);
167
+ send(res, 200, turnResponse(sessionId, next, outcome));
168
+ }
169
+ }
170
+ finally {
171
+ inFlight.delete(sessionId);
172
+ }
173
+ return;
174
+ }
175
+ send(res, 404, { error: `no route for ${url}` });
176
+ };
177
+ // A held WS connection drives the SAME two-identifier protocol over frames: the
178
+ // first frame with no token STARTS (mints a session); a frame with a token
179
+ // CONTINUES the connection's bound session. The token is read FRESH from the
180
+ // shared Map each turn (never cached), so it stays coherent with the REST/SSE
181
+ // paths' single-use rotation. (Cross-protocol concurrent driving of one session
182
+ // is unsupported but still SAFE — the shared inFlight Set prevents double commit.)
183
+ const runWsConnection = (socket, head) => {
184
+ let sessionId = null;
185
+ const sendEvent = (ev) => {
186
+ try {
187
+ socket.write(encodeTextFrame(JSON.stringify(ev)));
188
+ }
189
+ catch {
190
+ /* socket gone */
191
+ }
192
+ };
193
+ const handleMessage = async (text) => {
194
+ let body;
195
+ try {
196
+ body = JSON.parse(text);
197
+ }
198
+ catch {
199
+ sendEvent({ type: "error", message: "malformed JSON frame" });
200
+ return;
201
+ }
202
+ const presented = typeof body.continuationToken === "string"
203
+ ? body.continuationToken
204
+ : null;
205
+ if (sessionId === null) {
206
+ if (presented !== null) {
207
+ // a fresh connection can only START (no token); continuation needs a
208
+ // session this connection already minted.
209
+ sendEvent({ type: "error", message: "present no continuationToken to start a session" });
210
+ return;
211
+ }
212
+ sessionId = mintSessionId();
213
+ }
214
+ else if (presented !== null) {
215
+ if (presented !== tokens.get(sessionId)) {
216
+ sendEvent({ type: "error", message: "stale or invalid continuationToken" });
217
+ return;
218
+ }
219
+ }
220
+ if (inFlight.has(sessionId)) {
221
+ sendEvent({ type: "error", message: "a turn is already in flight for this session" });
222
+ return;
223
+ }
224
+ const priorToken = tokens.get(sessionId) ?? null; // null on this connection's START turn
225
+ inFlight.add(sessionId);
226
+ try {
227
+ const outcome = await runTurn(sessionId, body, sendEvent);
228
+ sendEvent(toOutcomeEvent(sessionId, outcome, issueToken(sessionId, outcome, priorToken)));
229
+ }
230
+ catch (err) {
231
+ sendEvent({ type: "error", message: err instanceof Error ? err.message : String(err) });
232
+ }
233
+ finally {
234
+ inFlight.delete(sessionId);
235
+ }
236
+ };
237
+ const feed = makeWsFramer({
238
+ onText: (t) => {
239
+ void handleMessage(t);
240
+ },
241
+ onPing: (p) => {
242
+ try {
243
+ socket.write(encodePongFrame(p));
244
+ }
245
+ catch {
246
+ /* gone */
247
+ }
248
+ },
249
+ onClose: () => {
250
+ try {
251
+ socket.write(encodeCloseFrame());
252
+ }
253
+ catch {
254
+ /* gone */
255
+ }
256
+ socket.end();
257
+ },
258
+ });
259
+ if (head && head.length > 0)
260
+ feed(head);
261
+ socket.on("data", (chunk) => feed(chunk));
262
+ socket.on("error", () => {
263
+ /* a dropped connection is not a server error */
264
+ });
265
+ };
266
+ const server = createServer((req, res) => {
267
+ handler(req, res).catch((err) => {
268
+ // Never a silent 200: an internal failure surfaces as a loud 5xx.
269
+ const message = err instanceof Error ? err.message : String(err);
270
+ if (!res.headersSent)
271
+ send(res, 500, { error: `internal error: ${message}` });
272
+ else
273
+ res.end();
274
+ });
275
+ });
276
+ // WebSocket upgrade (ADR-0008 capability gate): a host that does not advertise
277
+ // `websockets` is refused LOUDLY (426, no 101) — never silently downgraded.
278
+ server.on("upgrade", (req, socket, head) => {
279
+ if (opts.adapter.capabilities.websockets !== true) {
280
+ refuseUpgrade(socket, "426 Upgrade Required");
281
+ return;
282
+ }
283
+ if ((req.url ?? "").split("?")[0] !== "/v1/ws") {
284
+ refuseUpgrade(socket, "404 Not Found");
285
+ return;
286
+ }
287
+ const key = req.headers["sec-websocket-key"];
288
+ const upgrade = String(req.headers["upgrade"] ?? "").toLowerCase();
289
+ if (upgrade !== "websocket" || typeof key !== "string") {
290
+ refuseUpgrade(socket, "400 Bad Request");
291
+ return;
292
+ }
293
+ writeHandshake(socket, key);
294
+ runWsConnection(socket, head);
295
+ });
296
+ return {
297
+ server,
298
+ listen(port = 0, host = "127.0.0.1") {
299
+ return new Promise((resolve) => {
300
+ server.listen(port, host, () => {
301
+ const addr = server.address();
302
+ const p = typeof addr === "object" && addr ? addr.port : port;
303
+ // a wildcard bind (0.0.0.0/::) is reachable externally; the returned URL
304
+ // uses loopback so a local client can always connect.
305
+ const connectHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
306
+ resolve(`http://${connectHost}:${p}`);
307
+ });
308
+ });
309
+ },
310
+ close() {
311
+ return new Promise((resolve, reject) => {
312
+ server.close((err) => (err ? reject(err) : resolve()));
313
+ });
314
+ },
315
+ };
316
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { StreamEvent } from "./events.js";
3
+ export declare function wantsStream(req: IncomingMessage, rawUrl: string): boolean;
4
+ export interface SseWriter {
5
+ emit(ev: StreamEvent): void;
6
+ end(): void;
7
+ closed(): boolean;
8
+ }
9
+ export declare function openSse(res: ServerResponse): SseWriter;
package/dist/sse.js ADDED
@@ -0,0 +1,44 @@
1
+ export function wantsStream(req, rawUrl) {
2
+ const accept = req.headers["accept"];
3
+ if (typeof accept === "string" && accept.includes("text/event-stream"))
4
+ return true;
5
+ const q = rawUrl.split("?")[1] ?? "";
6
+ return /(^|&)stream=1(&|$)/.test(q);
7
+ }
8
+ export function openSse(res) {
9
+ res.writeHead(200, {
10
+ "content-type": "text/event-stream",
11
+ "cache-control": "no-cache",
12
+ connection: "keep-alive",
13
+ });
14
+ let closed = false;
15
+ res.on("close", () => {
16
+ closed = true;
17
+ });
18
+ return {
19
+ emit(ev) {
20
+ if (closed)
21
+ return;
22
+ try {
23
+ res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
24
+ }
25
+ catch {
26
+ closed = true; // socket gone — stop writing, never throw
27
+ }
28
+ },
29
+ end() {
30
+ if (closed)
31
+ return;
32
+ closed = true;
33
+ try {
34
+ res.end();
35
+ }
36
+ catch {
37
+ /* already gone */
38
+ }
39
+ },
40
+ closed() {
41
+ return closed;
42
+ },
43
+ };
44
+ }
package/dist/ws.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { Duplex } from "node:stream";
2
+ export declare const MAX_WS_FRAME: number;
3
+ /** RFC 6455 accept token: base64( sha1( Sec-WebSocket-Key + magic GUID ) ). */
4
+ export declare function acceptKey(secWebSocketKey: string): string;
5
+ export declare function writeHandshake(socket: Duplex, key: string): void;
6
+ export declare function refuseUpgrade(socket: Duplex, statusLine: string): void;
7
+ export interface WsFrame {
8
+ fin: boolean;
9
+ opcode: number;
10
+ masked: boolean;
11
+ payload: Buffer;
12
+ }
13
+ /**
14
+ * Parse zero or more COMPLETE frames from `buf`; return them plus the unconsumed
15
+ * tail (a partial frame is left for the next chunk). Client frames are masked; we
16
+ * unmask. Handles 7-bit, 16-bit (126), and 64-bit (127) payload lengths.
17
+ */
18
+ export declare function decodeFrames(buf: Buffer): {
19
+ frames: WsFrame[];
20
+ rest: Buffer;
21
+ };
22
+ /** Server→client TEXT frame (FIN=1, opcode 0x1, unmasked). */
23
+ export declare function encodeTextFrame(text: string): Buffer;
24
+ /** Server close frame (FIN=1, opcode 0x8, 2-byte status code). */
25
+ export declare function encodeCloseFrame(code?: number): Buffer;
26
+ /** Server pong frame (FIN=1, opcode 0xA), echoing the ping payload. */
27
+ export declare function encodePongFrame(payload: Buffer): Buffer;
28
+ export interface WsFramerCallbacks {
29
+ onText: (text: string) => void;
30
+ onPing: (payload: Buffer) => void;
31
+ onClose: () => void;
32
+ }
33
+ /**
34
+ * A stateful chunk feeder: buffers partial frames across `data` events, reassembles
35
+ * a fragmented text message (text frame fin=0 then continuation frames), and routes
36
+ * control frames. Returns a `feed(chunk)` function. Unit-testable in isolation.
37
+ */
38
+ export declare function makeWsFramer(cb: WsFramerCallbacks): (chunk: Buffer) => void;
package/dist/ws.js ADDED
@@ -0,0 +1,191 @@
1
+ // Hand-rolled, zero-dep WebSocket (RFC 6455) for the streaming channel
2
+ // (serve-streaming Task 5). Node 24 ships a WebSocket CLIENT but no SERVER, and
3
+ // Iris has zero runtime deps — so we implement the handshake (node:crypto SHA-1)
4
+ // and a minimal text-frame codec here, consistent with the repo's hand-rolled
5
+ // MCP/gRPC/OCI protocols. Text frames only; ping/pong/close handled; client→server
6
+ // frames are masked (per spec) and unmasked here; server→client frames are
7
+ // unmasked. permessage-deflate is NOT negotiated (the handshake never echoes
8
+ // Sec-WebSocket-Extensions), so the built-in client sends raw frames. Host-side.
9
+ import { createHash } from "node:crypto";
10
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
11
+ // Hard cap on a single frame's declared payload (turn-request JSON is tiny). A
12
+ // hostile client could otherwise declare a 64-bit length and dribble bytes,
13
+ // growing the reassembly buffer without bound (DoS). decodeFrames THROWS on an
14
+ // over-cap declaration so the connection is closed loudly rather than OOM'd.
15
+ export const MAX_WS_FRAME = 4 * 1024 * 1024; // 4 MiB
16
+ /** RFC 6455 accept token: base64( sha1( Sec-WebSocket-Key + magic GUID ) ). */
17
+ export function acceptKey(secWebSocketKey) {
18
+ return createHash("sha1").update(secWebSocketKey + WS_GUID).digest("base64");
19
+ }
20
+ export function writeHandshake(socket, key) {
21
+ socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
22
+ "Upgrade: websocket\r\n" +
23
+ "Connection: Upgrade\r\n" +
24
+ `Sec-WebSocket-Accept: ${acceptKey(key)}\r\n` +
25
+ "\r\n");
26
+ }
27
+ export function refuseUpgrade(socket, statusLine) {
28
+ try {
29
+ socket.write(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`);
30
+ }
31
+ catch {
32
+ /* socket already gone */
33
+ }
34
+ socket.destroy();
35
+ }
36
+ /**
37
+ * Parse zero or more COMPLETE frames from `buf`; return them plus the unconsumed
38
+ * tail (a partial frame is left for the next chunk). Client frames are masked; we
39
+ * unmask. Handles 7-bit, 16-bit (126), and 64-bit (127) payload lengths.
40
+ */
41
+ export function decodeFrames(buf) {
42
+ const frames = [];
43
+ let offset = 0;
44
+ while (offset + 2 <= buf.length) {
45
+ const b0 = buf[offset];
46
+ const b1 = buf[offset + 1];
47
+ const fin = (b0 & 0x80) !== 0;
48
+ const opcode = b0 & 0x0f;
49
+ const masked = (b1 & 0x80) !== 0;
50
+ let len = b1 & 0x7f;
51
+ let p = offset + 2;
52
+ if (len === 126) {
53
+ if (p + 2 > buf.length)
54
+ break;
55
+ len = buf.readUInt16BE(p);
56
+ p += 2;
57
+ }
58
+ else if (len === 127) {
59
+ // 64-bit length branch — kept for spec correctness even though the built-in
60
+ // client uses minimal encoding (Iris memory: don't drop it as "dead code").
61
+ if (p + 8 > buf.length)
62
+ break;
63
+ const big = buf.readBigUInt64BE(p);
64
+ p += 8;
65
+ if (big > BigInt(MAX_WS_FRAME)) {
66
+ throw new RangeError(`ws frame too large: ${big} > ${MAX_WS_FRAME}`);
67
+ }
68
+ len = Number(big);
69
+ }
70
+ // Reject an over-cap declaration BEFORE allocating/awaiting the payload (DoS guard).
71
+ if (len > MAX_WS_FRAME)
72
+ throw new RangeError(`ws frame too large: ${len} > ${MAX_WS_FRAME}`);
73
+ let maskKey = null;
74
+ if (masked) {
75
+ if (p + 4 > buf.length)
76
+ break;
77
+ maskKey = buf.subarray(p, p + 4);
78
+ p += 4;
79
+ }
80
+ if (p + len > buf.length)
81
+ break; // incomplete payload — wait for more bytes
82
+ let payload;
83
+ if (maskKey) {
84
+ payload = Buffer.allocUnsafe(len);
85
+ for (let i = 0; i < len; i++)
86
+ payload[i] = buf[p + i] ^ maskKey[i & 3];
87
+ }
88
+ else {
89
+ payload = Buffer.from(buf.subarray(p, p + len)); // copy out of the shared buffer
90
+ }
91
+ frames.push({ fin, opcode, masked, payload });
92
+ offset = p + len;
93
+ }
94
+ return { frames, rest: buf.subarray(offset) };
95
+ }
96
+ /** Server→client TEXT frame (FIN=1, opcode 0x1, unmasked). */
97
+ export function encodeTextFrame(text) {
98
+ const payload = Buffer.from(text, "utf8");
99
+ const len = payload.length;
100
+ let header;
101
+ if (len < 126) {
102
+ header = Buffer.from([0x81, len]);
103
+ }
104
+ else if (len < 65536) {
105
+ header = Buffer.allocUnsafe(4);
106
+ header[0] = 0x81;
107
+ header[1] = 126;
108
+ header.writeUInt16BE(len, 2);
109
+ }
110
+ else {
111
+ header = Buffer.allocUnsafe(10);
112
+ header[0] = 0x81;
113
+ header[1] = 127;
114
+ header.writeBigUInt64BE(BigInt(len), 2);
115
+ }
116
+ return Buffer.concat([header, payload]);
117
+ }
118
+ /** Server close frame (FIN=1, opcode 0x8, 2-byte status code). */
119
+ export function encodeCloseFrame(code = 1000) {
120
+ const body = Buffer.allocUnsafe(2);
121
+ body.writeUInt16BE(code, 0);
122
+ return Buffer.concat([Buffer.from([0x88, 2]), body]);
123
+ }
124
+ /** Server pong frame (FIN=1, opcode 0xA), echoing the ping payload. */
125
+ export function encodePongFrame(payload) {
126
+ const len = Math.min(payload.length, 125); // control frames carry < 126 bytes
127
+ return Buffer.concat([Buffer.from([0x8a, len]), payload.subarray(0, len)]);
128
+ }
129
+ /**
130
+ * A stateful chunk feeder: buffers partial frames across `data` events, reassembles
131
+ * a fragmented text message (text frame fin=0 then continuation frames), and routes
132
+ * control frames. Returns a `feed(chunk)` function. Unit-testable in isolation.
133
+ */
134
+ export function makeWsFramer(cb) {
135
+ let buf = Buffer.alloc(0); // annotated: decodeFrames' tail is Buffer<ArrayBufferLike>
136
+ let fragData = [];
137
+ let fragging = false;
138
+ return (chunk) => {
139
+ buf = Buffer.concat([buf, chunk]);
140
+ let frames;
141
+ let rest;
142
+ try {
143
+ ({ frames, rest } = decodeFrames(buf));
144
+ }
145
+ catch {
146
+ // over-cap frame (DoS guard) or malformed framing → close loudly, drop buffer
147
+ buf = Buffer.alloc(0);
148
+ cb.onClose();
149
+ return;
150
+ }
151
+ buf = rest;
152
+ for (const f of frames) {
153
+ // RFC 6455 §5.1: a server MUST fail the connection on an unmasked client frame.
154
+ if (!f.masked) {
155
+ cb.onClose();
156
+ return;
157
+ }
158
+ switch (f.opcode) {
159
+ case 0x8: // close
160
+ cb.onClose();
161
+ break;
162
+ case 0x9: // ping
163
+ cb.onPing(f.payload);
164
+ break;
165
+ case 0xa: // pong — ignore
166
+ break;
167
+ case 0x1: // text (possibly fragmented)
168
+ fragData = [f.payload];
169
+ fragging = true;
170
+ if (f.fin) {
171
+ cb.onText(Buffer.concat(fragData).toString("utf8"));
172
+ fragData = [];
173
+ fragging = false;
174
+ }
175
+ break;
176
+ case 0x0: // continuation
177
+ if (fragging) {
178
+ fragData.push(f.payload);
179
+ if (f.fin) {
180
+ cb.onText(Buffer.concat(fragData).toString("utf8"));
181
+ fragData = [];
182
+ fragging = false;
183
+ }
184
+ }
185
+ break;
186
+ default:
187
+ break; // unknown opcode — ignore
188
+ }
189
+ }
190
+ };
191
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@irisrun/channel-rest",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Iris in-process REST channel (ADR-0009) over node:http — the two-identifier protocol: the channel MINTS sessionId and OWNS/ISSUES the continuationToken; every turn rotates the token, and a missing/stale/malformed token is refused with a loud 4xx (never a silent 200). A real external HTTP deploy is a manual smoke.",
6
+ "exports": {
7
+ ".": {
8
+ "iris-src": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@irisrun/core": "^0.1.0",
15
+ "@irisrun/host": "^0.1.0"
16
+ },
17
+ "license": "MIT",
18
+ "engines": {
19
+ "node": ">=24"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/xoai/iris.git",
27
+ "directory": "packages/channel-rest"
28
+ },
29
+ "homepage": "https://github.com/xoai/iris#readme",
30
+ "files": [
31
+ "dist"
32
+ ]
33
+ }