@irisrun/channel-rest 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,34 @@
1
+ # @irisrun/channel-rest
2
+
3
+ **Durable, replay-safe HTTP sessions.** The in-process REST channel over
4
+ `node:http` that owns the two-identifier protocol — a stable `sessionId` plus a
5
+ single-use `continuationToken` rotated every committed turn — so a session can be
6
+ paused and resumed across processes and a stale or forged token is refused
7
+ **loudly** (4xx), never a silent 200.
8
+
9
+ ## What it is
10
+
11
+ `makeRestChannel` mints the `sessionId`, owns and issues the `continuationToken`,
12
+ and streams a turn live over **SSE** *and* a hand-rolled, zero-dependency
13
+ **WebSocket** (`ws://…/v1/ws`) — both records and model token deltas. Drop the
14
+ `Accept: text/event-stream` header for one buffered JSON reply. A real
15
+ external HTTP deploy is an env-gated smoke test.
16
+
17
+ The two-identifier protocol itself — token rotation (only on a committed turn), the
18
+ atomic single-use claim, and the loud refusal taxonomy — lives in
19
+ **[`@irisrun/channel-core`](../channel-core/README.md)**, the shared channel **port**;
20
+ this package is the HTTP/SSE/WS transport over it and passes the shared channel-port
21
+ conformance suite (see the normative
22
+ **[channel-port spec](../../docs/reference/channel-port-spec.md)**).
23
+
24
+ ## Use it
25
+
26
+ ```sh
27
+ iris serve ./image --port 8787 # REST + SSE + WS; add --web for the chat UI
28
+ ```
29
+
30
+ See **[docs/Channels](../../docs/channels.md)** for the wire protocol and
31
+ **[@irisrun/client-sdk](../client-sdk/README.md)** for a typed client.
32
+
33
+ ---
34
+ Part of [Iris](../../README.md) — own, portable, verifiable state.
package/dist/events.d.ts CHANGED
@@ -1,20 +1,2 @@
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;
1
+ export type { ChannelEvent as StreamEvent } from "@irisrun/channel-core";
2
+ export { toOutcomeEvent } from "@irisrun/channel-core";
package/dist/events.js CHANGED
@@ -1,18 +1 @@
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
- }
1
+ export { toOutcomeEvent } from "@irisrun/channel-core";
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- // makeRestChannel (ADR-0009, Spec 05 B3): an in-process node:http server speaking
1
+ // makeRestChannel: an in-process node:http server speaking
2
2
  // the TWO-IDENTIFIER protocol. The channel MINTS the sessionId and OWNS/ISSUES the
3
3
  // continuationToken — the client presents it on the next call. Every turn ROTATES
4
4
  // the token; a missing/stale/malformed token is refused with a LOUD 4xx, never a
@@ -8,6 +8,7 @@ import { createServer } from "node:http";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { runTurnOn } from "@irisrun/host";
10
10
  import { toOutcomeEvent } from "./events.js";
11
+ import { makeChannelSession } from "@irisrun/channel-core";
11
12
  import { wantsStream, openSse } from "./sse.js";
12
13
  import { writeHandshake, refuseUpgrade, makeWsFramer, encodeTextFrame, encodeCloseFrame, encodePongFrame, } from "./ws.js";
13
14
  const SESSION_MESSAGE = /^\/v1\/session\/([^/]+)\/message$/;
@@ -41,58 +42,60 @@ function turnResponse(sessionId, token, outcome) {
41
42
  return base;
42
43
  }
43
44
  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;
45
+ // The two-identifier protocol (mint sessionId, own/rotate a single-use token, atomic
46
+ // single-use, committed-only rotation) lives in the shared channel-core port — the
47
+ // SAME driver channel-mcp uses. This REST transport maps refusals to HTTP status and
48
+ // adds the SSE/WS framing. `runTurn` builds the per-request journal-timeline tap.
49
+ const session = makeChannelSession({
50
+ runTurn: async (sessionId, body, emit) => {
51
+ const inputs = await opts.makeTurnInputs(sessionId, body, emit);
52
+ // onRecord is the per-REQUEST journal-timeline tap (only on a streaming request).
53
+ // It rides RunTurnOnOptions, NOT TurnInputs (per-session-static).
54
+ const onRecord = emit
55
+ ? (r) => emit({ type: "record", record: r })
56
+ : undefined;
57
+ return runTurnOn(opts.adapter, {
58
+ sessionId,
59
+ ...inputs,
60
+ ...(onRecord ? { onRecord } : {}),
61
+ });
62
+ },
63
+ mintSessionId: opts.mintSessionId ?? (() => randomUUID()),
64
+ mintToken: opts.mintToken ?? (() => randomUUID()),
65
+ });
66
+ // Map a channel-core refusal to its LOUD HTTP status (preserving the prior messages).
67
+ const REFUSAL_STATUS = {
68
+ "unknown-session": 404,
69
+ "missing-token": 400,
70
+ "stale-token": 409,
71
+ "in-flight": 409,
65
72
  };
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
- });
73
+ const refusalMessage = (reason, sessionId) => {
74
+ switch (reason) {
75
+ case "unknown-session":
76
+ return `unknown session '${sessionId}'`;
77
+ case "missing-token":
78
+ return "missing continuationToken";
79
+ case "stale-token":
80
+ return "stale or invalid continuationToken";
81
+ case "in-flight":
82
+ return "a turn is already in flight for this session";
83
+ }
78
84
  };
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
85
+ // Drive a session op into an SSE stream: records + deltas, then a terminal outcome
86
+ // carrying the rotated token. Any loud refusal (4xx) already ran in the handler
81
87
  // BEFORE this opens the stream, so a refusal is never a half-open SSE.
82
- const streamTurn = async (res, sessionId, body, priorToken) => {
88
+ const runSse = async (res, produce) => {
83
89
  const sse = openSse(res);
84
90
  const emit = (ev) => sse.emit(ev);
85
- let outcome;
86
91
  try {
87
- outcome = await runTurn(sessionId, body, emit);
92
+ const r = await produce(emit);
93
+ emit(toOutcomeEvent(r.sessionId, r.outcome, r.token));
88
94
  }
89
95
  catch (err) {
90
96
  // The turn threw AFTER the stream opened — surface it in-band, loudly.
91
97
  emit({ type: "error", message: err instanceof Error ? err.message : String(err) });
92
- sse.end();
93
- return;
94
98
  }
95
- emit(toOutcomeEvent(sessionId, outcome, issueToken(sessionId, outcome, priorToken)));
96
99
  sse.end();
97
100
  };
98
101
  const handler = async (req, res) => {
@@ -115,61 +118,54 @@ export function makeRestChannel(opts) {
115
118
  const body = parsed.body;
116
119
  // --- start: MINT a session + the first continuationToken --------------
117
120
  if (url === "/v1/session") {
118
- const sessionId = mintSessionId();
119
121
  if (stream) {
120
- await streamTurn(res, sessionId, body, null); // START → always issues a fresh token
122
+ await runSse(res, async (emit) => {
123
+ const r = await session.start(body, emit); // START → always issues a fresh token
124
+ return { sessionId: r.sessionId, token: r.token, outcome: r.outcome };
125
+ });
121
126
  return;
122
127
  }
123
- const outcome = await runTurn(sessionId, body);
124
- const token = issueToken(sessionId, outcome, null);
125
- send(res, 200, turnResponse(sessionId, token, outcome));
128
+ const r = await session.start(body);
129
+ send(res, 200, turnResponse(r.sessionId, r.token, r.outcome));
126
130
  return;
127
131
  }
128
132
  // --- continue: REQUIRE the matching token, then ROTATE it -------------
129
133
  const m = SESSION_MESSAGE.exec(url);
130
134
  if (m) {
131
135
  const sessionId = decodeURIComponent(m[1]);
132
- if (!tokens.has(sessionId)) {
133
- send(res, 404, { error: `unknown session '${sessionId}'` });
134
- return;
135
- }
136
136
  const headerToken = req.headers["x-continuation-token"];
137
137
  const presented = typeof body.continuationToken === "string"
138
138
  ? body.continuationToken
139
139
  : typeof headerToken === "string"
140
140
  ? headerToken
141
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
142
+ if (stream) {
143
+ // Validate the token AND peek in-flight BEFORE opening the stream — these
144
+ // checks plus the advance() claim run with no `await` between, so the
145
+ // single-use claim stays atomic and a refusal is never a half-open SSE.
146
+ const refusal = session.validateContinue(sessionId, presented);
147
+ if (refusal) {
148
+ send(res, REFUSAL_STATUS[refusal], { error: refusalMessage(refusal, sessionId) });
149
+ return;
163
150
  }
164
- else {
165
- const outcome = await runTurn(sessionId, body);
166
- const next = issueToken(sessionId, outcome, presented);
167
- send(res, 200, turnResponse(sessionId, next, outcome));
151
+ if (session.inFlight(sessionId)) {
152
+ send(res, 409, { error: "a turn is already in flight for this session" });
153
+ return;
168
154
  }
155
+ await runSse(res, async (emit) => {
156
+ const r = await session.advance(sessionId, body, emit); // rotate only if committed
157
+ if (!r.ok)
158
+ throw new Error("a turn is already in flight for this session");
159
+ return { sessionId, token: r.token, outcome: r.outcome };
160
+ });
161
+ return;
169
162
  }
170
- finally {
171
- inFlight.delete(sessionId);
163
+ const r = await session.continueTurn(sessionId, presented, body);
164
+ if (!r.ok) {
165
+ send(res, REFUSAL_STATUS[r.reason], { error: refusalMessage(r.reason, sessionId) });
166
+ return;
172
167
  }
168
+ send(res, 200, turnResponse(sessionId, r.token, r.outcome));
173
169
  return;
174
170
  }
175
171
  send(res, 404, { error: `no route for ${url}` });
@@ -209,30 +205,37 @@ export function makeRestChannel(opts) {
209
205
  sendEvent({ type: "error", message: "present no continuationToken to start a session" });
210
206
  return;
211
207
  }
212
- sessionId = mintSessionId();
208
+ // Bind the session to the connection SYNCHRONOUSLY (before the first turn
209
+ // runs) so a second frame on this connection sees the bound session.
210
+ sessionId = session.newSessionId();
213
211
  }
214
212
  else if (presented !== null) {
215
- if (presented !== tokens.get(sessionId)) {
213
+ // The held connection authorizes continuation; a presented token (if any) is
214
+ // validated against the current one (read fresh, never cached).
215
+ if (presented !== session.currentToken(sessionId)) {
216
216
  sendEvent({ type: "error", message: "stale or invalid continuationToken" });
217
217
  return;
218
218
  }
219
219
  }
220
- if (inFlight.has(sessionId)) {
220
+ // in-flight peek then advance() claim run with no `await` between → atomic
221
+ // single-use even across two frames interleaving at their awaits.
222
+ if (session.inFlight(sessionId)) {
221
223
  sendEvent({ type: "error", message: "a turn is already in flight for this session" });
222
224
  return;
223
225
  }
224
- const priorToken = tokens.get(sessionId) ?? null; // null on this connection's START turn
225
- inFlight.add(sessionId);
226
226
  try {
227
- const outcome = await runTurn(sessionId, body, sendEvent);
228
- sendEvent(toOutcomeEvent(sessionId, outcome, issueToken(sessionId, outcome, priorToken)));
227
+ // advance on a freshly-minted (unregistered) session runs as a START (prior
228
+ // token null mints); on a bound session it rotates per the committed rule.
229
+ const r = await session.advance(sessionId, body, sendEvent);
230
+ if (!r.ok) {
231
+ sendEvent({ type: "error", message: "a turn is already in flight for this session" });
232
+ return;
233
+ }
234
+ sendEvent(toOutcomeEvent(sessionId, r.outcome, r.token));
229
235
  }
230
236
  catch (err) {
231
237
  sendEvent({ type: "error", message: err instanceof Error ? err.message : String(err) });
232
238
  }
233
- finally {
234
- inFlight.delete(sessionId);
235
- }
236
239
  };
237
240
  const feed = makeWsFramer({
238
241
  onText: (t) => {
@@ -273,7 +276,7 @@ export function makeRestChannel(opts) {
273
276
  res.end();
274
277
  });
275
278
  });
276
- // WebSocket upgrade (ADR-0008 capability gate): a host that does not advertise
279
+ // WebSocket upgrade (capability gate): a host that does not advertise
277
280
  // `websockets` is refused LOUDLY (426, no 101) — never silently downgraded.
278
281
  server.on("upgrade", (req, socket, head) => {
279
282
  if (opts.adapter.capabilities.websockets !== true) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@irisrun/channel-rest",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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.",
5
+ "description": "Durable, replay-safe HTTP sessions — the in-process REST channel over node:http with live SSE + hand-rolled zero-dep WebSocket streaming. Owns the two-identifier protocol: the channel MINTS the sessionId and OWNS/ISSUES a single-use continuationToken, rotated every committed turn, so 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
6
  "exports": {
7
7
  ".": {
8
8
  "iris-src": "./src/index.ts",
@@ -11,8 +11,9 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@irisrun/core": "^0.1.0",
15
- "@irisrun/host": "^0.1.0"
14
+ "@irisrun/core": "^0.2.0",
15
+ "@irisrun/channel-core": "^0.2.0",
16
+ "@irisrun/host": "^0.2.0"
16
17
  },
17
18
  "license": "MIT",
18
19
  "engines": {