@irisrun/channel-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.
@@ -0,0 +1,32 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>iris — web chat</title>
7
+ <style>
8
+ body { font: 14px/1.5 system-ui, -apple-system, sans-serif; max-width: 680px; margin: 2rem auto; padding: 0 1rem; color: #111; }
9
+ h1 { font-size: 1.1rem; }
10
+ #banner { color: #555; font-size: 12px; margin-bottom: .5rem; }
11
+ #log { border: 1px solid #ddd; border-radius: 8px; padding: .75rem; height: 60vh; overflow-y: auto; white-space: pre-wrap; }
12
+ .msg { margin: .25rem 0; }
13
+ .msg.user { color: #1a56db; }
14
+ .msg.agent { color: #111; }
15
+ form { display: flex; gap: .5rem; margin-top: .5rem; }
16
+ input { flex: 1; padding: .5rem; border: 1px solid #ccc; border-radius: 6px; }
17
+ button { padding: .5rem .9rem; border: 0; border-radius: 6px; background: #1a56db; color: #fff; cursor: pointer; }
18
+ #reset { background: #eee; color: #333; }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <h1>iris — web chat</h1>
23
+ <div id="banner"></div>
24
+ <div id="log"></div>
25
+ <form id="form">
26
+ <input id="input" autocomplete="off" placeholder="Type a message…" autofocus />
27
+ <button type="submit">Send</button>
28
+ <button type="button" id="reset">New</button>
29
+ </form>
30
+ <script type="module" src="/iris-web.js"></script>
31
+ </body>
32
+ </html>
@@ -0,0 +1,105 @@
1
+ // @iris/channel-web browser shell — a STATIC asset (NOT typechecked, NOT in the
2
+ // test glob; browser render is a manual smoke). It MIRRORS @iris/client-sdk's wire
3
+ // logic (no bundler → no shared import) and talks the `iris serve` two-identifier
4
+ // protocol over SSE. It persists {sessionId, continuationToken} to localStorage so a
5
+ // tab close/reload resumes the SAME session WHILE THE SERVER IS UP (the channel owns
6
+ // the token in its in-memory Map; a serve RESTART does NOT resume via the REST token
7
+ // — that cross-restart/host-migration durability is the edge deploy's story). On a
8
+ // stale token (404/409, e.g. after a restart) the shell starts fresh, never throws.
9
+ const KEY = "iris.session";
10
+ const $ = (id) => document.getElementById(id);
11
+
12
+ function loadHandle() {
13
+ try {
14
+ const h = JSON.parse(localStorage.getItem(KEY) || "null");
15
+ return h && typeof h.sessionId === "string" && typeof h.continuationToken === "string" ? h : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ function saveHandle(h) { try { localStorage.setItem(KEY, JSON.stringify(h)); } catch {} }
21
+ function clearHandle() { try { localStorage.removeItem(KEY); } catch {} }
22
+
23
+ let handle = loadHandle();
24
+
25
+ function append(role, text) {
26
+ const div = document.createElement("div");
27
+ div.className = "msg " + role;
28
+ div.textContent = (role === "user" ? "you> " : "agent> ") + text;
29
+ $("log").appendChild(div);
30
+ $("log").scrollTop = $("log").scrollHeight;
31
+ return div;
32
+ }
33
+ function banner(t) { $("banner").textContent = t; }
34
+
35
+ banner(handle
36
+ ? "resumed session " + handle.sessionId + " (close/reload keeps it while the server is up)"
37
+ : "new session");
38
+
39
+ // Mirror of client-sdk parseSseFrames: complete frames out, trailing partial in rest.
40
+ function parseSse(buf) {
41
+ const events = [];
42
+ let rest = buf;
43
+ let i = rest.indexOf("\n\n");
44
+ while (i !== -1) {
45
+ const frame = rest.slice(0, i);
46
+ rest = rest.slice(i + 2);
47
+ const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).replace(/^ /, "")).join("\n");
48
+ if (data) { try { events.push(JSON.parse(data)); } catch { events.push({ type: "error", message: "malformed SSE data frame" }); } }
49
+ i = rest.indexOf("\n\n");
50
+ }
51
+ return { events, rest };
52
+ }
53
+
54
+ async function turn(text) {
55
+ append("user", text);
56
+ const url = handle ? "/v1/session/" + encodeURIComponent(handle.sessionId) + "/message" : "/v1/session";
57
+ const body = { messages: [{ role: "user", content: text }] };
58
+ if (handle) body.continuationToken = handle.continuationToken;
59
+
60
+ const res = await fetch(url, {
61
+ method: "POST",
62
+ headers: { "content-type": "application/json", accept: "text/event-stream" },
63
+ body: JSON.stringify(body),
64
+ });
65
+ if (!res.ok) {
66
+ let msg = "HTTP " + res.status;
67
+ try { const j = await res.json(); if (j && j.error) msg = j.error; } catch {}
68
+ if (res.status === 404 || res.status === 409) { clearHandle(); handle = null; banner("previous session expired — starting fresh"); }
69
+ append("agent", "⚠ " + msg);
70
+ return;
71
+ }
72
+
73
+ const reader = res.body.getReader();
74
+ const dec = new TextDecoder();
75
+ let buf = "";
76
+ let line = null;
77
+ for (;;) {
78
+ const { value, done } = await reader.read();
79
+ if (value) buf += dec.decode(value, { stream: true });
80
+ const parsed = parseSse(buf);
81
+ buf = parsed.rest;
82
+ for (const ev of parsed.events) {
83
+ if (ev.type === "delta") {
84
+ if (!line) line = append("agent", "");
85
+ line.textContent += ev.text;
86
+ $("log").scrollTop = $("log").scrollHeight;
87
+ } else if (ev.type === "error") {
88
+ append("agent", "⚠ " + ev.message);
89
+ } else if (ev.type === "outcome") {
90
+ if (ev.continuationToken) { handle = { sessionId: ev.sessionId, continuationToken: ev.continuationToken }; saveHandle(handle); }
91
+ if (!line && ev.status === "finished" && ev.output !== undefined) append("agent", JSON.stringify(ev.output));
92
+ }
93
+ }
94
+ if (done) break;
95
+ }
96
+ }
97
+
98
+ $("form").addEventListener("submit", (e) => {
99
+ e.preventDefault();
100
+ const v = $("input").value.trim();
101
+ if (!v) return;
102
+ $("input").value = "";
103
+ turn(v).catch((err) => append("agent", "⚠ " + (err && err.message ? err.message : String(err))));
104
+ });
105
+ $("reset").addEventListener("click", () => { clearHandle(); handle = null; $("log").innerHTML = ""; banner("new session"); });
@@ -0,0 +1,18 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export declare const PACKAGE = "@irisrun/channel-web";
3
+ export interface WebAsset {
4
+ contentType: string;
5
+ body: string;
6
+ }
7
+ /** The served routes: path → {contentType, body}. Loaded once at module init. */
8
+ export declare const webAssets: Record<string, WebAsset>;
9
+ export interface WebHandlerOptions {
10
+ /** reserved (future): a page title override. Unused today. */
11
+ title?: string;
12
+ }
13
+ /**
14
+ * A pre-POST GET hook for `RestChannelOptions.webHandler`. Serves `GET /` and
15
+ * `GET /iris-web.js`; returns `false` for any other method/path so the REST channel
16
+ * keeps handling `/v1/*` (and the upgrade listener keeps handling WS). Never throws.
17
+ */
18
+ export declare function makeWebHandler(_opts?: WebHandlerOptions): (req: IncomingMessage, res: ServerResponse) => boolean;
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ // @irisrun/channel-web — the web channel's HOST side (spec §2.2). `makeWebHandler`
2
+ // returns a pre-POST GET hook for `makeRestChannel`'s `webHandler` seam: it serves
3
+ // the two static assets (the chat page + its browser shell) and returns `false` for
4
+ // everything else, so `/v1/*` POST and the WebSocket upgrade are untouched. Host-side
5
+ // (node:fs to read the assets at load; node:http types only). The assets themselves
6
+ // are NOT typechecked / NOT in the test glob — the browser render is a manual smoke.
7
+ import { readFileSync } from "node:fs";
8
+ export const PACKAGE = "@irisrun/channel-web";
9
+ // Read an asset relative to THIS module (packages/channel-web/src/ → ../assets/).
10
+ function loadAsset(rel) {
11
+ return readFileSync(new URL(rel, import.meta.url), "utf8");
12
+ }
13
+ /** The served routes: path → {contentType, body}. Loaded once at module init. */
14
+ export const webAssets = {
15
+ "/": { contentType: "text/html; charset=utf-8", body: loadAsset("../assets/index.html") },
16
+ "/iris-web.js": {
17
+ contentType: "text/javascript; charset=utf-8",
18
+ body: loadAsset("../assets/iris-web.js"),
19
+ },
20
+ };
21
+ /**
22
+ * A pre-POST GET hook for `RestChannelOptions.webHandler`. Serves `GET /` and
23
+ * `GET /iris-web.js`; returns `false` for any other method/path so the REST channel
24
+ * keeps handling `/v1/*` (and the upgrade listener keeps handling WS). Never throws.
25
+ */
26
+ export function makeWebHandler(_opts = {}) {
27
+ return (req, res) => {
28
+ if (req.method !== "GET")
29
+ return false;
30
+ const path = (req.url ?? "").split("?")[0];
31
+ const asset = webAssets[path];
32
+ if (asset === undefined)
33
+ return false;
34
+ res.writeHead(200, { "content-type": asset.contentType });
35
+ res.end(asset.body);
36
+ return true;
37
+ };
38
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@irisrun/channel-web",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Iris web channel — a minimal, zero-dep web chat UI served by `iris serve --web` on the same port. `makeWebHandler` is a pre-POST GET hook (host-side, node:fs/http) that serves two static assets (index.html + iris-web.js). The browser shell talks the iris serve SSE protocol and persists {sessionId, continuationToken} to localStorage so a tab close/reload resumes the same session while the server is up. The shell MIRRORS @irisrun/client-sdk's wire logic (no bundler → no shared import); it is a static asset OUTSIDE the tsc include and the test glob.",
6
+ "exports": {
7
+ ".": {
8
+ "iris-src": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "license": "MIT",
14
+ "engines": {
15
+ "node": ">=24"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/xoai/iris.git",
23
+ "directory": "packages/channel-web"
24
+ },
25
+ "homepage": "https://github.com/xoai/iris#readme",
26
+ "files": [
27
+ "dist",
28
+ "assets"
29
+ ]
30
+ }