@ollie-shop/cli 1.3.3 → 1.4.1

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @ollie-shop/cli@1.3.3 build /home/runner/work/ollie-shop/ollie-shop/packages/cli
2
+ > @ollie-shop/cli@1.4.1 build /home/runner/work/ollie-shop/ollie-shop/packages/cli
3
3
  > tsup
4
4
 
5
5
  CLI Building entry: src/index.tsx
@@ -9,5 +9,5 @@
9
9
  CLI Target: node22
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
- ESM dist/index.js 92.64 KB
13
- ESM ⚡️ Build success in 300ms
12
+ ESM dist/index.js 95.89 KB
13
+ ESM ⚡️ Build success in 248ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @ollie-shop/cli
2
2
 
3
+ ## 1.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 630ae3e: Keep Studio live reload working after a component is added or removed. The dev server proxy now owns the `/esbuild` connection and re-subscribes across context recreates, so the browser's EventSource no longer drops when the esbuild context is disposed.
8
+
9
+ ## 1.4.0
10
+
11
+ ### Minor Changes
12
+
13
+ - bac681e: Add a `--no-open` flag to `ollieshop start` (also honored via the `CI` env var) so the dev server can run headless without auto-opening Studio in the browser — useful when an agent spawns it and drives its own preview harness. `ollieshop start` now also runs without a TTY: it no longer crashes with "Raw mode is not supported" when backgrounded, and simply disables keyboard shortcuts in that case. Upgrade the CLI to React 19 and Ink 6.
14
+
3
15
  ## 1.3.3
4
16
 
5
17
  ### Patch Changes
package/CONTEXT.md CHANGED
@@ -78,6 +78,14 @@ The `deploy` command bundles a component directory into a zip, uploads it to the
78
78
 
79
79
  Terminal build statuses: `SUCCEEDED`, `FAILED`, `STOPPED`, `TIMED_OUT`, `FAULT`
80
80
 
81
+ ## Dev Server (agent / harness mode)
82
+
83
+ `ollieshop start` runs the local build+serve dev server (port 4000: `/manifest.json`, `/<Name>/index.js`, `/<Name>/index.css`, SSE `/esbuild`) and normally **auto-opens the admin Studio in a browser**. When you (an agent) are spawning it, pass `--no-open` so it does **not** open a browser — then drive your own preview harness against the dev server instead. The flag is also honored automatically when the `CI` env var is set.
84
+
85
+ ```bash
86
+ ollieshop start --no-open # build+serve only; no browser is opened
87
+ ```
88
+
81
89
  ## Response Format
82
90
 
83
91
  All commands return:
package/README.md CHANGED
@@ -172,6 +172,7 @@ ollieshop init --store-id <STORE_UUID> --version-id <VERSION_UUID> -o json
172
172
  | `--fields a,b,c` | | Limit output fields (comma-separated) |
173
173
  | `--data '{...}'` | `-d` | Raw JSON payload for mutations (alternative to individual flags) |
174
174
  | `--stage <name>` | `-s` | Config stage — loads `ollie.<stage>.json` instead of `ollie.json` |
175
+ | `--no-open` | | `start` only: don't auto-open Studio in the browser (also honored via the `CI` env var) |
175
176
 
176
177
  ## Response Format
177
178
 
package/dist/index.js CHANGED
@@ -107,12 +107,17 @@ function HelpCommand() {
107
107
  "{stage}",
108
108
  ".json)"
109
109
  ] })
110
+ ] }),
111
+ /* @__PURE__ */ jsxs(Box, { children: [
112
+ /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "--no-open" }) }),
113
+ /* @__PURE__ */ jsx(Text, { children: "start: don't auto-open Studio (also honored via CI env)" })
110
114
  ] })
111
115
  ] }),
112
116
  /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { bold: true, children: "Examples:" }) }),
113
117
  /* @__PURE__ */ jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [
114
118
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop login" }),
115
119
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --stage dev" }),
120
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --no-open" }),
116
121
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop whoami -o json" }),
117
122
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop schema store.create" }),
118
123
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: '$ ollieshop store create --name "My Store" --platform vtex --platform-store-id mystore' }),
@@ -695,6 +700,7 @@ async function startDevServer(options = {}) {
695
700
  ctx = null;
696
701
  if (oldCtx) await oldCtx.dispose();
697
702
  await buildAndServe(components);
703
+ notifyComponentsChanged(components);
698
704
  }
699
705
  await buildAndServe(await discoverComponents({ cwd, stage }));
700
706
  async function currentComponentNames() {
@@ -732,8 +738,80 @@ async function startDevServer(options = {}) {
732
738
  if (watchTimer) clearTimeout(watchTimer);
733
739
  watchTimer = setTimeout(maybeRecreate, 150);
734
740
  });
741
+ const sseClients = /* @__PURE__ */ new Set();
742
+ let upstreamReq = null;
743
+ let upstreamRetry = null;
744
+ function broadcast(chunk) {
745
+ for (const client of sseClients) {
746
+ if (!client.writableEnded) client.write(chunk);
747
+ }
748
+ }
749
+ function connectUpstream() {
750
+ if (upstreamReq || sseClients.size === 0) return;
751
+ const req = http.request(
752
+ {
753
+ hostname: host,
754
+ port: internalPort,
755
+ path: "/esbuild",
756
+ method: "GET",
757
+ headers: { accept: "text/event-stream" }
758
+ },
759
+ (upstream) => {
760
+ upstream.on("data", broadcast);
761
+ upstream.on("close", onUpstreamLost);
762
+ upstream.on("error", onUpstreamLost);
763
+ }
764
+ );
765
+ req.on("error", onUpstreamLost);
766
+ req.end();
767
+ upstreamReq = req;
768
+ }
769
+ function onUpstreamLost() {
770
+ if (!upstreamReq) return;
771
+ upstreamReq = null;
772
+ if (upstreamRetry) clearTimeout(upstreamRetry);
773
+ if (sseClients.size === 0) return;
774
+ upstreamRetry = setTimeout(connectUpstream, 200);
775
+ }
776
+ function teardownUpstream() {
777
+ if (upstreamRetry) {
778
+ clearTimeout(upstreamRetry);
779
+ upstreamRetry = null;
780
+ }
781
+ const req = upstreamReq;
782
+ upstreamReq = null;
783
+ req?.destroy();
784
+ }
785
+ function notifyComponentsChanged(components) {
786
+ if (sseClients.size === 0) return;
787
+ const updated = components.map((c) => `/${c.name}/index.js`);
788
+ const payload = JSON.stringify({ added: [], removed: [], updated });
789
+ broadcast(`event: change
790
+ data: ${payload}
791
+
792
+ `);
793
+ }
735
794
  const proxyServer = http.createServer(async (req, res) => {
736
795
  const url = new URL(req.url || "/", `http://${host}:${port}`);
796
+ if (url.pathname === "/esbuild" && req.method === "GET") {
797
+ res.writeHead(200, {
798
+ "Content-Type": "text/event-stream",
799
+ "Cache-Control": "no-cache",
800
+ Connection: "keep-alive",
801
+ "Access-Control-Allow-Origin": "*",
802
+ "Access-Control-Allow-Private-Network": "true"
803
+ });
804
+ res.write("retry: 500\n\n");
805
+ sseClients.add(res);
806
+ connectUpstream();
807
+ const dropClient = () => {
808
+ sseClients.delete(res);
809
+ if (sseClients.size === 0) teardownUpstream();
810
+ };
811
+ req.on("close", dropClient);
812
+ res.on("error", dropClient);
813
+ return;
814
+ }
737
815
  if (url.pathname === "/bundle" && req.method === "GET") {
738
816
  const componentPath = url.searchParams.get("path");
739
817
  if (!componentPath) {
@@ -891,6 +969,9 @@ async function startDevServer(options = {}) {
891
969
  stop: async () => {
892
970
  if (watchTimer) clearTimeout(watchTimer);
893
971
  componentsWatcher.close();
972
+ teardownUpstream();
973
+ for (const client of sseClients) client.end();
974
+ sseClients.clear();
894
975
  proxyServer.close();
895
976
  await ctx?.dispose();
896
977
  }
@@ -945,6 +1026,8 @@ function StartCommand({ args }) {
945
1026
  const rebuildRef = useRef(null);
946
1027
  const stopRef = useRef(null);
947
1028
  const stage = resolveStage(parseArg(args, "--stage", "-s"));
1029
+ const noOpen = args.includes("--no-open") || Boolean(process.env.CI);
1030
+ const isInteractive = Boolean(process.stdin.isTTY);
948
1031
  const addLog = useCallback((log) => {
949
1032
  setLogs((prev) => {
950
1033
  const newLog = {
@@ -1015,12 +1098,14 @@ function StartCommand({ args }) {
1015
1098
  storeId: config.storeId,
1016
1099
  versionId: config.versionId
1017
1100
  });
1018
- const studioUrl = new URL(STUDIO_BASE_URL);
1019
- studioUrl.searchParams.set("storeId", config.storeId);
1020
- if (config.versionId) {
1021
- studioUrl.searchParams.set("versionId", config.versionId);
1101
+ if (!noOpen) {
1102
+ const studioUrl = new URL(STUDIO_BASE_URL);
1103
+ studioUrl.searchParams.set("storeId", config.storeId);
1104
+ if (config.versionId) {
1105
+ studioUrl.searchParams.set("versionId", config.versionId);
1106
+ }
1107
+ open(studioUrl.toString());
1022
1108
  }
1023
- open(studioUrl.toString());
1024
1109
  } catch (error) {
1025
1110
  if (!mounted) return;
1026
1111
  setState({
@@ -1034,23 +1119,26 @@ function StartCommand({ args }) {
1034
1119
  mounted = false;
1035
1120
  stopRef.current?.();
1036
1121
  };
1037
- }, [stage, handleRequest]);
1038
- useInput((input, key) => {
1039
- if (input === "q" || input === "c" && key.ctrl) {
1040
- stopRef.current?.().then(() => exit());
1041
- }
1042
- if (input === "r" && state.status === "running") {
1043
- rebuildRef.current?.();
1044
- }
1045
- if (input === "o" && state.status === "running") {
1046
- const studioUrl = new URL(STUDIO_BASE_URL);
1047
- studioUrl.searchParams.set("storeId", state.storeId);
1048
- if (state.versionId) {
1049
- studioUrl.searchParams.set("versionId", state.versionId);
1122
+ }, [stage, handleRequest, noOpen]);
1123
+ useInput(
1124
+ (input, key) => {
1125
+ if (input === "q" || input === "c" && key.ctrl) {
1126
+ stopRef.current?.().then(() => exit());
1050
1127
  }
1051
- open(studioUrl.toString());
1052
- }
1053
- });
1128
+ if (input === "r" && state.status === "running") {
1129
+ rebuildRef.current?.();
1130
+ }
1131
+ if (input === "o" && state.status === "running") {
1132
+ const studioUrl = new URL(STUDIO_BASE_URL);
1133
+ studioUrl.searchParams.set("storeId", state.storeId);
1134
+ if (state.versionId) {
1135
+ studioUrl.searchParams.set("versionId", state.versionId);
1136
+ }
1137
+ open(studioUrl.toString());
1138
+ }
1139
+ },
1140
+ { isActive: isInteractive }
1141
+ );
1054
1142
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
1055
1143
  /* @__PURE__ */ jsx3(Header, {}),
1056
1144
  state.status === "initializing" && /* @__PURE__ */ jsxs3(Box3, { children: [
@@ -1081,13 +1169,14 @@ function StartCommand({ args }) {
1081
1169
  port: state.port,
1082
1170
  stage,
1083
1171
  storeId: state.storeId,
1084
- versionId: state.versionId
1172
+ versionId: state.versionId,
1173
+ noOpen
1085
1174
  }
1086
1175
  ),
1087
1176
  /* @__PURE__ */ jsx3(ComponentList, { components }),
1088
1177
  /* @__PURE__ */ jsx3(BuildInfo, { buildCount, lastBuildTime }),
1089
1178
  /* @__PURE__ */ jsx3(RequestLogs, { logs }),
1090
- /* @__PURE__ */ jsx3(Footer, {})
1179
+ /* @__PURE__ */ jsx3(Footer, { interactive: isInteractive })
1091
1180
  ] })
1092
1181
  ] });
1093
1182
  }
@@ -1102,7 +1191,8 @@ function ServerInfo({
1102
1191
  port,
1103
1192
  stage,
1104
1193
  storeId,
1105
- versionId
1194
+ versionId,
1195
+ noOpen
1106
1196
  }) {
1107
1197
  const studioUrl = new URL(STUDIO_BASE_URL);
1108
1198
  studioUrl.searchParams.set("storeId", storeId);
@@ -1129,7 +1219,8 @@ function ServerInfo({
1129
1219
  /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, children: [
1130
1220
  /* @__PURE__ */ jsx3(Text3, { color: "green", children: "\u2713 " }),
1131
1221
  /* @__PURE__ */ jsx3(Text3, { children: "Studio: " }),
1132
- /* @__PURE__ */ jsx3(Text3, { bold: true, color: "magenta", children: studioUrl.toString() })
1222
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "magenta", children: studioUrl.toString() }),
1223
+ noOpen && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " (auto-open off \u2014 press o)" })
1133
1224
  ] }),
1134
1225
  /* @__PURE__ */ jsx3(Box3, { marginLeft: 2, marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1135
1226
  "Components: http://",
@@ -1213,7 +1304,10 @@ function RequestLogs({ logs }) {
1213
1304
  ] }, log.id)) })
1214
1305
  ] });
1215
1306
  }
1216
- function Footer() {
1307
+ function Footer({ interactive = true }) {
1308
+ if (!interactive) {
1309
+ return /* @__PURE__ */ jsx3(Box3, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Headless (no TTY) \u2014 Ctrl+C to stop" }) });
1310
+ }
1217
1311
  return /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [
1218
1312
  /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Press " }),
1219
1313
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "q" }),
@@ -1246,7 +1340,7 @@ function App({ command, args }) {
1246
1340
  }
1247
1341
  }
1248
1342
  function VersionCommand() {
1249
- const version = "1.3.3" ? "1.3.3" : "unknown";
1343
+ const version = "1.4.1" ? "1.4.1" : "unknown";
1250
1344
  return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { children: [
1251
1345
  "ollieshop v",
1252
1346
  version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ollie-shop/cli",
3
- "version": "1.3.3",
3
+ "version": "1.4.1",
4
4
  "description": "Ollie Shop CLI - Development tools for custom checkouts",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,18 +13,17 @@
13
13
  "archiver": "^7.0.1",
14
14
  "esbuild": "^0.24.0",
15
15
  "glob": "^11.0.0",
16
- "ink": "^5.0.1",
16
+ "ink": "^6.8.0",
17
17
  "jwt-decode": "^4.0.0",
18
18
  "open": "^10.1.0",
19
- "react": "^18.3.1",
19
+ "react": "^19.2.0",
20
20
  "zod": "^3.24.2",
21
21
  "zod-to-json-schema": "^3.24.5"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/archiver": "^6.0.3",
25
- "@types/ink": "^2.0.3",
26
25
  "@types/node": "^22.15.23",
27
- "@types/react": "^18.3.12",
26
+ "@types/react": "^19.2.0",
28
27
  "tsup": "^8.0.1",
29
28
  "typescript": "^5.7.3"
30
29
  },
@@ -139,6 +139,12 @@ export function HelpCommand() {
139
139
  </Box>
140
140
  <Text>Config stage (loads ollie.{"{stage}"}.json)</Text>
141
141
  </Box>
142
+ <Box>
143
+ <Box width={24}>
144
+ <Text color="yellow">--no-open</Text>
145
+ </Box>
146
+ <Text>start: don't auto-open Studio (also honored via CI env)</Text>
147
+ </Box>
142
148
  </Box>
143
149
 
144
150
  <Box marginTop={1}>
@@ -147,6 +153,7 @@ export function HelpCommand() {
147
153
  <Box marginLeft={2} flexDirection="column">
148
154
  <Text dimColor>$ ollieshop login</Text>
149
155
  <Text dimColor>$ ollieshop start --stage dev</Text>
156
+ <Text dimColor>$ ollieshop start --no-open</Text>
150
157
  <Text dimColor>$ ollieshop whoami -o json</Text>
151
158
  <Text dimColor>$ ollieshop schema store.create</Text>
152
159
  <Text dimColor>
@@ -59,6 +59,15 @@ export function StartCommand({ args }: StartCommandProps) {
59
59
 
60
60
  // Parse args
61
61
  const stage = resolveStage(parseArg(args, "--stage", "-s"));
62
+ // Suppress auto-opening Studio in the browser (Storybook-style). Useful when an
63
+ // agent spawns the dev server and drives its own harness instead. Also honored
64
+ // via the conventional CI env var.
65
+ const noOpen = args.includes("--no-open") || Boolean(process.env.CI);
66
+ // Keyboard shortcuts need raw mode, which Ink can only enable on a TTY stdin.
67
+ // When spawned headless (agent/CI/backgrounded), stdin isn't a TTY — guard the
68
+ // input handler so the server runs instead of crashing with "Raw mode is not
69
+ // supported on the current process.stdin".
70
+ const isInteractive = Boolean(process.stdin.isTTY);
62
71
 
63
72
  const addLog = useCallback((log: Omit<RequestLog, "id" | "timestamp">) => {
64
73
  setLogs((prev) => {
@@ -148,13 +157,15 @@ export function StartCommand({ args }: StartCommandProps) {
148
157
  versionId: config.versionId,
149
158
  });
150
159
 
151
- // Open Studio in browser
152
- const studioUrl = new URL(STUDIO_BASE_URL);
153
- studioUrl.searchParams.set("storeId", config.storeId);
154
- if (config.versionId) {
155
- studioUrl.searchParams.set("versionId", config.versionId);
160
+ // Open Studio in browser (unless suppressed)
161
+ if (!noOpen) {
162
+ const studioUrl = new URL(STUDIO_BASE_URL);
163
+ studioUrl.searchParams.set("storeId", config.storeId);
164
+ if (config.versionId) {
165
+ studioUrl.searchParams.set("versionId", config.versionId);
166
+ }
167
+ open(studioUrl.toString());
156
168
  }
157
- open(studioUrl.toString());
158
169
  } catch (error) {
159
170
  if (!mounted) return;
160
171
  setState({
@@ -170,29 +181,32 @@ export function StartCommand({ args }: StartCommandProps) {
170
181
  mounted = false;
171
182
  stopRef.current?.();
172
183
  };
173
- }, [stage, handleRequest]);
184
+ }, [stage, handleRequest, noOpen]);
174
185
 
175
- // Handle keyboard input
176
- useInput((input, key) => {
177
- if (input === "q" || (input === "c" && key.ctrl)) {
178
- stopRef.current?.().then(() => exit());
179
- }
186
+ // Handle keyboard input (only when attached to a TTY — see isInteractive above)
187
+ useInput(
188
+ (input, key) => {
189
+ if (input === "q" || (input === "c" && key.ctrl)) {
190
+ stopRef.current?.().then(() => exit());
191
+ }
180
192
 
181
- // Manual rebuild with 'r' (manifest is updated by the plugin)
182
- if (input === "r" && state.status === "running") {
183
- rebuildRef.current?.();
184
- }
193
+ // Manual rebuild with 'r' (manifest is updated by the plugin)
194
+ if (input === "r" && state.status === "running") {
195
+ rebuildRef.current?.();
196
+ }
185
197
 
186
- // Open Studio in browser with 'o'
187
- if (input === "o" && state.status === "running") {
188
- const studioUrl = new URL(STUDIO_BASE_URL);
189
- studioUrl.searchParams.set("storeId", state.storeId);
190
- if (state.versionId) {
191
- studioUrl.searchParams.set("versionId", state.versionId);
198
+ // Open Studio in browser with 'o'
199
+ if (input === "o" && state.status === "running") {
200
+ const studioUrl = new URL(STUDIO_BASE_URL);
201
+ studioUrl.searchParams.set("storeId", state.storeId);
202
+ if (state.versionId) {
203
+ studioUrl.searchParams.set("versionId", state.versionId);
204
+ }
205
+ open(studioUrl.toString());
192
206
  }
193
- open(studioUrl.toString());
194
- }
195
- });
207
+ },
208
+ { isActive: isInteractive },
209
+ );
196
210
 
197
211
  return (
198
212
  <Box flexDirection="column" gap={1}>
@@ -236,11 +250,12 @@ export function StartCommand({ args }: StartCommandProps) {
236
250
  stage={stage}
237
251
  storeId={state.storeId}
238
252
  versionId={state.versionId}
253
+ noOpen={noOpen}
239
254
  />
240
255
  <ComponentList components={components} />
241
256
  <BuildInfo buildCount={buildCount} lastBuildTime={lastBuildTime} />
242
257
  <RequestLogs logs={logs} />
243
- <Footer />
258
+ <Footer interactive={isInteractive} />
244
259
  </>
245
260
  )}
246
261
  </Box>
@@ -264,12 +279,14 @@ function ServerInfo({
264
279
  stage,
265
280
  storeId,
266
281
  versionId,
282
+ noOpen,
267
283
  }: {
268
284
  host: string;
269
285
  port: number;
270
286
  stage?: string;
271
287
  storeId: string;
272
288
  versionId?: string;
289
+ noOpen?: boolean;
273
290
  }) {
274
291
  const studioUrl = new URL(STUDIO_BASE_URL);
275
292
 
@@ -301,6 +318,7 @@ function ServerInfo({
301
318
  <Text bold color="magenta">
302
319
  {studioUrl.toString()}
303
320
  </Text>
321
+ {noOpen && <Text dimColor> (auto-open off — press o)</Text>}
304
322
  </Box>
305
323
  <Box marginLeft={2} marginTop={1}>
306
324
  <Text dimColor>
@@ -386,7 +404,15 @@ function RequestLogs({ logs }: { logs: RequestLog[] }) {
386
404
  );
387
405
  }
388
406
 
389
- function Footer() {
407
+ function Footer({ interactive = true }: { interactive?: boolean }) {
408
+ if (!interactive) {
409
+ return (
410
+ <Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
411
+ <Text dimColor>Headless (no TTY) — Ctrl+C to stop</Text>
412
+ </Box>
413
+ );
414
+ }
415
+
390
416
  return (
391
417
  <Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
392
418
  <Text dimColor>Press </Text>
@@ -267,6 +267,7 @@ export async function startDevServer(
267
267
  ctx = null;
268
268
  if (oldCtx) await oldCtx.dispose();
269
269
  await buildAndServe(components);
270
+ notifyComponentsChanged(components);
270
271
  }
271
272
 
272
273
  await buildAndServe(await discoverComponents({ cwd, stage }));
@@ -313,10 +314,92 @@ export async function startDevServer(
313
314
  watchTimer = setTimeout(maybeRecreate, 150);
314
315
  });
315
316
 
317
+ // esbuild's /esbuild live-reload stream lives on the context, so disposing it
318
+ // during a recreate drops the browser's EventSource — and Studio closes that
319
+ // connection for good on the first error. The proxy owns the downstream
320
+ // connection instead and re-subscribes to esbuild's stream across recreates,
321
+ // so live reload survives a component being added or removed.
322
+ const sseClients = new Set<http.ServerResponse>();
323
+ let upstreamReq: http.ClientRequest | null = null;
324
+ let upstreamRetry: ReturnType<typeof setTimeout> | null = null;
325
+
326
+ function broadcast(chunk: string | Buffer): void {
327
+ for (const client of sseClients) {
328
+ if (!client.writableEnded) client.write(chunk);
329
+ }
330
+ }
331
+
332
+ function connectUpstream(): void {
333
+ if (upstreamReq || sseClients.size === 0) return;
334
+ const req = http.request(
335
+ {
336
+ hostname: host,
337
+ port: internalPort,
338
+ path: "/esbuild",
339
+ method: "GET",
340
+ headers: { accept: "text/event-stream" },
341
+ },
342
+ (upstream) => {
343
+ upstream.on("data", broadcast);
344
+ upstream.on("close", onUpstreamLost);
345
+ upstream.on("error", onUpstreamLost);
346
+ },
347
+ );
348
+ req.on("error", onUpstreamLost);
349
+ req.end();
350
+ upstreamReq = req;
351
+ }
352
+
353
+ function onUpstreamLost(): void {
354
+ if (!upstreamReq) return;
355
+ upstreamReq = null;
356
+ if (upstreamRetry) clearTimeout(upstreamRetry);
357
+ if (sseClients.size === 0) return;
358
+ upstreamRetry = setTimeout(connectUpstream, 200);
359
+ }
360
+
361
+ function teardownUpstream(): void {
362
+ if (upstreamRetry) {
363
+ clearTimeout(upstreamRetry);
364
+ upstreamRetry = null;
365
+ }
366
+ const req = upstreamReq;
367
+ upstreamReq = null;
368
+ req?.destroy();
369
+ }
370
+
371
+ function notifyComponentsChanged(components: ComponentInfo[]): void {
372
+ if (sseClients.size === 0) return;
373
+ const updated = components.map((c) => `/${c.name}/index.js`);
374
+ const payload = JSON.stringify({ added: [], removed: [], updated });
375
+ broadcast(`event: change\ndata: ${payload}\n\n`);
376
+ }
377
+
316
378
  // Create proxy server that handles /bundle/* and forwards to esbuild
317
379
  const proxyServer = http.createServer(async (req, res) => {
318
380
  const url = new URL(req.url || "/", `http://${host}:${port}`);
319
381
 
382
+ // Hold the Studio live-reload stream open across esbuild recreates
383
+ if (url.pathname === "/esbuild" && req.method === "GET") {
384
+ res.writeHead(200, {
385
+ "Content-Type": "text/event-stream",
386
+ "Cache-Control": "no-cache",
387
+ Connection: "keep-alive",
388
+ "Access-Control-Allow-Origin": "*",
389
+ "Access-Control-Allow-Private-Network": "true",
390
+ });
391
+ res.write("retry: 500\n\n");
392
+ sseClients.add(res);
393
+ connectUpstream();
394
+ const dropClient = () => {
395
+ sseClients.delete(res);
396
+ if (sseClients.size === 0) teardownUpstream();
397
+ };
398
+ req.on("close", dropClient);
399
+ res.on("error", dropClient);
400
+ return;
401
+ }
402
+
320
403
  // Handle /bundle?path=/ComponentName/index.js
321
404
  if (url.pathname === "/bundle" && req.method === "GET") {
322
405
  const componentPath = url.searchParams.get("path");
@@ -520,6 +603,9 @@ export async function startDevServer(
520
603
  stop: async () => {
521
604
  if (watchTimer) clearTimeout(watchTimer);
522
605
  componentsWatcher.close();
606
+ teardownUpstream();
607
+ for (const client of sseClients) client.end();
608
+ sseClients.clear();
523
609
  proxyServer.close();
524
610
  await ctx?.dispose();
525
611
  },