@oversk7/pi-web-dsh-style 1.3.3 → 1.3.4

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 CHANGED
@@ -40,6 +40,10 @@ pi --web
40
40
 
41
41
  You can also enter `/web` in an existing Pi terminal. The page opens on your computer; the default local address is `http://127.0.0.1:18789`. To stop the service, run `/web --stop` or exit the host Pi process. Closing the browser tab alone does not stop it.
42
42
 
43
+ Each visible page subscribes to its selected session, receives an initial snapshot, and then receives only changes. Hidden tabs pause live updates and notifications; returning to the page synchronizes the latest state. Pi and its tools continue running while no page is viewing them.
44
+
45
+ Switching back to a newly created or previously loaded session shows its cached content immediately and keeps the composer available while refreshing. Repeated opens share pending requests, and sessions with a running process return their current snapshot without waiting for RPC refreshes.
46
+
43
47
  The interface starts in Chinese. To switch to English, open **设置 (Settings) → 外观 (Appearance) → 界面语言 (Interface language) → English**. Your choice is saved in this browser.
44
48
 
45
49
  ### Phone and remote access
@@ -85,6 +89,10 @@ pi --web
85
89
 
86
90
  也可以在已有的 Pi 终端中输入 `/web`。网页会在本机打开,默认地址为 `http://127.0.0.1:18789`。执行 `/web --stop` 或退出宿主 Pi 可停止服务;仅关闭浏览器标签页不会停止服务。
87
91
 
92
+ 每个可见网页只订阅正在查看的会话,首次接收快照,随后只传输变化内容。隐藏标签页会暂停实时更新和通知,返回网页后同步最新状态。没有网页查看时,Pi 和工具仍会继续运行。
93
+
94
+ 切回新建或已加载的会话时,立即显示缓存内容,后台刷新期间仍可编辑输入。重复打开会复用待完成请求;已有运行进程的会话直接返回当前快照,避免等待 RPC 刷新。
95
+
88
96
  界面默认使用中文。打开 **设置 → 外观 → 界面语言**,即可一键切换中文或 English;选择会保存在当前浏览器。
89
97
 
90
98
  ### 手机与远程访问
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oversk7/pi-web-dsh-style",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "An unofficial DSH-styled Windows browser UI for pi coding agent with configurable LAN and native frp relay access",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server.ts CHANGED
@@ -183,6 +183,7 @@ interface RuntimeSession {
183
183
  streaming: boolean;
184
184
  needsAttention: boolean;
185
185
  starting?: Promise<void>;
186
+ openRefresh?: Promise<void>;
186
187
  modelRefresh?: Promise<void>;
187
188
  commandRefresh?: Promise<void>;
188
189
  lastUsed: number;
@@ -620,7 +621,14 @@ function firstMessageTitle(session: SessionInfo): string {
620
621
  // ---------------------------------------------------------------------------
621
622
 
622
623
  function broadcastEvent(event: Json): void {
623
- for (const client of [...sseClients]) sendSseEvent(client, event);
624
+ for (const client of [...sseClients]) {
625
+ if (event.sessionId && client.sessionId !== event.sessionId) continue;
626
+ sendSseEvent(client, event);
627
+ }
628
+ }
629
+
630
+ function hasSessionSubscribers(sessionId: string): boolean {
631
+ return [...sseClients].some((client) => !client.closed && client.sessionId === sessionId);
624
632
  }
625
633
 
626
634
  function sessionSummary(record: RuntimeSession): Json {
@@ -684,12 +692,17 @@ function setPendingQueue(record: RuntimeSession, queue: RpcQueueSnapshot): void
684
692
  const snapshotScheduler = createSnapshotScheduler<RuntimeSession>(emitSessionSnapshot);
685
693
 
686
694
  function broadcastSessionSnapshot(record: RuntimeSession): void {
687
- if (shuttingDown) return;
695
+ if (shuttingDown || !hasSessionSubscribers(record.id)) return;
688
696
  snapshotScheduler.schedule(record.id, record, !record.streaming || record.dialogs.length > 0);
689
697
  }
690
698
 
691
699
  function emitSessionSnapshot(record: RuntimeSession): void {
692
- broadcastEvent({
700
+ if (shuttingDown || !hasSessionSubscribers(record.id)) return;
701
+ broadcastEvent(sessionSnapshot(record));
702
+ }
703
+
704
+ function sessionSnapshot(record: RuntimeSession): Json {
705
+ return {
693
706
  type: "snapshot",
694
707
  sessionId: record.id,
695
708
  session: sessionSummary(record),
@@ -703,7 +716,7 @@ function emitSessionSnapshot(record: RuntimeSession): void {
703
716
  messages: browserMessages(snapshotMessages(record)),
704
717
  pendingQueue: pendingQueueSnapshot(record),
705
718
  dialogs: record.dialogs,
706
- });
719
+ };
707
720
  }
708
721
 
709
722
  function serializeWorkspaces(): Json[] {
@@ -1473,6 +1486,19 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
1473
1486
  return task;
1474
1487
  }
1475
1488
 
1489
+ function refreshOpenedSession(record: RuntimeSession): void {
1490
+ if (record.openRefresh || record.streaming || record.starting) return;
1491
+ record.openRefresh = (async () => {
1492
+ await Promise.allSettled([refreshState(record), refreshMessages(record)]);
1493
+ if (record.models.length === 0) await refreshModels(record).catch(() => {});
1494
+ applyCachedModelInfo(record);
1495
+ await refreshStats(record);
1496
+ broadcastSessionSnapshot(record);
1497
+ })().catch((error) => {
1498
+ console.error("[pi-web] open session refresh failed:", error);
1499
+ }).finally(() => { record.openRefresh = undefined; });
1500
+ }
1501
+
1476
1502
  async function openSessionRecord(record: RuntimeSession): Promise<void> {
1477
1503
  record.lastUsed = Date.now();
1478
1504
  applyCachedCommands(record);
@@ -1535,6 +1561,14 @@ async function openSessionRecord(record: RuntimeSession): Promise<void> {
1535
1561
  return;
1536
1562
  }
1537
1563
 
1564
+ if (record.proc && record.state) {
1565
+ // Live events keep this snapshot current; browsing must not wait for RPC.
1566
+ applyCachedModelInfo(record);
1567
+ refreshOpenedSession(record);
1568
+ broadcastSessionSnapshot(record);
1569
+ return;
1570
+ }
1571
+
1538
1572
  if (!record.proc) {
1539
1573
  await ensureProcess(record);
1540
1574
  } else {
@@ -2593,6 +2627,9 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2593
2627
  }
2594
2628
 
2595
2629
  if (method === "GET" && path === "/api/events") {
2630
+ const sessionId = url.searchParams.get("sessionId") || undefined;
2631
+ const subscribedSession = sessionId ? sessions.get(sessionId) : undefined;
2632
+ if (sessionId && !subscribedSession) return sendJson(res, 404, { error: "session not found" });
2596
2633
  res.writeHead(200, {
2597
2634
  "content-type": "text/event-stream; charset=utf-8",
2598
2635
  "cache-control": "no-store",
@@ -2604,10 +2641,13 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2604
2641
  const removeClient = () => {
2605
2642
  closeSseClient(client);
2606
2643
  sseClients.delete(client);
2644
+ if (sessionId && !hasSessionSubscribers(sessionId)) snapshotScheduler.cancel(sessionId);
2607
2645
  };
2608
2646
  client = createSseClient(res, removeClient);
2647
+ client.sessionId = sessionId;
2609
2648
  sseClients.add(client);
2610
2649
  req.on("close", removeClient);
2650
+ if (subscribedSession) sendSseEvent(client, sessionSnapshot(subscribedSession));
2611
2651
  return true;
2612
2652
  }
2613
2653
 
package/sse-channel.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { canonicalizeJson, diffSessionSnapshots, type JsonValue } from "./web/session-sync.js";
2
+
1
3
  export type SseEvent = Record<string, unknown>;
2
4
 
3
5
  // Coalesce before serializing full histories, so a burst of tokens does not
@@ -44,6 +46,9 @@ export interface SseWritable {
44
46
 
45
47
  export interface SseClient {
46
48
  res: SseWritable;
49
+ sessionId?: string;
50
+ snapshotBaseline?: Record<string, JsonValue>;
51
+ snapshotRevision: number;
47
52
  waitingForDrain: boolean;
48
53
  pendingEvents: Map<string, SseEvent>;
49
54
  closed: boolean;
@@ -59,6 +64,7 @@ function eventKey(event: SseEvent): string {
59
64
  export function createSseClient(res: SseWritable, onError: () => void): SseClient {
60
65
  return {
61
66
  res,
67
+ snapshotRevision: 0,
62
68
  waitingForDrain: false,
63
69
  pendingEvents: new Map(),
64
70
  closed: false,
@@ -68,7 +74,24 @@ export function createSseClient(res: SseWritable, onError: () => void): SseClien
68
74
 
69
75
  function writeEvent(client: SseClient, event: SseEvent): boolean {
70
76
  try {
71
- const accepted = client.res.write(`data: ${JSON.stringify(event)}\n\n`);
77
+ let outgoing = event;
78
+ const snapshot = event.type === "snapshot" ? event as Record<string, JsonValue> : undefined;
79
+ if (snapshot) {
80
+ const revision = client.snapshotRevision + 1;
81
+ if (client.snapshotBaseline && client.snapshotBaseline.sessionId === snapshot.sessionId) {
82
+ const patches = diffSessionSnapshots(client.snapshotBaseline, snapshot);
83
+ if (patches.length === 0) return true;
84
+ outgoing = { type: "session_patch", sessionId: snapshot.sessionId, baseRevision: client.snapshotRevision, revision, patches };
85
+ } else {
86
+ outgoing = { ...snapshot, revision };
87
+ }
88
+ }
89
+ const accepted = client.res.write(`data: ${JSON.stringify(outgoing)}\n\n`);
90
+ // A false return still means Node accepted the bytes into its buffer.
91
+ if (snapshot) {
92
+ client.snapshotBaseline = snapshot;
93
+ client.snapshotRevision++;
94
+ }
72
95
  if (accepted) return true;
73
96
  client.waitingForDrain = true;
74
97
  client.res.once("drain", () => flushPendingEvents(client));
@@ -93,7 +116,18 @@ function flushPendingEvents(client: SseClient): void {
93
116
 
94
117
  export function sendSseEvent(client: SseClient, event: SseEvent): void {
95
118
  if (client.closed) return;
119
+ if (event.type === "snapshot") {
120
+ try {
121
+ const { revision: _revision, ...body } = event;
122
+ event = canonicalizeJson(body) as Record<string, JsonValue>;
123
+ } catch {
124
+ closeSseClient(client);
125
+ client.onError();
126
+ return;
127
+ }
128
+ }
96
129
  if (client.waitingForDrain) {
130
+ // Snapshot entries are detached full targets, diffed only when written.
97
131
  // Keep only the newest event of each kind while the browser catches up.
98
132
  // This bounds memory even when snapshots contain a long conversation.
99
133
  client.pendingEvents.set(eventKey(event), event);
@@ -106,4 +140,6 @@ export function closeSseClient(client: SseClient): void {
106
140
  client.closed = true;
107
141
  client.waitingForDrain = false;
108
142
  client.pendingEvents.clear();
143
+ client.snapshotBaseline = undefined;
144
+ client.snapshotRevision = 0;
109
145
  }
package/web/app.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { markdown as renderMarkdown } from "./assets/markdown.js";
4
4
  import { observeMarkdownDiagrams } from "./markdown-diagrams.js";
5
+ import { applySessionPatches } from "./session-sync.js";
5
6
 
6
7
  observeMarkdownDiagrams();
7
8
  import { webSlashCommands, unsupportedSlashCommands, parseSlashCommand } from "./slash-commands.js";
@@ -20,6 +21,8 @@ const S = {
20
21
  currentWorkspaceId: null,
21
22
  currentSessionId: null,
22
23
  snapshots: new Map(),
24
+ loadedSessions: new Set(),
25
+ sessionOpens: new Map(),
23
26
  newSessionSnapshot: null,
24
27
  newSessionPreferenceWorkspaceId: null,
25
28
  collapsed: new Set(),
@@ -108,8 +111,8 @@ async function api(path, options = {}) {
108
111
  return data;
109
112
  }
110
113
 
111
- function post(path, body) {
112
- return api(path, { method: "POST", body: JSON.stringify(body || {}) });
114
+ function post(path, body, options = {}) {
115
+ return api(path, { ...options, method: "POST", body: JSON.stringify(body || {}) });
113
116
  }
114
117
 
115
118
  function timeLabel(iso) {
@@ -1456,6 +1459,7 @@ function renderActive() {
1456
1459
  }
1457
1460
 
1458
1461
  function renderConversation() {
1462
+ syncEventSubscription();
1459
1463
  const mount = $("#conversationMount");
1460
1464
  if (!mount) return;
1461
1465
  const previousInput = $("#composerInput");
@@ -3127,7 +3131,14 @@ async function openSession(id, opts = {}) {
3127
3131
  if (!opts.skipRemember) rememberCurrentDraft();
3128
3132
  const generation = ++S.openGeneration;
3129
3133
  S.currentSessionId = id;
3130
- S.loadingSession = opts.initialSnapshot ? null : id;
3134
+ if (opts.initialSnapshot) {
3135
+ S.snapshots.set(id, opts.initialSnapshot);
3136
+ S.loadedSessions.add(id);
3137
+ }
3138
+ const before = S.snapshots.get(id);
3139
+ const cached = S.loadedSessions.has(id) && before?.session?.id === id && !before.error;
3140
+ S.loadingSession = cached ? null : id;
3141
+ if (before?.session?.workspaceId) S.currentWorkspaceId = before.session.workspaceId;
3131
3142
  if (!opts.silent) {
3132
3143
  const key = draftKey(id);
3133
3144
  const savedDraft = S.drafts.get(key) || "";
@@ -3142,14 +3153,24 @@ async function openSession(id, opts = {}) {
3142
3153
  }
3143
3154
  renderSidebar();
3144
3155
  renderConversation();
3156
+ let request = S.sessionOpens.get(id);
3145
3157
  try {
3146
- const data = opts.initialSnapshot ?? await post(`/api/sessions/${encodeURIComponent(id)}/open`);
3147
- S.snapshots.set(id, data);
3158
+ if (!opts.initialSnapshot && !request) {
3159
+ const promise = post(`/api/sessions/${encodeURIComponent(id)}/open`, {}, { signal: AbortSignal.timeout(15_000) })
3160
+ .finally(() => { S.sessionOpens.delete(id); });
3161
+ request = { promise, snapshot: before };
3162
+ S.sessionOpens.set(id, request);
3163
+ }
3164
+ const data = opts.initialSnapshot ?? await request.promise;
3165
+ if (S.snapshots.get(id) === (opts.initialSnapshot ? before : request.snapshot)) S.snapshots.set(id, data);
3166
+ S.loadedSessions.add(id);
3148
3167
  if (generation !== S.openGeneration) return;
3149
3168
  const ws = data.session?.workspaceId;
3150
3169
  if (ws) S.currentWorkspaceId = ws;
3151
3170
  } catch (error) {
3152
- S.snapshots.set(id, { ...snapshotFor(id), session: { id, title: "会话", status: "error" }, error: String(error) });
3171
+ if (!cached && S.snapshots.get(id) === (request ? request.snapshot : before)) {
3172
+ S.snapshots.set(id, { ...snapshotFor(id), session: { id, title: "会话", status: "error" }, error: String(error) });
3173
+ }
3153
3174
  } finally {
3154
3175
  if (generation === S.openGeneration) {
3155
3176
  S.loadingSession = null;
@@ -4869,6 +4890,7 @@ async function onClick(ev) {
4869
4890
  ];
4870
4891
  await Promise.all(orphanedAttachments.map((attachment) => deleteAttachment(attachment.id)));
4871
4892
  S.snapshots.delete(sessionId);
4893
+ S.loadedSessions.delete(sessionId);
4872
4894
  S.drafts.delete(sessionDraftKey);
4873
4895
  S.failedDrafts.delete(sessionId);
4874
4896
  S.attachmentDrafts.delete(sessionDraftKey);
@@ -5124,7 +5146,8 @@ let loginPromise;
5124
5146
 
5125
5147
  function requireLogin() {
5126
5148
  if (loginPromise) return loginPromise;
5127
- eventSource?.close();
5149
+ let authenticated = false;
5150
+ disconnectEvents();
5128
5151
  loginPromise = (async () => {
5129
5152
  let { passwordConfigured } = await api("/api/auth");
5130
5153
  const dialog = el(`<div class="pi-loginBackdrop">
@@ -5163,6 +5186,7 @@ function requireLogin() {
5163
5186
  input.value = "";
5164
5187
  dialog.remove();
5165
5188
  root.inert = false;
5189
+ authenticated = true;
5166
5190
  resolve();
5167
5191
  } catch (error) {
5168
5192
  message.textContent = error.message || String(error);
@@ -5172,8 +5196,10 @@ function requireLogin() {
5172
5196
  }
5173
5197
  };
5174
5198
  });
5175
- if (eventSource) connectEvents();
5176
- })().finally(() => { loginPromise = null; });
5199
+ })().finally(() => {
5200
+ loginPromise = null;
5201
+ if (authenticated && !document.hidden) connectEvents();
5202
+ });
5177
5203
  return loginPromise;
5178
5204
  }
5179
5205
 
@@ -5182,6 +5208,17 @@ let eventWatchdog;
5182
5208
  let lastEventAt = 0;
5183
5209
  let resyncPending;
5184
5210
 
5211
+ function disconnectEvents() {
5212
+ eventSource?.close();
5213
+ eventSource = null;
5214
+ clearInterval(eventWatchdog);
5215
+ }
5216
+
5217
+ function syncEventSubscription() {
5218
+ if (document.hidden || !eventSource || loginPromise) return;
5219
+ if (eventSource.sessionId !== (S.currentSessionId || null)) connectEvents(false);
5220
+ }
5221
+
5185
5222
  async function resyncAfterReconnect() {
5186
5223
  if (resyncPending) return resyncPending;
5187
5224
  resyncPending = (async () => {
@@ -5200,7 +5237,7 @@ async function resyncAfterReconnect() {
5200
5237
  const data = await api(`/api/sessions/${encodeURIComponent(id)}/open`, {
5201
5238
  method: "POST", body: "{}", signal: AbortSignal.timeout(15_000),
5202
5239
  });
5203
- if (S.snapshots.get(id) === before) S.snapshots.set(id, data);
5240
+ if (id === S.currentSessionId && generation === S.openGeneration && S.snapshots.get(id) === before) S.snapshots.set(id, data);
5204
5241
  } else if (id && generation === S.openGeneration) {
5205
5242
  rememberCurrentDraft();
5206
5243
  ++S.openGeneration;
@@ -5215,21 +5252,31 @@ async function resyncAfterReconnect() {
5215
5252
  return resyncPending;
5216
5253
  }
5217
5254
 
5218
- function connectEvents() {
5219
- eventSource?.close();
5220
- const es = new EventSource("/api/events");
5255
+ function connectEvents(resync = true) {
5256
+ disconnectEvents();
5257
+ if (document.hidden || loginPromise) return;
5258
+ const sessionId = S.currentSessionId || null;
5259
+ const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
5260
+ const es = new EventSource(`/api/events${query}`);
5261
+ es.sessionId = sessionId;
5221
5262
  eventSource = es;
5263
+ let streamedSnapshot;
5264
+ let streamRevision = 0;
5265
+ let opened = false;
5222
5266
  lastEventAt = Date.now();
5223
- clearInterval(eventWatchdog);
5224
5267
  // Heartbeats arrive every 25s, including when the model is idle. Some mobile
5225
5268
  // networks leave a dead stream open without notifying EventSource.
5226
5269
  eventWatchdog = setInterval(() => {
5227
- if (document.hidden || loginPromise || eventSource !== es) return;
5270
+ if (eventSource !== es || document.hidden || loginPromise) return;
5228
5271
  if (Date.now() - lastEventAt > 60_000) connectEvents();
5229
5272
  }, 5_000);
5230
5273
  es.onopen = () => {
5274
+ if (eventSource !== es) return;
5275
+ streamedSnapshot = undefined;
5276
+ streamRevision = 0;
5231
5277
  lastEventAt = Date.now();
5232
- void resyncAfterReconnect();
5278
+ if (resync || opened) void resyncAfterReconnect();
5279
+ opened = true;
5233
5280
  };
5234
5281
  es.onmessage = (ev) => {
5235
5282
  if (eventSource !== es) return;
@@ -5265,9 +5312,26 @@ function connectEvents() {
5265
5312
  }
5266
5313
  return;
5267
5314
  }
5268
- if (event.type === "snapshot") {
5269
- if (!event.sessionId) return;
5270
- S.snapshots.set(event.sessionId, { ...snapshotFor(event.sessionId), ...event });
5315
+ if (event.type === "snapshot" || event.type === "session_patch") {
5316
+ if (!event.sessionId || event.sessionId !== es.sessionId || event.sessionId !== S.currentSessionId) return;
5317
+ if (event.type === "snapshot") {
5318
+ const { revision, ...snapshot } = event;
5319
+ streamedSnapshot = snapshot;
5320
+ streamRevision = revision;
5321
+ } else {
5322
+ if (!streamedSnapshot || event.baseRevision !== streamRevision || event.revision !== streamRevision + 1) {
5323
+ connectEvents(false);
5324
+ return;
5325
+ }
5326
+ try {
5327
+ streamedSnapshot = applySessionPatches(streamedSnapshot, event.patches);
5328
+ } catch {
5329
+ connectEvents(false);
5330
+ return;
5331
+ }
5332
+ streamRevision = event.revision;
5333
+ }
5334
+ S.snapshots.set(event.sessionId, { ...snapshotFor(event.sessionId), error: undefined, ...streamedSnapshot });
5271
5335
  const current = event.sessionId === S.currentSessionId;
5272
5336
  // Settings do not depend on session snapshots. Rebuilding the panel on
5273
5337
  // every streamed chunk replaces controls between pointerdown and click.
@@ -5300,15 +5364,18 @@ function connectEvents() {
5300
5364
  // ignore malformed event
5301
5365
  }
5302
5366
  };
5303
- es.onerror = () => { void resyncAfterReconnect(); };
5367
+ es.onerror = () => {
5368
+ if (eventSource === es && !document.hidden) void resyncAfterReconnect();
5369
+ };
5304
5370
  }
5305
5371
 
5306
5372
  window.addEventListener("resize", () => { updateViewport(); applyFrameLayout(); });
5307
5373
  window.visualViewport?.addEventListener("resize", updateViewport);
5308
5374
  window.visualViewport?.addEventListener("scroll", updateViewport);
5309
- window.addEventListener("online", () => { if (eventSource && !loginPromise) connectEvents(); });
5375
+ window.addEventListener("online", () => { if (!document.hidden && !loginPromise) connectEvents(); });
5310
5376
  document.addEventListener("visibilitychange", () => {
5311
- if (!document.hidden && eventSource && !loginPromise) connectEvents();
5377
+ if (document.hidden) disconnectEvents();
5378
+ else if (!loginPromise) connectEvents();
5312
5379
  });
5313
5380
  setLanguage(getLanguage());
5314
5381
  observeUi();
@@ -0,0 +1,12 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
2
+ export type SessionPatch =
3
+ | { op: "set"; path: (string | number)[]; value: JsonValue }
4
+ | { op: "delete"; path: (string | number)[] }
5
+ | { op: "append"; path: (string | number)[]; value: string }
6
+ | { op: "truncate"; path: (string | number)[]; length: number };
7
+ /** Detached JSON normalization: undefined object fields disappear; array holes become null. */
8
+ export function canonicalizeJson(value: unknown): JsonValue | undefined;
9
+ /** Compare canonical JSON, emitting string suffixes and changes to individual paths. */
10
+ export function diffSessionSnapshots(previous: JsonValue, next: JsonValue): SessionPatch[];
11
+ /** Immutable application with structural sharing; invalid or unsafe paths throw. */
12
+ export function applySessionPatches<T extends JsonValue>(snapshot: T, patches: readonly SessionPatch[]): T;
@@ -0,0 +1,105 @@
1
+ // Paths contain object keys or array indices. Operations are set, delete (object
2
+ // field), append (string suffix), and truncate (array length). A root set resets
3
+ // structure. Paths traverse only own properties, including ordinary JSON keys
4
+ // such as __proto__; assignments cannot invoke prototype setters.
5
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
6
+ function setOwn(value, key, item) {
7
+ Object.defineProperty(value, key, { value: item, enumerable: true, writable: true, configurable: true });
8
+ }
9
+
10
+ export function canonicalizeJson(value) {
11
+ const ancestors = new Set();
12
+ function visit(input, key = "") {
13
+ if (input && typeof input.toJSON === "function") input = input.toJSON(key);
14
+ if (input === null || typeof input === "string" || typeof input === "boolean") return input;
15
+ if (typeof input === "number") return Number.isFinite(input) ? input : null;
16
+ if (typeof input === "bigint") throw new TypeError("BigInt is not JSON data");
17
+ if (typeof input !== "object") return undefined;
18
+ if (ancestors.has(input)) throw new TypeError("Circular JSON data");
19
+ ancestors.add(input);
20
+ let result;
21
+ if (Array.isArray(input)) {
22
+ result = Array.from({ length: input.length }, (_, i) => visit(input[i], String(i)) ?? null);
23
+ } else {
24
+ result = {};
25
+ for (const key of Object.keys(input)) {
26
+ const item = visit(input[key], key);
27
+ if (item !== undefined) setOwn(result, key, item);
28
+ }
29
+ }
30
+ ancestors.delete(input);
31
+ return result;
32
+ }
33
+ return visit(value);
34
+ }
35
+
36
+ // Compare detached canonical JSON without serializing unchanged history strings.
37
+ export function diffSessionSnapshots(previous, next) {
38
+ const patches = [];
39
+ function walk(before, after, path) {
40
+ if (before === after) return;
41
+ if (typeof before === "string" && typeof after === "string" && after.startsWith(before)) {
42
+ patches.push({ op: "append", path, value: after.slice(before.length) });
43
+ } else if (Array.isArray(before) && Array.isArray(after)) {
44
+ const common = Math.min(before.length, after.length);
45
+ for (let i = 0; i < common; i++) walk(before[i], after[i], [...path, i]);
46
+ for (let i = common; i < after.length; i++) patches.push({ op: "set", path: [...path, i], value: after[i] });
47
+ if (after.length < before.length) patches.push({ op: "truncate", path, length: after.length });
48
+ } else if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
49
+ for (const key of Object.keys(before)) {
50
+ if (!own(after, key)) patches.push({ op: "delete", path: [...path, key] });
51
+ }
52
+ for (const key of Object.keys(after)) {
53
+ if (own(before, key)) walk(before[key], after[key], [...path, key]);
54
+ else patches.push({ op: "set", path: [...path, key], value: after[key] });
55
+ }
56
+ } else {
57
+ patches.push({ op: "set", path, value: after });
58
+ }
59
+ }
60
+ walk(previous, next, []);
61
+ return patches;
62
+ }
63
+
64
+ // Rebuild only modified paths; the input snapshot and unchanged branches retain
65
+ // their identity. Invalid operations throw before any input can be mutated.
66
+ export function applySessionPatches(snapshot, patches) {
67
+ if (!Array.isArray(patches)) throw new TypeError("Expected patches array");
68
+ let result = snapshot;
69
+ for (const patch of patches) {
70
+ if (!patch || !["set", "delete", "append", "truncate"].includes(patch.op) || !Array.isArray(patch.path)) throw new TypeError("Invalid patch");
71
+ for (const key of patch.path) {
72
+ if (typeof key !== "string" && (!Number.isSafeInteger(key) || key < 0)) throw new TypeError("Invalid patch path");
73
+ }
74
+ function update(current, depth) {
75
+ if (depth === patch.path.length) {
76
+ if (patch.op === "set") {
77
+ if (!own(patch, "value")) throw new TypeError("Missing patch value");
78
+ const value = canonicalizeJson(patch.value);
79
+ if (value === undefined) throw new TypeError("Invalid patch value");
80
+ return value;
81
+ }
82
+ if (patch.op === "append" && typeof current === "string" && typeof patch.value === "string") return current + patch.value;
83
+ if (patch.op === "truncate" && Array.isArray(current) && Number.isSafeInteger(patch.length) && patch.length >= 0 && patch.length <= current.length) return current.slice(0, patch.length);
84
+ throw new TypeError("Invalid patch target");
85
+ }
86
+ const key = patch.path[depth];
87
+ if (!current || typeof current !== "object") throw new TypeError("Invalid patch parent");
88
+ const array = Array.isArray(current);
89
+ if (array ? typeof key !== "number" || key > current.length : typeof key !== "string") throw new TypeError("Invalid patch key");
90
+ const leaf = depth === patch.path.length - 1;
91
+ const exists = own(current, key);
92
+ if (!exists && !(leaf && patch.op === "set")) throw new TypeError("Missing patch path");
93
+ const copy = array ? current.slice() : { ...current };
94
+ if (leaf && patch.op === "delete") {
95
+ if (array) throw new TypeError("Use truncate for arrays");
96
+ delete copy[key];
97
+ } else {
98
+ setOwn(copy, key, update(exists ? current[key] : undefined, depth + 1));
99
+ }
100
+ return copy;
101
+ }
102
+ result = update(result, 0);
103
+ }
104
+ return result;
105
+ }