@shipvo/sim 0.1.5 → 0.1.6

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/shade.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // Notification shade (Android style), Notification Center and Control Center (iPhone style) for the simulated phone.
2
+ // Pure: no DOM, no timers. The frame page draws the panels; the server uses the same tile rules for the MCP tools.
3
+ // Each tile is a switch in the simulated device (`set_device`), so the app sees the network drop, the colour scheme change, and so on.
4
+
5
+ /** Typed error for a tile or notification action that cannot run. */
6
+ export class ShadeError extends Error { constructor(code, message) { super(message); this.code = code; } }
7
+
8
+ /** Tile id -> label, the device field it flips, and which phones have it. */
9
+ export const TILES = {
10
+ wifi: { label: "Wi-Fi", field: "wifi", on: ["android", "ios"] },
11
+ mobile_data: { label: "Mobile data", field: "mobileData", on: ["android", "ios"] },
12
+ airplane: { label: "Airplane mode", field: "airplane", on: ["android", "ios"] },
13
+ location: { label: "Location", field: "locationServices", on: ["android"] },
14
+ dark_mode: { label: "Dark mode", field: "darkMode", on: ["android", "ios"] },
15
+ dnd: { label: "Do Not Disturb", field: "dnd", on: ["android"] },
16
+ focus: { label: "Focus", field: "dnd", on: ["ios"] },
17
+ auto_rotate: { label: "Auto-rotate", field: "autoRotate", on: ["android"] },
18
+ orientation_lock: { label: "Orientation lock", field: "orientationLock", on: ["ios"] },
19
+ flashlight: { label: "Flashlight", field: "flashlight", on: ["android"] },
20
+ brightness: { label: "Brightness", field: "brightness", on: ["ios"], slider: true },
21
+ };
22
+
23
+ const order = { android: ["wifi", "mobile_data", "airplane", "location", "dark_mode", "dnd", "auto_rotate", "flashlight"], ios: ["airplane", "wifi", "mobile_data", "focus", "orientation_lock", "brightness", "dark_mode"] };
24
+ /** Tiles the first pull of the Android shade shows; the second pull shows the rest. */
25
+ export const ANDROID_FIRST_PULL = 4;
26
+
27
+ /** The tiles for a phone with their state: `[{ id, label, on, value? }]`. `device` is the simulated device settings. */
28
+ export function tileStates(platform, device = {}, expanded = true) {
29
+ const ids = order[platform] ?? order.android;
30
+ const list = platform === "android" && !expanded ? ids.slice(0, ANDROID_FIRST_PULL) : ids;
31
+ return list.map((id) => { const t = TILES[id], v = device[t.field]; return t.slider ? { id, label: t.label, on: true, value: Number(v ?? 70) } : { id, label: t.label, on: id === "wifi" || id === "mobile_data" ? v !== false : v === true }; });
32
+ }
33
+
34
+ /** The `set_device` patch for a tile. `value` is true/false (or 0..100 for brightness); omit it to flip. Throws ShadeError. */
35
+ export function tilePatch(platform, tile, device = {}, value) {
36
+ const t = TILES[tile];
37
+ if (!t) throw new ShadeError("unknown_tile", `unknown tile '${tile}'. Tiles: ${(order[platform] ?? order.android).join(", ")}`);
38
+ if (!t.on.includes(platform)) throw new ShadeError("unknown_tile", `'${tile}' is not on ${platform === "ios" ? "an iPhone" : "an Android phone"}. Tiles: ${order[platform].join(", ")}`);
39
+ const cur = device[t.field];
40
+ if (t.slider) {
41
+ const v = value === undefined ? (Number(cur ?? 70) > 50 ? 20 : 100) : Number(value);
42
+ if (!Number.isFinite(v) || v < 0 || v > 100) throw new ShadeError("bad_value", "brightness needs a value from 0 to 100");
43
+ return { [t.field]: Math.round(v) };
44
+ }
45
+ if (value !== undefined && typeof value !== "boolean") throw new ShadeError("bad_value", `'${tile}' takes true or false`);
46
+ const on = tile === "wifi" || tile === "mobile_data" ? cur !== false : cur === true;
47
+ return { [t.field]: value === undefined ? !on : value };
48
+ }
49
+
50
+ /** Groups notifications by app, newest group first, newest notification first inside a group. */
51
+ export function groupByApp(list = []) {
52
+ const groups = new Map();
53
+ for (const n of [...list].sort((a, b) => (b.at ?? 0) - (a.at ?? 0))) { const k = n.app || "App"; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(n); }
54
+ return [...groups].map(([app, items]) => ({ app, items }));
55
+ }
56
+
57
+ /** "now", "5 min ago", "2 h ago". */
58
+ export function ago(at, now = Date.now()) {
59
+ const m = Math.floor(Math.max(0, now - (at ?? now)) / 60000);
60
+ return m < 1 ? "now" : m < 60 ? `${m} min ago` : `${Math.floor(m / 60)} h ago`;
61
+ }
62
+
63
+ /**
64
+ * Which panel a downward swipe from the top opens. Android: any x opens the shade (the second pull expands it). iPhone: the right third is Control Center, the rest Notification Center.
65
+ * `open` is the current panel: "none", "shade", "shade-expanded", "notifications", "control".
66
+ */
67
+ export function panelFor(platform, x, width, open = "none") {
68
+ if (platform === "ios") return x > width * 2 / 3 ? "control" : "notifications";
69
+ return open === "shade" || open === "shade-expanded" ? "shade-expanded" : "shade";
70
+ }
71
+
72
+ /** Plain-text view of the open panel for an agent (the MCP result): tiles, then notifications. */
73
+ export function describePanel(platform, panel, device, list, now = Date.now()) {
74
+ if (panel === "none") return "Panels closed.";
75
+ const lines = [];
76
+ const control = panel === "control", notif = panel === "notifications";
77
+ const tiles = notif ? [] : tileStates(platform, device, panel !== "shade");
78
+ lines.push(control ? "Control Center" : notif ? "Notification Center" : "Notification shade");
79
+ for (const t of tiles) lines.push(`- tile ${t.id}: ${t.slider ? `${t.value}%` : t.on ? "on" : "off"}`);
80
+ if (!control) {
81
+ if (!list.length) lines.push("No notifications");
82
+ for (const g of platform === "ios" ? groupByApp(list) : [{ app: null, items: [...list].reverse() }]) {
83
+ if (g.app) lines.push(`${g.app}:`);
84
+ for (const n of g.items) lines.push(`- notification ${n.id}: ${n.title} - ${n.body} (${ago(n.at, now)})`);
85
+ }
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+
90
+ /** Markup for the panels. Hidden until the page opens one. */
91
+ export function shadeHtml(platform = "android") {
92
+ const ios = platform === "ios";
93
+ const panel = (part, label, inner) => `<div class="sf-panel" data-part="${part}" role="dialog" aria-label="${label}" tabindex="-1" hidden>${inner}<button type="button" class="sf-gear" data-part="settings-gear" aria-label="Settings">⚙ Settings</button><button type="button" class="sf-close" data-part="close">Close</button></div>`;
94
+ const list = `<div class="sf-notes" data-part="notification-list"></div><button type="button" class="sf-clear" data-part="clear-all">Clear all</button>`;
95
+ return `<div class="sf-grab" data-part="top-edge" aria-hidden="true"></div>`
96
+ + (ios ? panel("notification-center", "Notification Center", list) + panel("control-center", "Control Center", `<div class="sf-tiles" data-part="tiles"></div>`)
97
+ : panel("notification-shade", "Notification shade", `<div class="sf-tiles" data-part="tiles"></div>` + list));
98
+ }
99
+
100
+ export const shadeCss = [
101
+ `.sf-grab{position:absolute;left:0;right:0;top:0;height:18px;z-index:7}`,
102
+ `.sf-panel{position:absolute;inset:0;z-index:6;overflow:auto;padding:48px 14px 14px;background:#1b1e23f2;color:#fff;font:14px system-ui,sans-serif}.sf-panel[hidden]{display:none}`,
103
+ `.sf-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}`,
104
+ `.sf-tile{padding:10px 4px;border:0;border-radius:14px;background:#3a3d42;color:#fff;font:600 11px system-ui,sans-serif}.sf-tile[aria-pressed="true"]{background:#4da3ff;color:#001}`,
105
+ `.sf-row{position:relative}.sf-icon{flex:none;width:28px;height:28px;margin-right:10px;border-radius:8px;background:#4da3ff;color:#001;font:700 14px/28px system-ui,sans-serif;text-align:center}.sf-text{flex:1;min-width:0}.sf-dismiss{position:absolute;right:8px;top:8px;padding:2px 8px;border:1px solid #fff6;border-radius:999px;background:#1b1e23;color:#fff;font:11px system-ui,sans-serif;opacity:0;pointer-events:none}.sf-dismiss:focus{opacity:1;pointer-events:auto}.sf-note{display:flex;align-items:flex-start;width:100%;margin:6px 0;padding:10px 12px;border:0;border-radius:14px;background:#2c3036;color:#fff;text-align:left;font:13px system-ui,sans-serif}.sf-text b{display:block}.sf-note small{opacity:.7}.sf-group{margin:10px 0 0;font-weight:700;opacity:.8}`,
106
+ `.sf-clear,.sf-close{margin-top:8px;padding:8px 16px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff}`,
107
+ ].join("\n");
108
+
109
+ /** Browser script for the frame page: pulls down from the top open a panel; tiles and notifications act on the app's `__pnDevice`. Expects the markup from shadeHtml. */
110
+ export function shadeClientScript(iframeId = "app", platform = "android") {
111
+ return `(function(){
112
+ var TILES=${JSON.stringify(TILES)},tileStates=${tileStates.toString()},tilePatch=${tilePatch.toString()},groupByApp=${groupByApp.toString()},ago=${ago.toString()},panelFor=${panelFor.toString()},order=${JSON.stringify(order)},ANDROID_FIRST_PULL=${ANDROID_FIRST_PULL};
113
+ function ShadeError(c,m){var e=new Error(m);e.code=c;return e}
114
+ var P=${JSON.stringify(platform)},r=document.querySelector('[data-part="phone-body"]');if(!r)return;
115
+ var q=function(p){return r.querySelector('[data-part="'+p+'"]')},open="none",edge=q("top-edge");if(!edge)return;
116
+ function dev(){var f=document.getElementById(${JSON.stringify(iframeId)});var w=f&&f.contentWindow;return w&&w.__pnDevice?w.__pnDevice:null}
117
+ function partOf(o){return o==="control"?"control-center":o==="notifications"?"notification-center":"notification-shade"}
118
+ function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]})}
119
+ function draw(){var d=dev(),el=open==="none"?null:q(partOf(open));if(!el||!d)return;
120
+ var t=q("tiles");if(t&&el.contains(t)){t.innerHTML=tileStates(P,d.settings(),open!=="shade").map(function(x){return '<button type="button" class="sf-tile" data-tile="'+x.id+'" aria-pressed="'+x.on+'">'+esc(x.label)+(x.slider?' '+x.value+'%':'')+'</button>'}).join("")}
121
+ var l=q("notification-list");if(l&&el.contains(l)){var ns=d.notifications(),h="";(P==="ios"?groupByApp(ns):[{app:null,items:ns.slice().reverse()}]).forEach(function(g){if(g.app)h+='<p class="sf-group">'+esc(g.app)+'</p>';g.items.forEach(function(n){h+='<div class="sf-row"><button type="button" class="sf-note" data-note="'+esc(n.id)+'"><span class="sf-icon" aria-hidden="true">'+esc(String(n.app||"App").charAt(0).toUpperCase())+'</span><span class="sf-text"><b>'+esc(n.title)+'</b>'+esc(n.body)+' <small>'+ago(n.at)+'</small></span></button><button type="button" class="sf-dismiss" data-dismiss="'+esc(n.id)+'" aria-label="Dismiss '+esc(n.title)+'">Dismiss</button></div>'})});l.innerHTML=h||'<p>No notifications</p>'}}
122
+ var back=null;
123
+ function show(o){var was=open;if(was==="none"&&o!=="none")back=document.activeElement;open=o;["notification-shade","notification-center","control-center"].forEach(function(p){var e=q(p);if(e)e.hidden=true});if(o!=="none"){var e=q(partOf(o));if(e){e.hidden=false;draw();e.focus&&e.focus({preventScroll:true})}}else if(was!=="none"&&back){var b=back;back=null;b.focus&&b.focus({preventScroll:true})}}
124
+ function pull(x){var e=r.getBoundingClientRect();show(panelFor(P,x,e.width,open))}
125
+ var sy=null,sx=0;edge.addEventListener("mousedown",function(e){e.preventDefault()});edge.addEventListener("pointerdown",function(e){try{edge.setPointerCapture(e.pointerId)}catch(x){}sy=e.clientY;sx=e.clientX-r.getBoundingClientRect().left});
126
+ document.addEventListener("pointerup",function(e){if(sy!=null&&e.clientY-sy>40)pull(sx);sy=null});
127
+ document.addEventListener("keydown",function(e){if(e.key==="Escape"&&open!=="none")show("none")});
128
+ var closeY=null;r.addEventListener("pointerdown",function(e){if(open!=="none"&&!e.target.closest(".sf-tile,.sf-note,.sf-dismiss,.sf-clear,.sf-close,.sf-gear"))closeY=e.clientY});
129
+ document.addEventListener("pointerup",function(e){if(closeY!=null&&closeY-e.clientY>60)show("none");closeY=null});
130
+ r.addEventListener("click",function(e){if(swiped){swiped=false;return}if(e.target.closest(".sf-close,.sf-gear")){show("none");return}var d=dev();if(!d)return;var tl=e.target.closest(".sf-tile");
131
+ if(tl){var id=tl.getAttribute("data-tile");try{d.set(tilePatch(P,id,d.settings(),undefined))}catch(x){}draw();return}
132
+ if(e.target.closest(".sf-clear")){d.clearAll();draw();return}
133
+ var dm=e.target.closest(".sf-dismiss");if(dm){var did=dm.getAttribute("data-dismiss"),rows=[].slice.call(r.querySelectorAll(".sf-dismiss")),at=rows.indexOf(dm);d.dismiss(did);draw();var nx=r.querySelectorAll(".sf-dismiss")[at]||r.querySelectorAll(".sf-dismiss")[at-1],pn=r.querySelector('[data-part="'+partOf(open)+'"]');(nx||pn).focus({preventScroll:true});return}
134
+ var nb=e.target.closest(".sf-note");if(nb){var nid=nb.getAttribute("data-note");show("none");d.openNotification(nid)}});
135
+ var sw=null,swiped=false;r.addEventListener("pointerdown",function(e){var n=e.target.closest&&e.target.closest(".sf-note");sw=n?{n:n,x:e.clientX}:null});
136
+ document.addEventListener("pointerup",function(e){if(sw&&Math.abs(e.clientX-sw.x)>80){var d=dev();if(d){d.dismiss(sw.n.getAttribute("data-note"));draw();swiped=true;setTimeout(function(){swiped=false},100)}}sw=null});
137
+ var f=document.getElementById(${JSON.stringify(iframeId)});function wire(){var w=f&&f.contentWindow;if(w&&!w.__shipvoShadeHooked){w.__shipvoShadeHooked=true;w.addEventListener("pn:settings",draw);w.addEventListener("pn:status",draw)}}
138
+ if(f){f.addEventListener("load",wire);wire()}
139
+ setInterval(function(){if(open!=="none"&&!document.hidden)draw()},1000);
140
+ })();`;
141
+ }
package/update.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // Simulated OTA update lifecycle for the phone: idle, checking, downloading, ready, applied, reverted.
2
+ // Pure rules plus one injected fetch: no DOM, no timers. The server drives it from the MCP tools check_for_update and apply_update,
3
+ // and tells it how each boot went (a page error while the new bundle loads is a failed boot). Nothing real is installed.
4
+ // The pointer is checked the same way the runtime checks it: an ed25519 signature over the canonical JSON (sorted keys, no
5
+ // `signature` field), then the bundle's sha256. Signing and verification code is not changed: this only follows its format.
6
+ import { createPublicKey, verify, createHash } from "node:crypto";
7
+
8
+ /** Typed error: no_update or not_ready. */
9
+ export class UpdateError extends Error { constructor(code, message) { super(message); this.code = code; } }
10
+
11
+ export const UPDATE_STATES = ["idle", "checking", "downloading", "ready", "applied", "reverted"];
12
+ export const UPDATE_REASONS = ["verify_failed", "download_failed", "crash_loop"];
13
+ /** Failed boots of a new release before the phone goes back to the one it had. Same number as docs/spike-results.md. */
14
+ export const CRASH_LIMIT = 3;
15
+
16
+ /** `running` is { release, url }: the bundle the phone runs now (the first one is the app the session opened). */
17
+ export function createUpdate(running) {
18
+ return { state: "idle", reason: null, running, previous: null, pending: null, trial: null, blocked: [], seen: [], bundles: new Map(), log: [] };
19
+ }
20
+
21
+ /** A release as a number: 13 and "r13" are 13; anything else is unknown (null). */
22
+ const asNumber = (r) => typeof r === "number" ? r : typeof r === "string" && /(\d+)$/.test(r) ? Number(r.match(/(\d+)$/)[1]) : null;
23
+ const set = (st, state, reason = null, extra = {}) => {
24
+ st.state = state; st.reason = reason;
25
+ st.log.push({ at: Date.now(), name: "update_state", state, release: st.running.release, reason, ...extra, source: "simulated" });
26
+ };
27
+
28
+ /** What tools and tests read. */
29
+ export function updateView(st) {
30
+ return { state: st.state, release: st.running.release, pending_release: st.pending?.release ?? null, reason: st.reason, blocked: [...st.blocked] };
31
+ }
32
+
33
+ /** Same bytes the signer used: sorted keys, no whitespace, no `signature`. */
34
+ export function canonical(value) {
35
+ const strip = (v) => Array.isArray(v) ? v.map(strip) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, strip(v[k])])) : v;
36
+ const { signature: _s, ...rest } = value ?? {};
37
+ return JSON.stringify(strip(rest));
38
+ }
39
+
40
+ const SPKI_ED25519 = Buffer.from("302a300506032b6570032100", "hex");
41
+ /** True when `pointer.signature` is a valid ed25519 signature by the base64 public key. Never throws. */
42
+ export function verifyPointer(pointer, publicKeyB64) {
43
+ try {
44
+ const key = createPublicKey({ key: Buffer.concat([SPKI_ED25519, Buffer.from(publicKeyB64, "base64")]), format: "der", type: "spki" });
45
+ return verify(null, Buffer.from(canonical(pointer)), key, Buffer.from(pointer.signature, "base64"));
46
+ } catch { return false; }
47
+ }
48
+
49
+ /**
50
+ * Ask the update source for the latest release: GET <url>/pointer.json, check it, download its web bundle, check the hash.
51
+ * Ends in `ready` (a new release is held), or `idle` with a reason (verify_failed, download_failed).
52
+ * Throws no_update when there is nothing newer (the state goes back to what it was). `fetchFn` is fetch-like.
53
+ */
54
+ export async function checkForUpdate(st, { url, publicKey, fetchFn = fetch }) {
55
+ if (!url || !publicKey) throw new UpdateError("no_update", "no update source: start the simulator with --update-url and --update-key (or PN_UPDATE_URL and PN_UPDATE_KEY)");
56
+ const was = { state: st.state, reason: st.reason };
57
+ set(st, "checking");
58
+ const fail = (reason, error) => { set(st, "idle", reason, { error }); return updateView(st); };
59
+ let pointer;
60
+ try {
61
+ const r = await fetchFn(`${url.replace(/\/$/, "")}/pointer.json`);
62
+ if (!r.ok) return fail("download_failed", `pointer.json answered ${r.status}`);
63
+ pointer = await r.json();
64
+ } catch (e) { return fail("download_failed", `could not read pointer.json: ${String(e?.message ?? e).split("\n")[0]}`); }
65
+ if (!pointer || typeof pointer !== "object" || !verifyPointer(pointer, publicKey)) return fail("verify_failed", "the release pointer has no valid signature for this app's key");
66
+ const n = pointer.release;
67
+ if (!Number.isInteger(n) || !pointer.web?.base || !pointer.web?.entry || !/^[0-9a-f]{64}$/.test(String(pointer.web.sha256))) return fail("verify_failed", "the signed pointer has no usable web bundle");
68
+ const known = [st.running.release, st.previous?.release, ...st.seen, ...st.blocked].map(asNumber).filter(Number.isInteger);
69
+ if (known.length && n <= Math.max(...known)) { st.state = was.state; st.reason = was.reason; throw new UpdateError("no_update", `release ${n} is not newer than the newest release this session knows (${Math.max(...known)})`); }
70
+ if (st.blocked.includes(n)) { st.state = was.state; st.reason = was.reason; throw new UpdateError("no_update", `release ${n} crashed on this phone and is blocked`); }
71
+ set(st, "downloading", null, { target: n });
72
+ let bytes;
73
+ try {
74
+ const entry = new URL(`${String(pointer.web.base).replace(/\/$/, "")}/${pointer.web.entry}`, `${url.replace(/\/$/, "")}/`);
75
+ if (!/^https?:$/.test(entry.protocol)) return fail("download_failed", "the bundle URL is not http(s)");
76
+ const r = await fetchFn(entry.href);
77
+ if (!r.ok) return fail("download_failed", `the bundle answered ${r.status}`);
78
+ bytes = Buffer.from(await r.arrayBuffer());
79
+ } catch (e) { return fail("download_failed", `could not download the bundle: ${String(e?.message ?? e).split("\n")[0]}`); }
80
+ if (createHash("sha256").update(bytes).digest("hex") !== pointer.web.sha256) return fail("verify_failed", "the bundle does not match the signed sha256");
81
+ st.bundles.set(n, bytes); st.pending = { release: n };
82
+ set(st, "ready", null, { target: n });
83
+ return updateView(st);
84
+ }
85
+
86
+ /** Make the held release the running one on the next boot. Throws not_ready unless the state is `ready`. `urlFor(release)` is where the server serves it. */
87
+ export function applyUpdate(st, urlFor) {
88
+ if (st.state !== "ready" || !st.pending) throw new UpdateError("not_ready", `no update is ready (state: ${st.state}). Call check_for_update first.`);
89
+ st.previous = st.running; st.running = { release: st.pending.release, url: urlFor(st.pending.release) }; st.pending = null;
90
+ st.seen.push(st.running.release);
91
+ st.trial = { release: st.running.release, fails: 0 };
92
+ set(st, "applied");
93
+ return st.running;
94
+ }
95
+
96
+ /**
97
+ * Tell the rules how a boot went. A new release must boot without a page error to count as good. After CRASH_LIMIT failed boots in a row
98
+ * it is dropped and blocked and the phone goes back: { revert: true, url } with the previous bundle's url.
99
+ */
100
+ export function bootDone(st, crashed) {
101
+ if (!st.trial) return { revert: false };
102
+ if (!crashed) { st.trial = null; return { revert: false }; }
103
+ if (++st.trial.fails < CRASH_LIMIT) return { revert: false };
104
+ const bad = st.trial.release;
105
+ st.blocked.push(bad); st.bundles.delete(bad); st.trial = null; st.running = st.previous; st.previous = null;
106
+ set(st, "reverted", "crash_loop", { reverted: bad });
107
+ return { revert: true, url: st.running.url };
108
+ }
109
+
110
+ const WORDS = { ready: (r) => `update ready: release ${r}`, applied: (r) => `update applied: release ${r}`, reverted: (r, why) => `update reverted${why ? ` (${why})` : ""}: back on release ${r}` };
111
+ /** The banner text for ready, applied and reverted; null for the other states. Starts with "Simulated". */
112
+ export function bannerText(st) {
113
+ const f = WORDS[st.state]; if (!f) return null;
114
+ return `Simulated: ${f(st.state === "ready" ? st.pending?.release : st.running.release, st.reason)}`;
115
+ }
116
+
117
+ /** Banner markup for the phone frame (both skins). Hidden until the page gets a "pn:update" message. */
118
+ export function updateBannerHtml() {
119
+ return `<div class="sf-update" data-part="update-banner" role="status" aria-live="polite" aria-label="Simulated update status" hidden><span data-part="update-banner-text"></span></div>`;
120
+ }
121
+ export const updateCss = `.sf-update{position:absolute;left:10px;right:10px;top:48px;z-index:4;padding:8px 12px;border-radius:12px;background:#1d2733;color:#fff;font:600 13px system-ui,sans-serif;box-shadow:0 4px 16px #0006}.sf-update[hidden]{display:none}`;
122
+ /** Browser script: shows the banner for { type: "pn:update", text } messages (null text hides it). */
123
+ export function updateClientScript() {
124
+ return `(function(){var b=document.querySelector('[data-part="update-banner"]');if(!b)return;var t=b.querySelector('[data-part="update-banner-text"]');
125
+ window.addEventListener("message",function(e){var d=e.data;if(!d||d.type!=="pn:update")return;if(typeof d.text==="string"&&d.text.indexOf("Simulated")===0){t.textContent=d.text.slice(0,120);b.hidden=false}else b.hidden=true})})();`;
126
+ }
package/ws-lite.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // A small server-side WebSocket (RFC 6455) on `node:http` upgrade: JSON text messages, nothing else.
2
+ import { createHash } from "node:crypto";
3
+
4
+ const MAX = 1 << 20;
5
+ function frame(op, payload) {
6
+ const n = payload.length;
7
+ const head = n < 126 ? Buffer.from([0x80 | op, n]) : n < 65536 ? Buffer.from([0x80 | op, 126, n >> 8, n & 255]) : (() => { const h = Buffer.alloc(10); h[0] = 0x80 | op; h[1] = 127; h.writeBigUInt64BE(BigInt(n), 2); return h; })();
8
+ return Buffer.concat([head, payload]);
9
+ }
10
+
11
+ /** Completes the handshake. Returns { send(text), close() } or undefined when the request is not a WebSocket upgrade. */
12
+ export function acceptWebSocket(req, socket, onText, onClose) {
13
+ const key = req.headers["sec-websocket-key"];
14
+ if (typeof key !== "string" || req.headers.upgrade?.toLowerCase() !== "websocket") { socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); return undefined; }
15
+ const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
16
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
17
+ let buf = Buffer.alloc(0), closed = false;
18
+ const done = () => { if (!closed) { closed = true; onClose?.(); } };
19
+ const shut = () => { if (!closed) socket.write(frame(8, Buffer.alloc(0))); socket.end(); done(); };
20
+ socket.on("close", done); socket.on("error", () => { socket.destroy(); done(); });
21
+ socket.on("data", (chunk) => {
22
+ buf = Buffer.concat([buf, chunk]);
23
+ for (;;) {
24
+ if (buf.length < 2) return;
25
+ const op = buf[0] & 15, fin = (buf[0] & 128) !== 0, masked = (buf[1] & 128) !== 0;
26
+ let len = buf[1] & 127, off = 2;
27
+ if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; }
28
+ else if (len === 127) { if (buf.length < 10) return; const big = buf.readBigUInt64BE(2); if (big > BigInt(MAX)) return shut(); len = Number(big); off = 10; }
29
+ if (!masked || len > MAX || !fin) return shut(); // clients must mask; fragments are not used here
30
+ if (buf.length < off + 4 + len) return;
31
+ const mask = buf.subarray(off, off + 4), data = Buffer.from(buf.subarray(off + 4, off + 4 + len));
32
+ for (let i = 0; i < len; i++) data[i] ^= mask[i & 3];
33
+ buf = buf.subarray(off + 4 + len);
34
+ if (op === 8) return shut();
35
+ if (op === 9) { socket.write(frame(10, data)); continue; }
36
+ if (op === 1) onText(data.toString("utf8"));
37
+ }
38
+ });
39
+ return { send: (t) => { if (!closed) socket.write(frame(1, Buffer.from(t))); }, close: shut };
40
+ }