@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 +34 -0
- package/dist/events.d.ts +2 -20
- package/dist/events.js +1 -18
- package/dist/server.js +92 -89
- package/package.json +5 -4
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
|
-
|
|
2
|
-
export
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
80
|
-
// carrying the rotated token.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
124
|
-
|
|
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 (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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.
|
|
15
|
-
"@irisrun/
|
|
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": {
|