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/cli.js CHANGED
@@ -37,7 +37,12 @@ import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
37
37
  // ../shared/dist/constants/index.js
38
38
  var STASHES_PORT = 4000;
39
39
  var DEFAULT_STASH_COUNT = 3;
40
+ var APP_PROXY_PORT = STASHES_PORT + 1;
40
41
  var STASH_PORT_START = 4010;
42
+ var STASH_PORT_END = 4030;
43
+ var MAX_PREVIEW_SERVERS = 5;
44
+ var PREVIEW_TTL_MS = 300000;
45
+ var PREVIEW_REAPER_INTERVAL = 30000;
41
46
  var DEFAULT_DIRECTIVES = [
42
47
  "Minimal and clean \u2014 reduce visual noise, emphasize whitespace, limit to 2-3 colors, typography-driven hierarchy",
43
48
  "Bold and expressive \u2014 strong visual identity, use motion/animation, rich interactions, distinctive layout",
@@ -295,6 +300,34 @@ class WorktreeManager {
295
300
  getPreviewPort() {
296
301
  return PREVIEW_PORT;
297
302
  }
303
+ async createPreviewForPool(stashId) {
304
+ const previewPath = join3(this.projectPath, ".stashes", "previews", stashId);
305
+ const branch = `stashes/${stashId}`;
306
+ if (existsSync3(previewPath)) {
307
+ try {
308
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
309
+ } catch {
310
+ rmSync(previewPath, { recursive: true, force: true });
311
+ }
312
+ await this.git.raw(["worktree", "prune"]);
313
+ }
314
+ logger.info("worktree", `creating pool preview: ${stashId}`, { branch, path: previewPath });
315
+ await this.git.raw(["worktree", "add", previewPath, branch]);
316
+ this.symlinkDeps(previewPath);
317
+ logger.info("worktree", `pool preview created: ${stashId}`);
318
+ return previewPath;
319
+ }
320
+ async removePreviewForPool(stashId) {
321
+ const previewPath = join3(this.projectPath, ".stashes", "previews", stashId);
322
+ logger.info("worktree", `removing pool preview: ${stashId}`);
323
+ try {
324
+ await this.git.raw(["worktree", "remove", "--force", previewPath]);
325
+ } catch {
326
+ if (existsSync3(previewPath)) {
327
+ rmSync(previewPath, { recursive: true, force: true });
328
+ }
329
+ }
330
+ }
298
331
  async apply(stashId) {
299
332
  const branch = `stashes/${stashId}`;
300
333
  logger.info("worktree", `merging: ${stashId}`, { branch });
@@ -335,6 +368,10 @@ class WorktreeManager {
335
368
  if (existsSync3(previewDir)) {
336
369
  rmSync(previewDir, { recursive: true, force: true });
337
370
  }
371
+ const previewsDir = join3(this.projectPath, ".stashes", "previews");
372
+ if (existsSync3(previewsDir)) {
373
+ rmSync(previewsDir, { recursive: true, force: true });
374
+ }
338
375
  logger.info("worktree", `cleanup complete`);
339
376
  }
340
377
  symlinkDeps(worktreePath) {
@@ -410,6 +447,9 @@ class PersistenceService {
410
447
  const filePath = join4(this.basePath, "projects", projectId, "stashes.json");
411
448
  return readJson(filePath, []);
412
449
  }
450
+ getStash(projectId, stashId) {
451
+ return this.listStashes(projectId).find((s) => s.id === stashId);
452
+ }
413
453
  saveStash(stash) {
414
454
  const stashes = [...this.listStashes(stash.projectId)];
415
455
  const index = stashes.findIndex((s) => s.id === stash.id);
@@ -915,19 +955,201 @@ async function cleanup(projectPath) {
915
955
  // ../server/dist/services/stash-service.js
916
956
  import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
917
957
  import { join as join7 } from "path";
958
+
959
+ // ../server/dist/services/preview-pool.js
960
+ class PreviewPool {
961
+ entries = new Map;
962
+ usedPorts = new Set;
963
+ maxSize;
964
+ ttlMs;
965
+ worktreeManager;
966
+ broadcast;
967
+ reaperInterval;
968
+ constructor(worktreeManager, broadcast, maxSize = MAX_PREVIEW_SERVERS, ttlMs = PREVIEW_TTL_MS) {
969
+ this.worktreeManager = worktreeManager;
970
+ this.broadcast = broadcast;
971
+ this.maxSize = maxSize;
972
+ this.ttlMs = ttlMs;
973
+ this.reaperInterval = setInterval(() => this.reap(), PREVIEW_REAPER_INTERVAL);
974
+ }
975
+ async getOrStart(stashId) {
976
+ const existing = this.entries.get(stashId);
977
+ if (existing) {
978
+ existing.lastHeartbeat = Date.now();
979
+ logger.info("pool", `warm hit: ${stashId} on port ${existing.port}`);
980
+ return existing.port;
981
+ }
982
+ if (this.entries.size >= this.maxSize) {
983
+ this.evictOldest();
984
+ }
985
+ const port = this.allocatePort();
986
+ const worktreePath = await this.worktreeManager.createPreviewForPool(stashId);
987
+ const process2 = Bun.spawn({
988
+ cmd: ["npm", "run", "dev"],
989
+ cwd: worktreePath,
990
+ stdin: "ignore",
991
+ stdout: "pipe",
992
+ stderr: "pipe",
993
+ env: { ...Bun.env, PORT: String(port), BROWSER: "none" }
994
+ });
995
+ const entry = {
996
+ stashId,
997
+ port,
998
+ process: process2,
999
+ worktreePath,
1000
+ lastHeartbeat: Date.now()
1001
+ };
1002
+ this.entries.set(stashId, entry);
1003
+ this.usedPorts.add(port);
1004
+ logger.info("pool", `cold start: ${stashId} on port ${port}`, { poolSize: this.entries.size });
1005
+ await this.waitForPort(port, 60000);
1006
+ return port;
1007
+ }
1008
+ heartbeat(stashId) {
1009
+ const entry = this.entries.get(stashId);
1010
+ if (entry) {
1011
+ entry.lastHeartbeat = Date.now();
1012
+ }
1013
+ }
1014
+ isWarm(stashId) {
1015
+ return this.entries.has(stashId);
1016
+ }
1017
+ getPort(stashId) {
1018
+ return this.entries.get(stashId)?.port ?? null;
1019
+ }
1020
+ async stop(stashId) {
1021
+ const entry = this.entries.get(stashId);
1022
+ if (!entry)
1023
+ return;
1024
+ logger.info("pool", `stopping: ${stashId} on port ${entry.port}`);
1025
+ this.killEntry(entry);
1026
+ this.entries.delete(stashId);
1027
+ this.usedPorts.delete(entry.port);
1028
+ try {
1029
+ await this.worktreeManager.removePreviewForPool(stashId);
1030
+ } catch (err) {
1031
+ logger.warn("pool", `worktree removal failed for ${stashId}`, {
1032
+ error: err instanceof Error ? err.message : String(err)
1033
+ });
1034
+ }
1035
+ }
1036
+ prefetchNeighbors(currentStashId, sortedStashIds) {
1037
+ const currentIndex = sortedStashIds.indexOf(currentStashId);
1038
+ if (currentIndex === -1 || sortedStashIds.length < 2)
1039
+ return;
1040
+ const neighbors = [];
1041
+ const prevIndex = (currentIndex - 1 + sortedStashIds.length) % sortedStashIds.length;
1042
+ const nextIndex = (currentIndex + 1) % sortedStashIds.length;
1043
+ if (!this.entries.has(sortedStashIds[prevIndex])) {
1044
+ neighbors.push(sortedStashIds[prevIndex]);
1045
+ }
1046
+ if (!this.entries.has(sortedStashIds[nextIndex])) {
1047
+ neighbors.push(sortedStashIds[nextIndex]);
1048
+ }
1049
+ for (const stashId of neighbors) {
1050
+ if (this.entries.size >= this.maxSize)
1051
+ break;
1052
+ logger.info("pool", `prefetching neighbor: ${stashId}`);
1053
+ this.getOrStart(stashId).then((port) => {
1054
+ this.broadcast({ type: "stash:port", stashId, port });
1055
+ }).catch((err) => {
1056
+ logger.warn("pool", `prefetch failed for ${stashId}`, {
1057
+ error: err instanceof Error ? err.message : String(err)
1058
+ });
1059
+ });
1060
+ }
1061
+ }
1062
+ async shutdown() {
1063
+ logger.info("pool", `shutting down all`, { poolSize: this.entries.size });
1064
+ clearInterval(this.reaperInterval);
1065
+ const stashIds = [...this.entries.keys()];
1066
+ for (const stashId of stashIds) {
1067
+ await this.stop(stashId);
1068
+ }
1069
+ }
1070
+ reap() {
1071
+ const now = Date.now();
1072
+ const expired = [];
1073
+ for (const [stashId, entry] of this.entries) {
1074
+ if (now - entry.lastHeartbeat > this.ttlMs) {
1075
+ expired.push(stashId);
1076
+ }
1077
+ }
1078
+ for (const stashId of expired) {
1079
+ logger.info("pool", `reaping inactive: ${stashId}`);
1080
+ const entry = this.entries.get(stashId);
1081
+ this.killEntry(entry);
1082
+ this.entries.delete(stashId);
1083
+ this.usedPorts.delete(entry.port);
1084
+ this.worktreeManager.removePreviewForPool(stashId).catch((err) => {
1085
+ logger.warn("pool", `reap worktree cleanup failed for ${stashId}`, {
1086
+ error: err instanceof Error ? err.message : String(err)
1087
+ });
1088
+ });
1089
+ this.broadcast({ type: "stash:preview_stopped", stashId });
1090
+ }
1091
+ }
1092
+ evictOldest() {
1093
+ let oldest = null;
1094
+ for (const entry of this.entries.values()) {
1095
+ if (!oldest || entry.lastHeartbeat < oldest.lastHeartbeat) {
1096
+ oldest = entry;
1097
+ }
1098
+ }
1099
+ if (oldest) {
1100
+ logger.info("pool", `evicting: ${oldest.stashId} (oldest heartbeat)`);
1101
+ this.killEntry(oldest);
1102
+ this.entries.delete(oldest.stashId);
1103
+ this.usedPorts.delete(oldest.port);
1104
+ this.worktreeManager.removePreviewForPool(oldest.stashId).catch((err) => {
1105
+ logger.warn("pool", `evict worktree cleanup failed for ${oldest.stashId}`, {
1106
+ error: err instanceof Error ? err.message : String(err)
1107
+ });
1108
+ });
1109
+ this.broadcast({ type: "stash:preview_stopped", stashId: oldest.stashId });
1110
+ }
1111
+ }
1112
+ allocatePort() {
1113
+ for (let port = STASH_PORT_START;port <= STASH_PORT_END; port++) {
1114
+ if (!this.usedPorts.has(port)) {
1115
+ return port;
1116
+ }
1117
+ }
1118
+ throw new Error(`No available ports in range ${STASH_PORT_START}-${STASH_PORT_END}`);
1119
+ }
1120
+ killEntry(entry) {
1121
+ try {
1122
+ entry.process.kill();
1123
+ } catch {}
1124
+ }
1125
+ async waitForPort(port, timeout) {
1126
+ const start = Date.now();
1127
+ while (Date.now() - start < timeout) {
1128
+ try {
1129
+ const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1130
+ if (res.ok || res.status < 500)
1131
+ return;
1132
+ } catch {}
1133
+ await new Promise((r) => setTimeout(r, 1000));
1134
+ }
1135
+ throw new Error(`Port ${port} not ready within ${timeout}ms`);
1136
+ }
1137
+ }
1138
+
1139
+ // ../server/dist/services/stash-service.js
918
1140
  class StashService {
919
1141
  projectPath;
920
1142
  worktreeManager;
921
1143
  persistence;
922
1144
  broadcast;
1145
+ previewPool;
923
1146
  selectedComponent = null;
924
- previewServer = null;
925
- activePreviewStashId = null;
926
1147
  constructor(projectPath, worktreeManager, persistence, broadcast) {
927
1148
  this.projectPath = projectPath;
928
1149
  this.worktreeManager = worktreeManager;
929
1150
  this.persistence = persistence;
930
1151
  this.broadcast = broadcast;
1152
+ this.previewPool = new PreviewPool(worktreeManager, broadcast);
931
1153
  }
932
1154
  setSelectedComponent(component) {
933
1155
  this.selectedComponent = component;
@@ -973,7 +1195,7 @@ class StashService {
973
1195
  });
974
1196
  }
975
1197
  }
976
- async chat(projectId, message) {
1198
+ async chat(projectId, message, referenceStashIds) {
977
1199
  const component = this.selectedComponent;
978
1200
  let sourceCode = "";
979
1201
  const filePath = component?.filePath || "";
@@ -983,8 +1205,18 @@ class StashService {
983
1205
  sourceCode = readFileSync4(sourceFile, "utf-8");
984
1206
  }
985
1207
  }
1208
+ let stashContext = "";
1209
+ if (referenceStashIds?.length) {
1210
+ const refs = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
1211
+ if (refs.length) {
1212
+ stashContext = `
1213
+ Referenced stashes:
1214
+ ${refs.join(`
1215
+ `)}`;
1216
+ }
1217
+ }
986
1218
  const chatPrompt = [
987
- "The user is asking about a UI component in their project. Answer concisely.",
1219
+ "The user is asking about their UI project. Answer concisely.",
988
1220
  "Do NOT modify any files.",
989
1221
  "",
990
1222
  component ? `Component: ${component.name}` : "",
@@ -994,6 +1226,7 @@ Source:
994
1226
  \`\`\`
995
1227
  ${sourceCode.substring(0, 3000)}
996
1228
  \`\`\`` : "",
1229
+ stashContext,
997
1230
  "",
998
1231
  `User question: ${message}`
999
1232
  ].filter(Boolean).join(`
@@ -1046,18 +1279,23 @@ ${sourceCode.substring(0, 3000)}
1046
1279
  break;
1047
1280
  }
1048
1281
  }
1049
- async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT) {
1050
- if (!this.selectedComponent) {
1051
- throw new Error("No component selected");
1282
+ async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
1283
+ let enrichedPrompt = prompt;
1284
+ if (referenceStashIds?.length) {
1285
+ const refDescriptions = referenceStashIds.map((id) => this.persistence.getStash(projectId, id)).filter(Boolean).map((s) => `- "${s.prompt}"${s.componentPath ? ` (${s.componentPath})` : ""}`);
1286
+ if (refDescriptions.length) {
1287
+ enrichedPrompt = `${prompt}
1288
+
1289
+ ## Reference Stashes (use as inspiration)
1290
+ ${refDescriptions.join(`
1291
+ `)}`;
1292
+ }
1052
1293
  }
1053
1294
  await generate({
1054
1295
  projectPath: this.projectPath,
1055
1296
  projectId,
1056
- prompt,
1057
- component: {
1058
- filePath: this.selectedComponent.filePath,
1059
- exportName: this.selectedComponent.name
1060
- },
1297
+ prompt: enrichedPrompt,
1298
+ component: this.selectedComponent ? { filePath: this.selectedComponent.filePath, exportName: this.selectedComponent.name } : undefined,
1061
1299
  count: stashCount,
1062
1300
  onProgress: (event) => this.progressToBroadcast(event)
1063
1301
  });
@@ -1070,94 +1308,25 @@ ${sourceCode.substring(0, 3000)}
1070
1308
  onProgress: (event) => this.progressToBroadcast(event)
1071
1309
  });
1072
1310
  }
1073
- async switchPreview(stashId) {
1074
- const previewPort = this.worktreeManager.getPreviewPort();
1075
- let previewPath = this.worktreeManager.getPreviewPath();
1076
- if (!previewPath) {
1077
- previewPath = await this.worktreeManager.createPreview();
1078
- }
1079
- await this.ensurePreviewServer(previewPath);
1080
- if (this.activePreviewStashId === stashId) {
1081
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1082
- return;
1311
+ async switchPreview(stashId, sortedStashIds) {
1312
+ const port = await this.previewPool.getOrStart(stashId);
1313
+ this.broadcast({ type: "stash:port", stashId, port });
1314
+ if (sortedStashIds && sortedStashIds.length > 1) {
1315
+ this.previewPool.prefetchNeighbors(stashId, sortedStashIds);
1083
1316
  }
1084
- await this.worktreeManager.switchPreviewTo(stashId);
1085
- this.activePreviewStashId = stashId;
1086
- await this.waitForRecompile(previewPort, 15000);
1087
- this.broadcast({ type: "stash:port", stashId, port: previewPort });
1317
+ }
1318
+ previewHeartbeat(stashId) {
1319
+ this.previewPool.heartbeat(stashId);
1088
1320
  }
1089
1321
  async applyStash(stashId) {
1090
- this.stopPreviewServer();
1322
+ await this.previewPool.shutdown();
1091
1323
  await apply({ projectPath: this.projectPath, stashId });
1092
- this.activePreviewStashId = null;
1093
1324
  this.broadcast({ type: "stash:applied", stashId });
1094
1325
  }
1095
1326
  async deleteStash(stashId) {
1096
- if (this.activePreviewStashId === stashId) {
1097
- this.activePreviewStashId = null;
1098
- }
1327
+ await this.previewPool.stop(stashId);
1099
1328
  await remove(this.projectPath, stashId);
1100
1329
  }
1101
- async ensurePreviewServer(previewPath) {
1102
- if (this.previewServer) {
1103
- const port = this.worktreeManager.getPreviewPort();
1104
- try {
1105
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1106
- if (res.ok || res.status < 500)
1107
- return;
1108
- } catch {
1109
- this.previewServer = null;
1110
- }
1111
- }
1112
- await this.startPreviewServer(previewPath);
1113
- }
1114
- async startPreviewServer(previewPath) {
1115
- const port = this.worktreeManager.getPreviewPort();
1116
- this.previewServer = Bun.spawn({
1117
- cmd: ["npm", "run", "dev"],
1118
- cwd: previewPath,
1119
- stdin: "ignore",
1120
- stdout: "pipe",
1121
- stderr: "pipe",
1122
- env: { ...process.env, PORT: String(port), BROWSER: "none" }
1123
- });
1124
- await this.waitForPort(port, 60000);
1125
- }
1126
- stopPreviewServer() {
1127
- if (this.previewServer) {
1128
- this.previewServer.kill();
1129
- this.previewServer = null;
1130
- }
1131
- }
1132
- async waitForRecompile(port, timeout) {
1133
- const start = Date.now();
1134
- await new Promise((r) => setTimeout(r, 500));
1135
- while (Date.now() - start < timeout) {
1136
- try {
1137
- const res = await fetch(`http://localhost:${port}`, {
1138
- signal: AbortSignal.timeout(3000),
1139
- headers: { "cache-control": "no-cache" }
1140
- });
1141
- if (res.ok) {
1142
- await new Promise((r) => setTimeout(r, 1000));
1143
- return;
1144
- }
1145
- } catch {}
1146
- await new Promise((r) => setTimeout(r, 500));
1147
- }
1148
- }
1149
- async waitForPort(port, timeout) {
1150
- const start = Date.now();
1151
- while (Date.now() - start < timeout) {
1152
- try {
1153
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
1154
- if (res.ok || res.status < 500)
1155
- return;
1156
- } catch {}
1157
- await new Promise((r) => setTimeout(r, 1000));
1158
- }
1159
- throw new Error(`Port ${port} not ready within ${timeout}ms`);
1160
- }
1161
1330
  }
1162
1331
 
1163
1332
  // ../server/dist/services/websocket.js
@@ -1174,7 +1343,7 @@ function broadcast(event) {
1174
1343
  function getPersistenceFromWs() {
1175
1344
  return persistence;
1176
1345
  }
1177
- function createWebSocketHandler(projectPath, userDevPort) {
1346
+ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1178
1347
  worktreeManager = new WorktreeManager(projectPath);
1179
1348
  persistence = new PersistenceService(projectPath);
1180
1349
  stashService = new StashService(projectPath, worktreeManager, persistence, broadcast);
@@ -1182,7 +1351,7 @@ function createWebSocketHandler(projectPath, userDevPort) {
1182
1351
  open(ws) {
1183
1352
  clients.add(ws);
1184
1353
  logger.info("ws", "client connected", { total: clients.size });
1185
- ws.send(JSON.stringify({ type: "server_ready", port: userDevPort }));
1354
+ ws.send(JSON.stringify({ type: "server_ready", port: userDevPort, appProxyPort }));
1186
1355
  },
1187
1356
  async message(ws, message) {
1188
1357
  const raw = typeof message === "string" ? message : new TextDecoder().decode(message);
@@ -1209,7 +1378,7 @@ function createWebSocketHandler(projectPath, userDevPort) {
1209
1378
  type: "text",
1210
1379
  createdAt: new Date().toISOString()
1211
1380
  });
1212
- await stashService.chat(event.projectId, event.message);
1381
+ await stashService.chat(event.projectId, event.message, event.referenceStashIds);
1213
1382
  break;
1214
1383
  case "generate":
1215
1384
  persistence.saveChatMessage(event.projectId, {
@@ -1219,13 +1388,16 @@ function createWebSocketHandler(projectPath, userDevPort) {
1219
1388
  type: "text",
1220
1389
  createdAt: new Date().toISOString()
1221
1390
  });
1222
- await stashService.generate(event.projectId, event.prompt, event.stashCount);
1391
+ await stashService.generate(event.projectId, event.prompt, event.stashCount, event.referenceStashIds);
1223
1392
  break;
1224
1393
  case "vary":
1225
1394
  await stashService.vary(event.sourceStashId, event.prompt);
1226
1395
  break;
1227
1396
  case "interact":
1228
- await stashService.switchPreview(event.stashId);
1397
+ await stashService.switchPreview(event.stashId, event.sortedStashIds);
1398
+ break;
1399
+ case "preview_heartbeat":
1400
+ stashService.previewHeartbeat(event.stashId);
1229
1401
  break;
1230
1402
  case "apply_stash":
1231
1403
  await stashService.applyStash(event.stashId);
@@ -1247,6 +1419,111 @@ function createWebSocketHandler(projectPath, userDevPort) {
1247
1419
  };
1248
1420
  }
1249
1421
 
1422
+ // ../server/dist/services/app-proxy.js
1423
+ function startAppProxy(userDevPort, proxyPort, injectOverlay) {
1424
+ const server = Bun.serve({
1425
+ port: proxyPort,
1426
+ async fetch(req, server2) {
1427
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1428
+ const url2 = new URL(req.url);
1429
+ const success = server2.upgrade(req, {
1430
+ data: {
1431
+ path: url2.pathname + url2.search,
1432
+ upstream: null,
1433
+ ready: false,
1434
+ buffer: []
1435
+ }
1436
+ });
1437
+ return success ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
1438
+ }
1439
+ const url = new URL(req.url);
1440
+ const targetUrl = `http://localhost:${userDevPort}${url.pathname}${url.search}`;
1441
+ try {
1442
+ const headers = new Headers;
1443
+ for (const [key, value] of req.headers.entries()) {
1444
+ if (!["host", "accept-encoding"].includes(key.toLowerCase())) {
1445
+ headers.set(key, value);
1446
+ }
1447
+ }
1448
+ headers.set("host", `localhost:${userDevPort}`);
1449
+ const response = await fetch(targetUrl, {
1450
+ method: req.method,
1451
+ headers,
1452
+ body: req.method !== "GET" && req.method !== "HEAD" ? await req.arrayBuffer() : undefined,
1453
+ redirect: "manual"
1454
+ });
1455
+ const contentType = response.headers.get("content-type") || "";
1456
+ if (contentType.includes("text/html")) {
1457
+ const html = await response.text();
1458
+ const injectedHtml = injectOverlay(html);
1459
+ const respHeaders2 = new Headers(response.headers);
1460
+ respHeaders2.delete("content-encoding");
1461
+ respHeaders2.delete("content-length");
1462
+ return new Response(injectedHtml, {
1463
+ status: response.status,
1464
+ headers: respHeaders2
1465
+ });
1466
+ }
1467
+ const respHeaders = new Headers(response.headers);
1468
+ respHeaders.delete("content-encoding");
1469
+ return new Response(response.body, {
1470
+ status: response.status,
1471
+ headers: respHeaders
1472
+ });
1473
+ } catch (err) {
1474
+ return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
1475
+ status: 502,
1476
+ headers: { "content-type": "application/json" }
1477
+ });
1478
+ }
1479
+ },
1480
+ websocket: {
1481
+ open(ws) {
1482
+ const { data } = ws;
1483
+ const upstream = new WebSocket(`ws://localhost:${userDevPort}${data.path}`);
1484
+ upstream.addEventListener("open", () => {
1485
+ data.upstream = upstream;
1486
+ data.ready = true;
1487
+ for (const msg of data.buffer) {
1488
+ upstream.send(msg);
1489
+ }
1490
+ data.buffer = [];
1491
+ });
1492
+ upstream.addEventListener("message", (event) => {
1493
+ try {
1494
+ ws.sendText(typeof event.data === "string" ? event.data : String(event.data));
1495
+ } catch {}
1496
+ });
1497
+ upstream.addEventListener("close", () => {
1498
+ try {
1499
+ ws.close();
1500
+ } catch {}
1501
+ });
1502
+ upstream.addEventListener("error", () => {
1503
+ try {
1504
+ ws.close();
1505
+ } catch {}
1506
+ });
1507
+ },
1508
+ message(ws, msg) {
1509
+ const { data } = ws;
1510
+ if (data.ready && data.upstream) {
1511
+ data.upstream.send(typeof msg === "string" ? msg : new Uint8Array(msg));
1512
+ } else {
1513
+ data.buffer.push(typeof msg === "string" ? msg : new Uint8Array(msg).buffer);
1514
+ }
1515
+ },
1516
+ close(ws) {
1517
+ try {
1518
+ ws.data.upstream?.close();
1519
+ } catch {}
1520
+ }
1521
+ }
1522
+ });
1523
+ logger.info("proxy", `App proxy at http://localhost:${proxyPort} \u2192 http://localhost:${userDevPort}`);
1524
+ return server;
1525
+ }
1526
+
1250
1527
  // ../server/dist/index.js
1251
1528
  var serverState = {
1252
1529
  projectPath: "",
@@ -1258,56 +1535,6 @@ function getPersistence() {
1258
1535
  var app2 = new Hono2;
1259
1536
  app2.use("/*", cors());
1260
1537
  app2.route("/api", apiRoutes);
1261
- async function proxyToUserApp(c, targetPath, injectOverlay = false) {
1262
- const userDevPort = serverState.userDevPort;
1263
- const url = new URL(c.req.url);
1264
- const targetUrl = `http://localhost:${userDevPort}${targetPath}${url.search}`;
1265
- try {
1266
- const headers = new Headers;
1267
- for (const [key, value] of Object.entries(c.req.header())) {
1268
- if (!["host", "accept-encoding"].includes(key.toLowerCase()) && value) {
1269
- headers.set(key, value);
1270
- }
1271
- }
1272
- headers.set("host", `localhost:${userDevPort}`);
1273
- const response = await fetch(targetUrl, {
1274
- method: c.req.method,
1275
- headers,
1276
- body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.arrayBuffer() : undefined,
1277
- redirect: "manual"
1278
- });
1279
- const contentType = response.headers.get("content-type") || "";
1280
- if (injectOverlay && contentType.includes("text/html")) {
1281
- const html = await response.text();
1282
- const injectedHtml = injectOverlayScript(html);
1283
- const respHeaders2 = new Headers(response.headers);
1284
- respHeaders2.delete("content-encoding");
1285
- respHeaders2.delete("content-length");
1286
- return new Response(injectedHtml, {
1287
- status: response.status,
1288
- headers: respHeaders2
1289
- });
1290
- }
1291
- const respHeaders = new Headers(response.headers);
1292
- respHeaders.delete("content-encoding");
1293
- return new Response(response.body, {
1294
- status: response.status,
1295
- headers: respHeaders
1296
- });
1297
- } catch (err) {
1298
- return new Response(JSON.stringify({ error: "Proxy failed", detail: String(err) }), {
1299
- status: 502,
1300
- headers: { "content-type": "application/json" }
1301
- });
1302
- }
1303
- }
1304
- app2.all("/app/*", (c) => {
1305
- const path = c.req.path.replace(/^\/app/, "") || "/";
1306
- return proxyToUserApp(c, path, true);
1307
- });
1308
- app2.all("/_next/*", (c) => proxyToUserApp(c, c.req.path));
1309
- app2.all("/__nextjs*", (c) => proxyToUserApp(c, c.req.path));
1310
- app2.all("/__next*", (c) => proxyToUserApp(c, c.req.path));
1311
1538
  app2.get("/*", async (c) => {
1312
1539
  const path = c.req.path;
1313
1540
  const selfDir = dirname2(fileURLToPath(import.meta.url));
@@ -1350,7 +1577,9 @@ app2.get("/*", async (c) => {
1350
1577
  function startServer(projectPath, userDevPort, port = STASHES_PORT) {
1351
1578
  serverState = { projectPath, userDevPort };
1352
1579
  initLogFile(projectPath);
1353
- const wsHandler = createWebSocketHandler(projectPath, userDevPort);
1580
+ const appProxyPort = port + 1;
1581
+ startAppProxy(userDevPort, appProxyPort, injectOverlayScript);
1582
+ const wsHandler = createWebSocketHandler(projectPath, userDevPort, appProxyPort);
1354
1583
  const server = Bun.serve({
1355
1584
  port,
1356
1585
  fetch(req, server2) {
@@ -1515,22 +1744,25 @@ function injectOverlayScript(html) {
1515
1744
  }
1516
1745
  });
1517
1746
 
1518
- document.addEventListener('click', function(e) {
1519
- if (pickerEnabled) return;
1520
- var link = e.target;
1521
- while (link && link.tagName !== 'A') link = link.parentElement;
1522
- if (link && link.href) {
1523
- var url;
1524
- try { url = new URL(link.href); } catch(_) { return; }
1525
- if (url.origin === window.location.origin) {
1526
- var path = url.pathname;
1527
- if (!path.startsWith('/app/')) {
1528
- e.preventDefault();
1529
- window.location.href = '/app' + path + url.search + url.hash;
1530
- }
1531
- }
1532
- }
1533
- }, false);
1747
+ // Report current URL to parent for status bar display
1748
+ function reportUrl() {
1749
+ window.parent.postMessage({
1750
+ type: 'stashes:url_change',
1751
+ url: window.location.pathname + window.location.search + window.location.hash
1752
+ }, '*');
1753
+ }
1754
+ reportUrl();
1755
+ var origPush = history.pushState;
1756
+ history.pushState = function() {
1757
+ origPush.apply(this, arguments);
1758
+ setTimeout(reportUrl, 0);
1759
+ };
1760
+ var origReplace = history.replaceState;
1761
+ history.replaceState = function() {
1762
+ origReplace.apply(this, arguments);
1763
+ setTimeout(reportUrl, 0);
1764
+ };
1765
+ window.addEventListener('popstate', reportUrl);
1534
1766
 
1535
1767
  document.addEventListener('mousemove', onMouseMove, { passive: true });
1536
1768
  document.addEventListener('click', onClick, true);