stashes 0.1.7 → 0.1.9

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/dist/mcp.js CHANGED
@@ -33,7 +33,12 @@ import simpleGit2 from "simple-git";
33
33
  // ../shared/dist/constants/index.js
34
34
  var STASHES_PORT = 4000;
35
35
  var DEFAULT_STASH_COUNT = 3;
36
+ var APP_PROXY_PORT = STASHES_PORT + 1;
36
37
  var STASH_PORT_START = 4010;
38
+ var STASH_PORT_END = 4030;
39
+ var MAX_PREVIEW_SERVERS = 5;
40
+ var PREVIEW_TTL_MS = 300000;
41
+ var PREVIEW_REAPER_INTERVAL = 30000;
37
42
  var DEFAULT_DIRECTIVES = [
38
43
  "Minimal and clean \u2014 reduce visual noise, emphasize whitespace, limit to 2-3 colors, typography-driven hierarchy",
39
44
  "Bold and expressive \u2014 strong visual identity, use motion/animation, rich interactions, distinctive layout",
@@ -231,6 +236,34 @@ class WorktreeManager {
231
236
  getPreviewPort() {
232
237
  return PREVIEW_PORT;
233
238
  }
239
+ async createPreviewForPool(stashId) {
240
+ const previewPath = join2(this.projectPath, ".stashes", "previews", stashId);
241
+ const branch = `stashes/${stashId}`;
242
+ if (existsSync2(previewPath)) {
243
+ try {
244
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
245
+ } catch {
246
+ rmSync(previewPath, { recursive: true, force: true });
247
+ }
248
+ await this.git.raw(["worktree", "prune"]);
249
+ }
250
+ logger.info("worktree", `creating pool preview: ${stashId}`, { branch, path: previewPath });
251
+ await this.git.raw(["worktree", "add", previewPath, branch]);
252
+ this.symlinkDeps(previewPath);
253
+ logger.info("worktree", `pool preview created: ${stashId}`);
254
+ return previewPath;
255
+ }
256
+ async removePreviewForPool(stashId) {
257
+ const previewPath = join2(this.projectPath, ".stashes", "previews", stashId);
258
+ logger.info("worktree", `removing pool preview: ${stashId}`);
259
+ try {
260
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
261
+ } catch {
262
+ if (existsSync2(previewPath)) {
263
+ rmSync(previewPath, { recursive: true, force: true });
264
+ }
265
+ }
266
+ }
234
267
  async apply(stashId) {
235
268
  const branch = `stashes/${stashId}`;
236
269
  logger.info("worktree", `merging: ${stashId}`, { branch });
@@ -271,6 +304,10 @@ class WorktreeManager {
271
304
  if (existsSync2(previewDir)) {
272
305
  rmSync(previewDir, { recursive: true, force: true });
273
306
  }
307
+ const previewsDir = join2(this.projectPath, ".stashes", "previews");
308
+ if (existsSync2(previewsDir)) {
309
+ rmSync(previewsDir, { recursive: true, force: true });
310
+ }
274
311
  logger.info("worktree", `cleanup complete`);
275
312
  }
276
313
  symlinkDeps(worktreePath) {
@@ -346,6 +383,9 @@ class PersistenceService {
346
383
  const filePath = join3(this.basePath, "projects", projectId, "stashes.json");
347
384
  return readJson(filePath, []);
348
385
  }
386
+ getStash(projectId, stashId) {
387
+ return this.listStashes(projectId).find((s) => s.id === stashId);
388
+ }
349
389
  saveStash(stash) {
350
390
  const stashes = [...this.listStashes(stash.projectId)];
351
391
  const index = stashes.findIndex((s) => s.id === stash.id);
@@ -1045,19 +1085,201 @@ var apiRoutes = app;
1045
1085
  // ../server/dist/services/stash-service.js
1046
1086
  import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
1047
1087
  import { join as join7 } from "path";
1088
+
1089
+ // ../server/dist/services/preview-pool.js
1090
+ class PreviewPool {
1091
+ entries = new Map;
1092
+ usedPorts = new Set;
1093
+ maxSize;
1094
+ ttlMs;
1095
+ worktreeManager;
1096
+ broadcast;
1097
+ reaperInterval;
1098
+ constructor(worktreeManager, broadcast, maxSize = MAX_PREVIEW_SERVERS, ttlMs = PREVIEW_TTL_MS) {
1099
+ this.worktreeManager = worktreeManager;
1100
+ this.broadcast = broadcast;
1101
+ this.maxSize = maxSize;
1102
+ this.ttlMs = ttlMs;
1103
+ this.reaperInterval = setInterval(() => this.reap(), PREVIEW_REAPER_INTERVAL);
1104
+ }
1105
+ async getOrStart(stashId) {
1106
+ const existing = this.entries.get(stashId);
1107
+ if (existing) {
1108
+ existing.lastHeartbeat = Date.now();
1109
+ logger.info("pool", `warm hit: ${stashId} on port ${existing.port}`);
1110
+ return existing.port;
1111
+ }
1112
+ if (this.entries.size >= this.maxSize) {
1113
+ this.evictOldest();
1114
+ }
1115
+ const port = this.allocatePort();
1116
+ const worktreePath = await this.worktreeManager.createPreviewForPool(stashId);
1117
+ const process2 = Bun.spawn({
1118
+ cmd: ["npm", "run", "dev"],
1119
+ cwd: worktreePath,
1120
+ stdin: "ignore",
1121
+ stdout: "pipe",
1122
+ stderr: "pipe",
1123
+ env: { ...Bun.env, PORT: String(port), BROWSER: "none" }
1124
+ });
1125
+ const entry = {
1126
+ stashId,
1127
+ port,
1128
+ process: process2,
1129
+ worktreePath,
1130
+ lastHeartbeat: Date.now()
1131
+ };
1132
+ this.entries.set(stashId, entry);
1133
+ this.usedPorts.add(port);
1134
+ logger.info("pool", `cold start: ${stashId} on port ${port}`, { poolSize: this.entries.size });
1135
+ await this.waitForPort(port, 60000);
1136
+ return port;
1137
+ }
1138
+ heartbeat(stashId) {
1139
+ const entry = this.entries.get(stashId);
1140
+ if (entry) {
1141
+ entry.lastHeartbeat = Date.now();
1142
+ }
1143
+ }
1144
+ isWarm(stashId) {
1145
+ return this.entries.has(stashId);
1146
+ }
1147
+ getPort(stashId) {
1148
+ return this.entries.get(stashId)?.port ?? null;
1149
+ }
1150
+ async stop(stashId) {
1151
+ const entry = this.entries.get(stashId);
1152
+ if (!entry)
1153
+ return;
1154
+ logger.info("pool", `stopping: ${stashId} on port ${entry.port}`);
1155
+ this.killEntry(entry);
1156
+ this.entries.delete(stashId);
1157
+ this.usedPorts.delete(entry.port);
1158
+ try {
1159
+ await this.worktreeManager.removePreviewForPool(stashId);
1160
+ } catch (err) {
1161
+ logger.warn("pool", `worktree removal failed for ${stashId}`, {
1162
+ error: err instanceof Error ? err.message : String(err)
1163
+ });
1164
+ }
1165
+ }
1166
+ prefetchNeighbors(currentStashId, sortedStashIds) {
1167
+ const currentIndex = sortedStashIds.indexOf(currentStashId);
1168
+ if (currentIndex === -1 || sortedStashIds.length < 2)
1169
+ return;
1170
+ const neighbors = [];
1171
+ const prevIndex = (currentIndex - 1 + sortedStashIds.length) % sortedStashIds.length;
1172
+ const nextIndex = (currentIndex + 1) % sortedStashIds.length;
1173
+ if (!this.entries.has(sortedStashIds[prevIndex])) {
1174
+ neighbors.push(sortedStashIds[prevIndex]);
1175
+ }
1176
+ if (!this.entries.has(sortedStashIds[nextIndex])) {
1177
+ neighbors.push(sortedStashIds[nextIndex]);
1178
+ }
1179
+ for (const stashId of neighbors) {
1180
+ if (this.entries.size >= this.maxSize)
1181
+ break;
1182
+ logger.info("pool", `prefetching neighbor: ${stashId}`);
1183
+ this.getOrStart(stashId).then((port) => {
1184
+ this.broadcast({ type: "stash:port", stashId, port });
1185
+ }).catch((err) => {
1186
+ logger.warn("pool", `prefetch failed for ${stashId}`, {
1187
+ error: err instanceof Error ? err.message : String(err)
1188
+ });
1189
+ });
1190
+ }
1191
+ }
1192
+ async shutdown() {
1193
+ logger.info("pool", `shutting down all`, { poolSize: this.entries.size });
1194
+ clearInterval(this.reaperInterval);
1195
+ const stashIds = [...this.entries.keys()];
1196
+ for (const stashId of stashIds) {
1197
+ await this.stop(stashId);
1198
+ }
1199
+ }
1200
+ reap() {
1201
+ const now = Date.now();
1202
+ const expired = [];
1203
+ for (const [stashId, entry] of this.entries) {
1204
+ if (now - entry.lastHeartbeat > this.ttlMs) {
1205
+ expired.push(stashId);
1206
+ }
1207
+ }
1208
+ for (const stashId of expired) {
1209
+ logger.info("pool", `reaping inactive: ${stashId}`);
1210
+ const entry = this.entries.get(stashId);
1211
+ this.killEntry(entry);
1212
+ this.entries.delete(stashId);
1213
+ this.usedPorts.delete(entry.port);
1214
+ this.worktreeManager.removePreviewForPool(stashId).catch((err) => {
1215
+ logger.warn("pool", `reap worktree cleanup failed for ${stashId}`, {
1216
+ error: err instanceof Error ? err.message : String(err)
1217
+ });
1218
+ });
1219
+ this.broadcast({ type: "stash:preview_stopped", stashId });
1220
+ }
1221
+ }
1222
+ evictOldest() {
1223
+ let oldest = null;
1224
+ for (const entry of this.entries.values()) {
1225
+ if (!oldest || entry.lastHeartbeat < oldest.lastHeartbeat) {
1226
+ oldest = entry;
1227
+ }
1228
+ }
1229
+ if (oldest) {
1230
+ logger.info("pool", `evicting: ${oldest.stashId} (oldest heartbeat)`);
1231
+ this.killEntry(oldest);
1232
+ this.entries.delete(oldest.stashId);
1233
+ this.usedPorts.delete(oldest.port);
1234
+ this.worktreeManager.removePreviewForPool(oldest.stashId).catch((err) => {
1235
+ logger.warn("pool", `evict worktree cleanup failed for ${oldest.stashId}`, {
1236
+ error: err instanceof Error ? err.message : String(err)
1237
+ });
1238
+ });
1239
+ this.broadcast({ type: "stash:preview_stopped", stashId: oldest.stashId });
1240
+ }
1241
+ }
1242
+ allocatePort() {
1243
+ for (let port = STASH_PORT_START;port <= STASH_PORT_END; port++) {
1244
+ if (!this.usedPorts.has(port)) {
1245
+ return port;
1246
+ }
1247
+ }
1248
+ throw new Error(`No available ports in range ${STASH_PORT_START}-${STASH_PORT_END}`);
1249
+ }
1250
+ killEntry(entry) {
1251
+ try {
1252
+ entry.process.kill();
1253
+ } catch {}
1254
+ }
1255
+ async waitForPort(port, timeout) {
1256
+ const start = Date.now();
1257
+ while (Date.now() - start < timeout) {
1258
+ try {
1259
+ const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1260
+ if (res.ok || res.status < 500)
1261
+ return;
1262
+ } catch {}
1263
+ await new Promise((r) => setTimeout(r, 1000));
1264
+ }
1265
+ throw new Error(`Port ${port} not ready within ${timeout}ms`);
1266
+ }
1267
+ }
1268
+
1269
+ // ../server/dist/services/stash-service.js
1048
1270
  class StashService {
1049
1271
  projectPath;
1050
1272
  worktreeManager;
1051
1273
  persistence;
1052
1274
  broadcast;
1275
+ previewPool;
1053
1276
  selectedComponent = null;
1054
- previewServer = null;
1055
- activePreviewStashId = null;
1056
1277
  constructor(projectPath, worktreeManager, persistence, broadcast) {
1057
1278
  this.projectPath = projectPath;
1058
1279
  this.worktreeManager = worktreeManager;
1059
1280
  this.persistence = persistence;
1060
1281
  this.broadcast = broadcast;
1282
+ this.previewPool = new PreviewPool(worktreeManager, broadcast);
1061
1283
  }
1062
1284
  setSelectedComponent(component) {
1063
1285
  this.selectedComponent = component;
@@ -1103,7 +1325,7 @@ class StashService {
1103
1325
  });
1104
1326
  }
1105
1327
  }
1106
- async chat(projectId, message) {
1328
+ async chat(projectId, message, referenceStashIds) {
1107
1329
  const component = this.selectedComponent;
1108
1330
  let sourceCode = "";
1109
1331
  const filePath = component?.filePath || "";
@@ -1113,8 +1335,18 @@ class StashService {
1113
1335
  sourceCode = readFileSync4(sourceFile, "utf-8");
1114
1336
  }
1115
1337
  }
1338
+ let stashContext = "";
1339
+ if (referenceStashIds?.length) {
1340
+ const refs = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
1341
+ if (refs.length) {
1342
+ stashContext = `
1343
+ Referenced stashes:
1344
+ ${refs.join(`
1345
+ `)}`;
1346
+ }
1347
+ }
1116
1348
  const chatPrompt = [
1117
- "The user is asking about a UI component in their project. Answer concisely.",
1349
+ "The user is asking about their UI project. Answer concisely.",
1118
1350
  "Do NOT modify any files.",
1119
1351
  "",
1120
1352
  component ? `Component: ${component.name}` : "",
@@ -1124,6 +1356,7 @@ Source:
1124
1356
  \`\`\`
1125
1357
  ${sourceCode.substring(0, 3000)}
1126
1358
  \`\`\`` : "",
1359
+ stashContext,
1127
1360
  "",
1128
1361
  `User question: ${message}`
1129
1362
  ].filter(Boolean).join(`
@@ -1176,18 +1409,23 @@ ${sourceCode.substring(0, 3000)}
1176
1409
  break;
1177
1410
  }
1178
1411
  }
1179
- async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT) {
1180
- if (!this.selectedComponent) {
1181
- throw new Error("No component selected");
1412
+ async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
1413
+ let enrichedPrompt = prompt;
1414
+ if (referenceStashIds?.length) {
1415
+ const refDescriptions = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
1416
+ if (refDescriptions.length) {
1417
+ enrichedPrompt = `${prompt}
1418
+
1419
+ ## Reference Stashes (use as inspiration)
1420
+ ${refDescriptions.join(`
1421
+ `)}`;
1422
+ }
1182
1423
  }
1183
1424
  await generate({
1184
1425
  projectPath: this.projectPath,
1185
1426
  projectId,
1186
- prompt,
1187
- component: {
1188
- filePath: this.selectedComponent.filePath,
1189
- exportName: this.selectedComponent.name
1190
- },
1427
+ prompt: enrichedPrompt,
1428
+ component: this.selectedComponent ? { filePath: this.selectedComponent.filePath, exportName: this.selectedComponent.name } : undefined,
1191
1429
  count: stashCount,
1192
1430
  onProgress: (event) => this.progressToBroadcast(event)
1193
1431
  });
@@ -1200,94 +1438,25 @@ ${sourceCode.substring(0, 3000)}
1200
1438
  onProgress: (event) => this.progressToBroadcast(event)
1201
1439
  });
1202
1440
  }
1203
- async switchPreview(stashId) {
1204
- const previewPort = this.worktreeManager.getPreviewPort();
1205
- let previewPath = this.worktreeManager.getPreviewPath();
1206
- if (!previewPath) {
1207
- previewPath = await this.worktreeManager.createPreview();
1208
- }
1209
- await this.ensurePreviewServer(previewPath);
1210
- if (this.activePreviewStashId === stashId) {
1211
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1212
- return;
1441
+ async switchPreview(stashId, sortedStashIds) {
1442
+ const port = await this.previewPool.getOrStart(stashId);
1443
+ this.broadcast({ type: "stash:port", stashId, port });
1444
+ if (sortedStashIds && sortedStashIds.length > 1) {
1445
+ this.previewPool.prefetchNeighbors(stashId, sortedStashIds);
1213
1446
  }
1214
- await this.worktreeManager.switchPreviewTo(stashId);
1215
- this.activePreviewStashId = stashId;
1216
- await this.waitForRecompile(previewPort, 15000);
1217
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1447
+ }
1448
+ previewHeartbeat(stashId) {
1449
+ this.previewPool.heartbeat(stashId);
1218
1450
  }
1219
1451
  async applyStash(stashId) {
1220
- this.stopPreviewServer();
1452
+ await this.previewPool.shutdown();
1221
1453
  await apply({ projectPath: this.projectPath, stashId });
1222
- this.activePreviewStashId = null;
1223
1454
  this.broadcast({ type: "stash:applied", stashId });
1224
1455
  }
1225
1456
  async deleteStash(stashId) {
1226
- if (this.activePreviewStashId === stashId) {
1227
- this.activePreviewStashId = null;
1228
- }
1457
+ await this.previewPool.stop(stashId);
1229
1458
  await remove(this.projectPath, stashId);
1230
1459
  }
1231
- async ensurePreviewServer(previewPath) {
1232
- if (this.previewServer) {
1233
- const port = this.worktreeManager.getPreviewPort();
1234
- try {
1235
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1236
- if (res.ok || res.status < 500)
1237
- return;
1238
- } catch {
1239
- this.previewServer = null;
1240
- }
1241
- }
1242
- await this.startPreviewServer(previewPath);
1243
- }
1244
- async startPreviewServer(previewPath) {
1245
- const port = this.worktreeManager.getPreviewPort();
1246
- this.previewServer = Bun.spawn({
1247
- cmd: ["npm", "run", "dev"],
1248
- cwd: previewPath,
1249
- stdin: "ignore",
1250
- stdout: "pipe",
1251
- stderr: "pipe",
1252
- env: { ...process.env, PORT: String(port), BROWSER: "none" }
1253
- });
1254
- await this.waitForPort(port, 60000);
1255
- }
1256
- stopPreviewServer() {
1257
- if (this.previewServer) {
1258
- this.previewServer.kill();
1259
- this.previewServer = null;
1260
- }
1261
- }
1262
- async waitForRecompile(port, timeout) {
1263
- const start = Date.now();
1264
- await new Promise((r) => setTimeout(r, 500));
1265
- while (Date.now() - start < timeout) {
1266
- try {
1267
- const res = await fetch(`http://localhost:${port}`, {
1268
- signal: AbortSignal.timeout(3000),
1269
- headers: { "cache-control": "no-cache" }
1270
- });
1271
- if (res.ok) {
1272
- await new Promise((r) => setTimeout(r, 1000));
1273
- return;
1274
- }
1275
- } catch {}
1276
- await new Promise((r) => setTimeout(r, 500));
1277
- }
1278
- }
1279
- async waitForPort(port, timeout) {
1280
- const start = Date.now();
1281
- while (Date.now() - start < timeout) {
1282
- try {
1283
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1284
- if (res.ok || res.status < 500)
1285
- return;
1286
- } catch {}
1287
- await new Promise((r) => setTimeout(r, 1000));
1288
- }
1289
- throw new Error(`Port ${port} not ready within ${timeout}ms`);
1290
- }
1291
1460
  }
1292
1461
 
1293
1462
  // ../server/dist/services/websocket.js
@@ -1304,7 +1473,7 @@ function broadcast(event) {
1304
1473
  function getPersistenceFromWs() {
1305
1474
  return persistence;
1306
1475
  }
1307
- function createWebSocketHandler(projectPath, userDevPort) {
1476
+ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1308
1477
  worktreeManager = new WorktreeManager(projectPath);
1309
1478
  persistence = new PersistenceService(projectPath);
1310
1479
  stashService = new StashService(projectPath, worktreeManager, persistence, broadcast);
@@ -1312,7 +1481,7 @@ function createWebSocketHandler(projectPath, userDevPort) {
1312
1481
  open(ws) {
1313
1482
  clients.add(ws);
1314
1483
  logger.info("ws", "client connected", { total: clients.size });
1315
- ws.send(JSON.stringify({ type: "server_ready", port: userDevPort }));
1484
+ ws.send(JSON.stringify({ type: "server_ready", port: userDevPort, appProxyPort }));
1316
1485
  },
1317
1486
  async message(ws, message) {
1318
1487
  const raw = typeof message === "string" ? message : new TextDecoder().decode(message);
@@ -1339,7 +1508,7 @@ function createWebSocketHandler(projectPath, userDevPort) {
1339
1508
  type: "text",
1340
1509
  createdAt: new Date().toISOString()
1341
1510
  });
1342
- await stashService.chat(event.projectId, event.message);
1511
+ await stashService.chat(event.projectId, event.message, event.referenceStashIds);
1343
1512
  break;
1344
1513
  case "generate":
1345
1514
  persistence.saveChatMessage(event.projectId, {
@@ -1349,13 +1518,16 @@ function createWebSocketHandler(projectPath, userDevPort) {
1349
1518
  type: "text",
1350
1519
  createdAt: new Date().toISOString()
1351
1520
  });
1352
- await stashService.generate(event.projectId, event.prompt, event.stashCount);
1521
+ await stashService.generate(event.projectId, event.prompt, event.stashCount, event.referenceStashIds);
1353
1522
  break;
1354
1523
  case "vary":
1355
1524
  await stashService.vary(event.sourceStashId, event.prompt);
1356
1525
  break;
1357
1526
  case "interact":
1358
- await stashService.switchPreview(event.stashId);
1527
+ await stashService.switchPreview(event.stashId, event.sortedStashIds);
1528
+ break;
1529
+ case "preview_heartbeat":
1530
+ stashService.previewHeartbeat(event.stashId);
1359
1531
  break;
1360
1532
  case "apply_stash":
1361
1533
  await stashService.applyStash(event.stashId);
@@ -1377,6 +1549,111 @@ function createWebSocketHandler(projectPath, userDevPort) {
1377
1549
  };
1378
1550
  }
1379
1551
 
1552
+ // ../server/dist/services/app-proxy.js
1553
+ function startAppProxy(userDevPort, proxyPort, injectOverlay) {
1554
+ const server = Bun.serve({
1555
+ port: proxyPort,
1556
+ async fetch(req, server2) {
1557
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1558
+ const url2 = new URL(req.url);
1559
+ const success = server2.upgrade(req, {
1560
+ data: {
1561
+ path: url2.pathname + url2.search,
1562
+ upstream: null,
1563
+ ready: false,
1564
+ buffer: []
1565
+ }
1566
+ });
1567
+ return success ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
1568
+ }
1569
+ const url = new URL(req.url);
1570
+ const targetUrl = `http://localhost:${userDevPort}${url.pathname}${url.search}`;
1571
+ try {
1572
+ const headers = new Headers;
1573
+ for (const [key, value] of req.headers.entries()) {
1574
+ if (!["host", "accept-encoding"].includes(key.toLowerCase())) {
1575
+ headers.set(key, value);
1576
+ }
1577
+ }
1578
+ headers.set("host", `localhost:${userDevPort}`);
1579
+ const response = await fetch(targetUrl, {
1580
+ method: req.method,
1581
+ headers,
1582
+ body: req.method !== "GET" && req.method !== "HEAD" ? await req.arrayBuffer() : undefined,
1583
+ redirect: "manual"
1584
+ });
1585
+ const contentType = response.headers.get("content-type") || "";
1586
+ if (contentType.includes("text/html")) {
1587
+ const html = await response.text();
1588
+ const injectedHtml = injectOverlay(html);
1589
+ const respHeaders2 = new Headers(response.headers);
1590
+ respHeaders2.delete("content-encoding");
1591
+ respHeaders2.delete("content-length");
1592
+ return new Response(injectedHtml, {
1593
+ status: response.status,
1594
+ headers: respHeaders2
1595
+ });
1596
+ }
1597
+ const respHeaders = new Headers(response.headers);
1598
+ respHeaders.delete("content-encoding");
1599
+ return new Response(response.body, {
1600
+ status: response.status,
1601
+ headers: respHeaders
1602
+ });
1603
+ } catch (err) {
1604
+ return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
1605
+ status: 502,
1606
+ headers: { "content-type": "application/json" }
1607
+ });
1608
+ }
1609
+ },
1610
+ websocket: {
1611
+ open(ws) {
1612
+ const { data } = ws;
1613
+ const upstream = new WebSocket(`ws://localhost:${userDevPort}${data.path}`);
1614
+ upstream.addEventListener("open", () => {
1615
+ data.upstream = upstream;
1616
+ data.ready = true;
1617
+ for (const msg of data.buffer) {
1618
+ upstream.send(msg);
1619
+ }
1620
+ data.buffer = [];
1621
+ });
1622
+ upstream.addEventListener("message", (event) => {
1623
+ try {
1624
+ ws.sendText(typeof event.data === "string" ? event.data : String(event.data));
1625
+ } catch {}
1626
+ });
1627
+ upstream.addEventListener("close", () => {
1628
+ try {
1629
+ ws.close();
1630
+ } catch {}
1631
+ });
1632
+ upstream.addEventListener("error", () => {
1633
+ try {
1634
+ ws.close();
1635
+ } catch {}
1636
+ });
1637
+ },
1638
+ message(ws, msg) {
1639
+ const { data } = ws;
1640
+ if (data.ready && data.upstream) {
1641
+ data.upstream.send(typeof msg === "string" ? msg : new Uint8Array(msg));
1642
+ } else {
1643
+ data.buffer.push(typeof msg === "string" ? msg : new Uint8Array(msg).buffer);
1644
+ }
1645
+ },
1646
+ close(ws) {
1647
+ try {
1648
+ ws.data.upstream?.close();
1649
+ } catch {}
1650
+ }
1651
+ }
1652
+ });
1653
+ logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
1654
+ return server;
1655
+ }
1656
+
1380
1657
  // ../server/dist/index.js
1381
1658
  var serverState = {
1382
1659
  projectPath: "",
@@ -1388,56 +1665,6 @@ function getPersistence() {
1388
1665
  var app2 = new Hono2;
1389
1666
  app2.use("/*", cors());
1390
1667
  app2.route("/api", apiRoutes);
1391
- async function proxyToUserApp(c, targetPath, injectOverlay = false) {
1392
- const userDevPort = serverState.userDevPort;
1393
- const url = new URL(c.req.url);
1394
- const targetUrl = `http://localhost:${userDevPort}${targetPath}${url.search}`;
1395
- try {
1396
- const headers = new Headers;
1397
- for (const [key, value] of Object.entries(c.req.header())) {
1398
- if (!["host", "accept-encoding"].includes(key.toLowerCase()) && value) {
1399
- headers.set(key, value);
1400
- }
1401
- }
1402
- headers.set("host", `localhost:${userDevPort}`);
1403
- const response = await fetch(targetUrl, {
1404
- method: c.req.method,
1405
- headers,
1406
- body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.arrayBuffer() : undefined,
1407
- redirect: "manual"
1408
- });
1409
- const contentType = response.headers.get("content-type") || "";
1410
- if (injectOverlay && contentType.includes("text/html")) {
1411
- const html = await response.text();
1412
- const injectedHtml = injectOverlayScript(html);
1413
- const respHeaders2 = new Headers(response.headers);
1414
- respHeaders2.delete("content-encoding");
1415
- respHeaders2.delete("content-length");
1416
- return new Response(injectedHtml, {
1417
- status: response.status,
1418
- headers: respHeaders2
1419
- });
1420
- }
1421
- const respHeaders = new Headers(response.headers);
1422
- respHeaders.delete("content-encoding");
1423
- return new Response(response.body, {
1424
- status: response.status,
1425
- headers: respHeaders
1426
- });
1427
- } catch (err) {
1428
- return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
1429
- status: 502,
1430
- headers: { "content-type": "application/json" }
1431
- });
1432
- }
1433
- }
1434
- app2.all("/app/*", (c) => {
1435
- const path = c.req.path.replace(/^\/app/, "") || "/";
1436
- return proxyToUserApp(c, path, true);
1437
- });
1438
- app2.all("/_next/*", (c) => proxyToUserApp(c, c.req.path));
1439
- app2.all("/__nextjs*", (c) => proxyToUserApp(c, c.req.path));
1440
- app2.all("/__next*", (c) => proxyToUserApp(c, c.req.path));
1441
1668
  app2.get("/*", async (c) => {
1442
1669
  const path = c.req.path;
1443
1670
  const selfDir = dirname2(fileURLToPath(import.meta.url));
@@ -1480,7 +1707,9 @@ app2.get("/*", async (c) => {
1480
1707
  function startServer(projectPath, userDevPort, port = STASHES_PORT) {
1481
1708
  serverState = { projectPath, userDevPort };
1482
1709
  initLogFile(projectPath);
1483
- const wsHandler = createWebSocketHandler(projectPath, userDevPort);
1710
+ const appProxyPort = port + 1;
1711
+ startAppProxy(userDevPort, appProxyPort, injectOverlayScript);
1712
+ const wsHandler = createWebSocketHandler(projectPath, userDevPort, appProxyPort);
1484
1713
  const server = Bun.serve({
1485
1714
  port,
1486
1715
  fetch(req, server2) {
@@ -1645,22 +1874,25 @@ function injectOverlayScript(html) {
1645
1874
  }
1646
1875
  });
1647
1876
 
1648
- document.addEventListener('click', function(e) {
1649
- if (pickerEnabled) return;
1650
- var link = e.target;
1651
- while (link && link.tagName !== 'A') link = link.parentElement;
1652
- if (link && link.href) {
1653
- var url;
1654
- try { url = new URL(link.href); } catch(_) { return; }
1655
- if (url.origin === window.location.origin) {
1656
- var path = url.pathname;
1657
- if (!path.startsWith('/app/')) {
1658
- e.preventDefault();
1659
- window.location.href = '/app' + path + url.search + url.hash;
1660
- }
1661
- }
1662
- }
1663
- }, false);
1877
+ // Report current URL to parent for status bar display
1878
+ function reportUrl() {
1879
+ window.parent.postMessage({
1880
+ type: 'stashes:url_change',
1881
+ url: window.location.pathname + window.location.search + window.location.hash
1882
+ }, '*');
1883
+ }
1884
+ reportUrl();
1885
+ var origPush = history.pushState;
1886
+ history.pushState = function() {
1887
+ origPush.apply(this, arguments);
1888
+ setTimeout(reportUrl, 0);
1889
+ };
1890
+ var origReplace = history.replaceState;
1891
+ history.replaceState = function() {
1892
+ origReplace.apply(this, arguments);
1893
+ setTimeout(reportUrl, 0);
1894
+ };
1895
+ window.addEventListener('popstate', reportUrl);
1664
1896
 
1665
1897
  document.addEventListener('mousemove', onMouseMove, { passive: true });
1666
1898
  document.addEventListener('click', onClick, true);