@ollie-shop/cli 1.5.0 → 1.6.0

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.5.0 build /home/runner/work/ollie-shop/ollie-shop/packages/cli
2
+ > @ollie-shop/cli@1.6.0 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 105.76 KB
13
- ESM ⚡️ Build success in 353ms
12
+ ESM dist/index.js 112.10 KB
13
+ ESM ⚡️ Build success in 291ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ollie-shop/cli
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - df230be: Add a `--browser-logs` flag to `ollieshop start` that streams custom components' browser console logs and uncaught errors to the CLI terminal, attributed per component. Off by default — no console patching or overhead unless the flag is passed, and deploy bundles are unaffected.
8
+
3
9
  ## 1.5.0
4
10
 
5
11
  ### Minor Changes
package/dist/index.js CHANGED
@@ -115,6 +115,10 @@ function HelpCommand() {
115
115
  /* @__PURE__ */ jsxs(Box, { children: [
116
116
  /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "--no-open" }) }),
117
117
  /* @__PURE__ */ jsx(Text, { children: "start: don't auto-open Studio (also honored via CI env)" })
118
+ ] }),
119
+ /* @__PURE__ */ jsxs(Box, { children: [
120
+ /* @__PURE__ */ jsx(Box, { width: 24, children: /* @__PURE__ */ jsx(Text, { color: "yellow", children: "--browser-logs" }) }),
121
+ /* @__PURE__ */ jsx(Text, { children: "start: stream custom components' browser console to terminal" })
118
122
  ] })
119
123
  ] }),
120
124
  /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { bold: true, children: "Examples:" }) }),
@@ -122,6 +126,7 @@ function HelpCommand() {
122
126
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop login" }),
123
127
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --stage dev" }),
124
128
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --no-open" }),
129
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop start --browser-logs" }),
125
130
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop whoami -o json" }),
126
131
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "$ ollieshop schema store.create" }),
127
132
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: '$ ollieshop store create --name "My Store" --platform vtex --platform-store-id mystore' }),
@@ -730,8 +735,106 @@ async function discoverComponents(options = {}) {
730
735
  }
731
736
  return components;
732
737
  }
738
+ function browserLogBridge(endpoint) {
739
+ const target = JSON.stringify(endpoint);
740
+ return `(() => {
741
+ if (typeof window === "undefined" || window.__ollieLogBridge) return;
742
+ window.__ollieLogBridge = true;
743
+
744
+ const endpoint = ${target};
745
+ const SLOT_FRAME = /ollie-slot\\/([^.\\s:]+)\\.js/;
746
+ const LEVELS = ["log", "info", "warn", "error", "debug"];
747
+ const MAX_MESSAGE = 2000;
748
+ const MAX_BATCH = 20;
749
+ const FLUSH_MS = 200;
750
+
751
+ let queue = [];
752
+ let flushTimer = null;
753
+
754
+ function format(value) {
755
+ if (typeof value === "string") return value;
756
+ try {
757
+ return JSON.stringify(value);
758
+ } catch (_) {
759
+ return String(value);
760
+ }
761
+ }
762
+
763
+ function slotName(stack) {
764
+ const match = (stack || "").match(SLOT_FRAME);
765
+ return match ? match[1] : null;
766
+ }
767
+
768
+ function flush() {
769
+ if (flushTimer) {
770
+ clearTimeout(flushTimer);
771
+ flushTimer = null;
772
+ }
773
+ if (queue.length === 0) return;
774
+ const batch = queue;
775
+ queue = [];
776
+ try {
777
+ fetch(endpoint, {
778
+ method: "POST",
779
+ mode: "cors",
780
+ keepalive: true,
781
+ headers: { "Content-Type": "text/plain" },
782
+ body: JSON.stringify({ batch: batch }),
783
+ }).catch(function () {});
784
+ } catch (_) {}
785
+ }
786
+
787
+ function forward(level, component, args) {
788
+ try {
789
+ let message = args.map(format).join(" ");
790
+ if (message.length > MAX_MESSAGE) {
791
+ message = message.slice(0, MAX_MESSAGE) + " \u2026(truncated)";
792
+ }
793
+ queue.push({ level: level, component: component, message: message });
794
+ if (queue.length >= MAX_BATCH) {
795
+ flush();
796
+ } else if (!flushTimer) {
797
+ flushTimer = setTimeout(flush, FLUSH_MS);
798
+ }
799
+ } catch (_) {}
800
+ }
801
+
802
+ for (const level of LEVELS) {
803
+ const original = console[level]
804
+ ? console[level].bind(console)
805
+ : function () {};
806
+ console[level] = function () {
807
+ const args = Array.prototype.slice.call(arguments);
808
+ original.apply(null, args);
809
+ const component = slotName(new Error().stack);
810
+ if (component) forward(level, component, args);
811
+ };
812
+ }
813
+
814
+ window.addEventListener("error", function (event) {
815
+ const component = slotName(event.error && event.error.stack);
816
+ if (component) forward("error", component, [event.message]);
817
+ });
818
+
819
+ window.addEventListener("unhandledrejection", function (event) {
820
+ const reason = event.reason;
821
+ const component = slotName(reason && reason.stack);
822
+ if (component) {
823
+ const message = reason && reason.message ? reason.message : String(reason);
824
+ forward("error", component, [message]);
825
+ }
826
+ });
827
+
828
+ window.addEventListener("pagehide", flush);
829
+ })();`;
830
+ }
733
831
  async function createBuildContext(components, options = {}) {
734
- const { cwd = process.cwd(), stage, onBuildEnd } = options;
832
+ const {
833
+ cwd = process.cwd(),
834
+ stage,
835
+ onBuildEnd,
836
+ browserLogsEndpoint
837
+ } = options;
735
838
  const outdir = path4.join(cwd, "node_modules/.ollie", "build");
736
839
  await fs4.mkdir(outdir, { recursive: true });
737
840
  const entryPoints = {};
@@ -789,7 +892,8 @@ async function createBuildContext(components, options = {}) {
789
892
  logLevel: "silent",
790
893
  // We handle logging ourselves
791
894
  jsx: "automatic",
792
- plugins: [manifestPlugin]
895
+ plugins: [manifestPlugin],
896
+ ...browserLogsEndpoint ? { banner: { js: browserLogBridge(browserLogsEndpoint) } } : {}
793
897
  });
794
898
  return ctx;
795
899
  }
@@ -800,11 +904,13 @@ async function startDevServer(options = {}) {
800
904
  cwd = process.cwd(),
801
905
  stage,
802
906
  onRequest,
803
- onBuildEnd
907
+ onBuildEnd,
908
+ onBrowserLogs
804
909
  } = options;
805
910
  const servedir = path4.join(cwd, "node_modules/.ollie", "build");
806
911
  const componentsDir = path4.join(cwd, "components");
807
912
  const internalPort = port + 1;
913
+ const browserLogsEndpoint = onBrowserLogs ? `http://${host}:${port}/__log` : void 0;
808
914
  let activeStage = stage ?? "prod";
809
915
  let ctx = null;
810
916
  let entryNames = /* @__PURE__ */ new Set();
@@ -813,7 +919,8 @@ async function startDevServer(options = {}) {
813
919
  ctx = await createBuildContext(components, {
814
920
  cwd,
815
921
  stage: activeStage,
816
- onBuildEnd
922
+ onBuildEnd,
923
+ browserLogsEndpoint
817
924
  });
818
925
  await ctx.rebuild();
819
926
  await ctx.watch();
@@ -1118,6 +1225,45 @@ data: ${payload}
1118
1225
  });
1119
1226
  return;
1120
1227
  }
1228
+ if (url.pathname === "/__log" && req.method === "POST") {
1229
+ if (!onBrowserLogs) {
1230
+ res.statusCode = 404;
1231
+ res.end();
1232
+ return;
1233
+ }
1234
+ res.setHeader("Access-Control-Allow-Origin", "*");
1235
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
1236
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
1237
+ const MAX_LOG_BODY = 64 * 1024;
1238
+ let body = "";
1239
+ let aborted = false;
1240
+ req.on("data", (chunk) => {
1241
+ if (aborted) return;
1242
+ body += chunk.toString();
1243
+ if (body.length > MAX_LOG_BODY) {
1244
+ aborted = true;
1245
+ res.statusCode = 413;
1246
+ res.end();
1247
+ req.destroy();
1248
+ }
1249
+ });
1250
+ req.on("end", () => {
1251
+ if (aborted) return;
1252
+ try {
1253
+ const parsed2 = JSON.parse(body);
1254
+ const batch = parsed2.batch;
1255
+ const candidates = Array.isArray(batch) ? batch : [parsed2];
1256
+ const entries = candidates.filter(
1257
+ (entry) => typeof entry?.message === "string"
1258
+ );
1259
+ if (entries.length > 0) onBrowserLogs(entries);
1260
+ } catch {
1261
+ }
1262
+ res.statusCode = 204;
1263
+ res.end();
1264
+ });
1265
+ return;
1266
+ }
1121
1267
  if (req.method === "OPTIONS") {
1122
1268
  res.setHeader("Access-Control-Allow-Origin", "*");
1123
1269
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -1222,13 +1368,16 @@ function StartCommand({ args }) {
1222
1368
  const [state, setState] = useState2({ status: "initializing" });
1223
1369
  const [components, setComponents] = useState2([]);
1224
1370
  const [logs, setLogs] = useState2([]);
1371
+ const [browserLogs, setBrowserLogs] = useState2([]);
1225
1372
  const [buildCount, setBuildCount] = useState2(0);
1226
1373
  const [lastBuildTime, setLastBuildTime] = useState2(null);
1227
1374
  const logIdRef = useRef(0);
1375
+ const browserLogIdRef = useRef(0);
1228
1376
  const rebuildRef = useRef(null);
1229
1377
  const stopRef = useRef(null);
1230
1378
  const stage = resolveStage(parseArg(args, "--stage", "-s"));
1231
1379
  const noOpen = args.includes("--no-open") || Boolean(process.env.CI);
1380
+ const browserLogsEnabled = args.includes("--browser-logs");
1232
1381
  const isInteractive = Boolean(process.stdin.isTTY);
1233
1382
  const addLog = useCallback((log) => {
1234
1383
  setLogs((prev) => {
@@ -1251,6 +1400,18 @@ function StartCommand({ args }) {
1251
1400
  },
1252
1401
  [addLog]
1253
1402
  );
1403
+ const addBrowserLogs = useCallback((entries) => {
1404
+ if (entries.length === 0) return;
1405
+ setBrowserLogs((prev) => {
1406
+ const mapped = entries.map((entry) => ({
1407
+ ...entry,
1408
+ message: sanitizeLogMessage(entry.message),
1409
+ id: ++browserLogIdRef.current,
1410
+ timestamp: /* @__PURE__ */ new Date()
1411
+ }));
1412
+ return [...prev, ...mapped].slice(-MAX_LOGS);
1413
+ });
1414
+ }, []);
1254
1415
  useEffect2(() => {
1255
1416
  let mounted = true;
1256
1417
  async function init() {
@@ -1281,6 +1442,7 @@ function StartCommand({ args }) {
1281
1442
  port: PORT,
1282
1443
  stage,
1283
1444
  onRequest: handleRequest,
1445
+ onBrowserLogs: browserLogsEnabled ? addBrowserLogs : void 0,
1284
1446
  onBuildEnd: (updatedComponents) => {
1285
1447
  setComponents(updatedComponents);
1286
1448
  setBuildCount((c) => c + 1);
@@ -1321,7 +1483,7 @@ function StartCommand({ args }) {
1321
1483
  mounted = false;
1322
1484
  stopRef.current?.();
1323
1485
  };
1324
- }, [stage, handleRequest, noOpen]);
1486
+ }, [stage, handleRequest, addBrowserLogs, browserLogsEnabled, noOpen]);
1325
1487
  useInput(
1326
1488
  (input, key) => {
1327
1489
  if (input === "q" || input === "c" && key.ctrl) {
@@ -1378,6 +1540,7 @@ function StartCommand({ args }) {
1378
1540
  /* @__PURE__ */ jsx3(ComponentList, { components }),
1379
1541
  /* @__PURE__ */ jsx3(BuildInfo, { buildCount, lastBuildTime }),
1380
1542
  /* @__PURE__ */ jsx3(RequestLogs, { logs }),
1543
+ browserLogsEnabled && /* @__PURE__ */ jsx3(BrowserLogs, { logs: browserLogs }),
1381
1544
  /* @__PURE__ */ jsx3(Footer, { interactive: isInteractive })
1382
1545
  ] })
1383
1546
  ] });
@@ -1506,6 +1669,38 @@ function RequestLogs({ logs }) {
1506
1669
  ] }, log.id)) })
1507
1670
  ] });
1508
1671
  }
1672
+ function sanitizeLogMessage(message) {
1673
+ let out = "";
1674
+ for (const ch of message) {
1675
+ const code = ch.codePointAt(0) ?? 0;
1676
+ out += code < 32 || code >= 127 && code <= 159 ? " " : ch;
1677
+ if (out.length >= 2e3) break;
1678
+ }
1679
+ return out;
1680
+ }
1681
+ function browserLogColor(level) {
1682
+ if (level === "error") return "red";
1683
+ if (level === "warn") return "yellow";
1684
+ if (level === "info") return "cyan";
1685
+ return "gray";
1686
+ }
1687
+ function BrowserLogs({ logs }) {
1688
+ return /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, flexDirection: "column", children: [
1689
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Browser Logs:" }),
1690
+ /* @__PURE__ */ jsx3(Box3, { marginLeft: 2, flexDirection: "column", children: logs.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No browser logs yet..." }) : logs.map((log) => /* @__PURE__ */ jsxs3(Box3, { children: [
1691
+ /* @__PURE__ */ jsxs3(Text3, { color: browserLogColor(log.level), children: [
1692
+ log.level.toUpperCase(),
1693
+ " "
1694
+ ] }),
1695
+ log.component && /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1696
+ "[",
1697
+ log.component,
1698
+ "] "
1699
+ ] }),
1700
+ /* @__PURE__ */ jsx3(Text3, { children: log.message })
1701
+ ] }, log.id)) })
1702
+ ] });
1703
+ }
1509
1704
  function Footer({ interactive = true }) {
1510
1705
  if (!interactive) {
1511
1706
  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" }) });
@@ -1542,7 +1737,7 @@ function App({ command, args }) {
1542
1737
  }
1543
1738
  }
1544
1739
  function VersionCommand() {
1545
- const version = "1.5.0" ? "1.5.0" : "unknown";
1740
+ const version = "1.6.0" ? "1.6.0" : "unknown";
1546
1741
  return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { children: [
1547
1742
  "ollieshop v",
1548
1743
  version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ollie-shop/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Ollie Shop CLI - Development tools for custom checkouts",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -151,6 +151,14 @@ export function HelpCommand() {
151
151
  </Box>
152
152
  <Text>start: don't auto-open Studio (also honored via CI env)</Text>
153
153
  </Box>
154
+ <Box>
155
+ <Box width={24}>
156
+ <Text color="yellow">--browser-logs</Text>
157
+ </Box>
158
+ <Text>
159
+ start: stream custom components' browser console to terminal
160
+ </Text>
161
+ </Box>
154
162
  </Box>
155
163
 
156
164
  <Box marginTop={1}>
@@ -160,6 +168,7 @@ export function HelpCommand() {
160
168
  <Text dimColor>$ ollieshop login</Text>
161
169
  <Text dimColor>$ ollieshop start --stage dev</Text>
162
170
  <Text dimColor>$ ollieshop start --no-open</Text>
171
+ <Text dimColor>$ ollieshop start --browser-logs</Text>
163
172
  <Text dimColor>$ ollieshop whoami -o json</Text>
164
173
  <Text dimColor>$ ollieshop schema store.create</Text>
165
174
  <Text dimColor>
@@ -4,6 +4,7 @@ import open from "open";
4
4
  import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import { loadConfig, resolveStage } from "../utils/config.js";
6
6
  import {
7
+ type BrowserLogEntry,
7
8
  type ComponentInfo,
8
9
  discoverComponents,
9
10
  startDevServer,
@@ -22,6 +23,14 @@ interface RequestLog {
22
23
  timestamp: Date;
23
24
  }
24
25
 
26
+ interface BrowserLog {
27
+ id: number;
28
+ level: string;
29
+ component?: string;
30
+ message: string;
31
+ timestamp: Date;
32
+ }
33
+
25
34
  type ServerState =
26
35
  | { status: "initializing" }
27
36
  | { status: "discovering" }
@@ -51,9 +60,11 @@ export function StartCommand({ args }: StartCommandProps) {
51
60
  const [state, setState] = useState<ServerState>({ status: "initializing" });
52
61
  const [components, setComponents] = useState<ComponentInfo[]>([]);
53
62
  const [logs, setLogs] = useState<RequestLog[]>([]);
63
+ const [browserLogs, setBrowserLogs] = useState<BrowserLog[]>([]);
54
64
  const [buildCount, setBuildCount] = useState(0);
55
65
  const [lastBuildTime, setLastBuildTime] = useState<Date | null>(null);
56
66
  const logIdRef = useRef(0);
67
+ const browserLogIdRef = useRef(0);
57
68
  const rebuildRef = useRef<(() => Promise<void>) | null>(null);
58
69
  const stopRef = useRef<(() => Promise<void>) | null>(null);
59
70
 
@@ -63,6 +74,7 @@ export function StartCommand({ args }: StartCommandProps) {
63
74
  // agent spawns the dev server and drives its own harness instead. Also honored
64
75
  // via the conventional CI env var.
65
76
  const noOpen = args.includes("--no-open") || Boolean(process.env.CI);
77
+ const browserLogsEnabled = args.includes("--browser-logs");
66
78
  // Keyboard shortcuts need raw mode, which Ink can only enable on a TTY stdin.
67
79
  // When spawned headless (agent/CI/backgrounded), stdin isn't a TTY — guard the
68
80
  // input handler so the server runs instead of crashing with "Raw mode is not
@@ -92,6 +104,19 @@ export function StartCommand({ args }: StartCommandProps) {
92
104
  [addLog],
93
105
  );
94
106
 
107
+ const addBrowserLogs = useCallback((entries: BrowserLogEntry[]) => {
108
+ if (entries.length === 0) return;
109
+ setBrowserLogs((prev) => {
110
+ const mapped = entries.map((entry) => ({
111
+ ...entry,
112
+ message: sanitizeLogMessage(entry.message),
113
+ id: ++browserLogIdRef.current,
114
+ timestamp: new Date(),
115
+ }));
116
+ return [...prev, ...mapped].slice(-MAX_LOGS);
117
+ });
118
+ }, []);
119
+
95
120
  // Initialize server
96
121
  useEffect(() => {
97
122
  let mounted = true;
@@ -134,6 +159,7 @@ export function StartCommand({ args }: StartCommandProps) {
134
159
  port: PORT,
135
160
  stage,
136
161
  onRequest: handleRequest,
162
+ onBrowserLogs: browserLogsEnabled ? addBrowserLogs : undefined,
137
163
  onBuildEnd: (updatedComponents) => {
138
164
  setComponents(updatedComponents);
139
165
  setBuildCount((c) => c + 1);
@@ -181,7 +207,7 @@ export function StartCommand({ args }: StartCommandProps) {
181
207
  mounted = false;
182
208
  stopRef.current?.();
183
209
  };
184
- }, [stage, handleRequest, noOpen]);
210
+ }, [stage, handleRequest, addBrowserLogs, browserLogsEnabled, noOpen]);
185
211
 
186
212
  // Handle keyboard input (only when attached to a TTY — see isInteractive above)
187
213
  useInput(
@@ -255,6 +281,7 @@ export function StartCommand({ args }: StartCommandProps) {
255
281
  <ComponentList components={components} />
256
282
  <BuildInfo buildCount={buildCount} lastBuildTime={lastBuildTime} />
257
283
  <RequestLogs logs={logs} />
284
+ {browserLogsEnabled && <BrowserLogs logs={browserLogs} />}
258
285
  <Footer interactive={isInteractive} />
259
286
  </>
260
287
  )}
@@ -404,6 +431,46 @@ function RequestLogs({ logs }: { logs: RequestLog[] }) {
404
431
  );
405
432
  }
406
433
 
434
+ function sanitizeLogMessage(message: string): string {
435
+ let out = "";
436
+ for (const ch of message) {
437
+ const code = ch.codePointAt(0) ?? 0;
438
+ out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch;
439
+ if (out.length >= 2000) break;
440
+ }
441
+ return out;
442
+ }
443
+
444
+ function browserLogColor(level: string): string {
445
+ if (level === "error") return "red";
446
+ if (level === "warn") return "yellow";
447
+ if (level === "info") return "cyan";
448
+ return "gray";
449
+ }
450
+
451
+ function BrowserLogs({ logs }: { logs: BrowserLog[] }) {
452
+ return (
453
+ <Box marginTop={1} flexDirection="column">
454
+ <Text bold>Browser Logs:</Text>
455
+ <Box marginLeft={2} flexDirection="column">
456
+ {logs.length === 0 ? (
457
+ <Text dimColor>No browser logs yet...</Text>
458
+ ) : (
459
+ logs.map((log) => (
460
+ <Box key={log.id}>
461
+ <Text color={browserLogColor(log.level)}>
462
+ {log.level.toUpperCase()}{" "}
463
+ </Text>
464
+ {log.component && <Text dimColor>[{log.component}] </Text>}
465
+ <Text>{log.message}</Text>
466
+ </Box>
467
+ ))
468
+ )}
469
+ </Box>
470
+ </Box>
471
+ );
472
+ }
473
+
407
474
  function Footer({ interactive = true }: { interactive?: boolean }) {
408
475
  if (!interactive) {
409
476
  return (
@@ -213,10 +213,111 @@ export interface BuildResult {
213
213
  buildTime: number;
214
214
  }
215
215
 
216
+ export interface BrowserLogEntry {
217
+ level: string;
218
+ component?: string;
219
+ message: string;
220
+ }
221
+
222
+ function browserLogBridge(endpoint: string): string {
223
+ const target = JSON.stringify(endpoint);
224
+ return `(() => {
225
+ if (typeof window === "undefined" || window.__ollieLogBridge) return;
226
+ window.__ollieLogBridge = true;
227
+
228
+ const endpoint = ${target};
229
+ const SLOT_FRAME = /ollie-slot\\/([^.\\s:]+)\\.js/;
230
+ const LEVELS = ["log", "info", "warn", "error", "debug"];
231
+ const MAX_MESSAGE = 2000;
232
+ const MAX_BATCH = 20;
233
+ const FLUSH_MS = 200;
234
+
235
+ let queue = [];
236
+ let flushTimer = null;
237
+
238
+ function format(value) {
239
+ if (typeof value === "string") return value;
240
+ try {
241
+ return JSON.stringify(value);
242
+ } catch (_) {
243
+ return String(value);
244
+ }
245
+ }
246
+
247
+ function slotName(stack) {
248
+ const match = (stack || "").match(SLOT_FRAME);
249
+ return match ? match[1] : null;
250
+ }
251
+
252
+ function flush() {
253
+ if (flushTimer) {
254
+ clearTimeout(flushTimer);
255
+ flushTimer = null;
256
+ }
257
+ if (queue.length === 0) return;
258
+ const batch = queue;
259
+ queue = [];
260
+ try {
261
+ fetch(endpoint, {
262
+ method: "POST",
263
+ mode: "cors",
264
+ keepalive: true,
265
+ headers: { "Content-Type": "text/plain" },
266
+ body: JSON.stringify({ batch: batch }),
267
+ }).catch(function () {});
268
+ } catch (_) {}
269
+ }
270
+
271
+ function forward(level, component, args) {
272
+ try {
273
+ let message = args.map(format).join(" ");
274
+ if (message.length > MAX_MESSAGE) {
275
+ message = message.slice(0, MAX_MESSAGE) + " …(truncated)";
276
+ }
277
+ queue.push({ level: level, component: component, message: message });
278
+ if (queue.length >= MAX_BATCH) {
279
+ flush();
280
+ } else if (!flushTimer) {
281
+ flushTimer = setTimeout(flush, FLUSH_MS);
282
+ }
283
+ } catch (_) {}
284
+ }
285
+
286
+ for (const level of LEVELS) {
287
+ const original = console[level]
288
+ ? console[level].bind(console)
289
+ : function () {};
290
+ console[level] = function () {
291
+ const args = Array.prototype.slice.call(arguments);
292
+ original.apply(null, args);
293
+ const component = slotName(new Error().stack);
294
+ if (component) forward(level, component, args);
295
+ };
296
+ }
297
+
298
+ window.addEventListener("error", function (event) {
299
+ const component = slotName(event.error && event.error.stack);
300
+ if (component) forward("error", component, [event.message]);
301
+ });
302
+
303
+ window.addEventListener("unhandledrejection", function (event) {
304
+ const reason = event.reason;
305
+ const component = slotName(reason && reason.stack);
306
+ if (component) {
307
+ const message = reason && reason.message ? reason.message : String(reason);
308
+ forward("error", component, [message]);
309
+ }
310
+ });
311
+
312
+ window.addEventListener("pagehide", flush);
313
+ })();`;
314
+ }
315
+
216
316
  export interface CreateBuildContextOptions {
217
317
  cwd?: string;
218
318
  stage?: string;
219
319
  onBuildEnd?: (components: ComponentInfo[], result: BuildResult) => void;
320
+ browserLogsEndpoint?: string;
220
321
  }
221
322
 
222
323
  /**
@@ -227,7 +328,12 @@ export async function createBuildContext(
227
328
  components: ComponentInfo[],
228
329
  options: CreateBuildContextOptions = {},
229
330
  ): Promise<esbuild.BuildContext> {
230
- const { cwd = process.cwd(), stage, onBuildEnd } = options;
331
+ const {
332
+ cwd = process.cwd(),
333
+ stage,
334
+ onBuildEnd,
335
+ browserLogsEndpoint,
336
+ } = options;
231
337
  const outdir = path.join(cwd, "node_modules/.ollie", "build");
232
338
 
233
339
  // Ensure output directory exists
@@ -303,6 +409,9 @@ export async function createBuildContext(
303
409
  logLevel: "silent", // We handle logging ourselves
304
410
  jsx: "automatic",
305
411
  plugins: [manifestPlugin],
412
+ ...(browserLogsEndpoint
413
+ ? { banner: { js: browserLogBridge(browserLogsEndpoint) } }
414
+ : {}),
306
415
  });
307
416
 
308
417
  return ctx;
@@ -327,6 +436,7 @@ export async function startDevServer(
327
436
  stage?: string;
328
437
  onRequest?: (args: esbuild.ServeOnRequestArgs) => void;
329
438
  onBuildEnd?: (components: ComponentInfo[], result: BuildResult) => void;
439
+ onBrowserLogs?: (entries: BrowserLogEntry[]) => void;
330
440
  } = {},
331
441
  ): Promise<
332
442
  ServeResult & { rebuild: () => Promise<void>; stop: () => Promise<void> }
@@ -338,11 +448,15 @@ export async function startDevServer(
338
448
  stage,
339
449
  onRequest,
340
450
  onBuildEnd,
451
+ onBrowserLogs,
341
452
  } = options;
342
453
 
343
454
  const servedir = path.join(cwd, "node_modules/.ollie", "build");
344
455
  const componentsDir = path.join(cwd, "components");
345
456
  const internalPort = port + 1;
457
+ const browserLogsEndpoint = onBrowserLogs
458
+ ? `http://${host}:${port}/__log`
459
+ : undefined;
346
460
 
347
461
  // The active stage is mutable: the admin can switch it at runtime via POST /stage.
348
462
  let activeStage = stage ?? "prod";
@@ -356,6 +470,7 @@ export async function startDevServer(
356
470
  cwd,
357
471
  stage: activeStage,
358
472
  onBuildEnd,
473
+ browserLogsEndpoint,
359
474
  });
360
475
  await ctx.rebuild();
361
476
  await ctx.watch();
@@ -731,6 +846,50 @@ export async function startDevServer(
731
846
  return;
732
847
  }
733
848
 
849
+ if (url.pathname === "/__log" && req.method === "POST") {
850
+ if (!onBrowserLogs) {
851
+ res.statusCode = 404;
852
+ res.end();
853
+ return;
854
+ }
855
+
856
+ res.setHeader("Access-Control-Allow-Origin", "*");
857
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
858
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
859
+
860
+ const MAX_LOG_BODY = 64 * 1024;
861
+ let body = "";
862
+ let aborted = false;
863
+ req.on("data", (chunk) => {
864
+ if (aborted) return;
865
+ body += chunk.toString();
866
+ if (body.length > MAX_LOG_BODY) {
867
+ aborted = true;
868
+ res.statusCode = 413;
869
+ res.end();
870
+ req.destroy();
871
+ }
872
+ });
873
+ req.on("end", () => {
874
+ if (aborted) return;
875
+ try {
876
+ const parsed: unknown = JSON.parse(body);
877
+ const batch = (parsed as { batch?: unknown }).batch;
878
+ const candidates: unknown[] = Array.isArray(batch) ? batch : [parsed];
879
+ const entries = candidates.filter(
880
+ (entry): entry is BrowserLogEntry =>
881
+ typeof (entry as BrowserLogEntry)?.message === "string",
882
+ );
883
+ if (entries.length > 0) onBrowserLogs(entries);
884
+ } catch {
885
+ // Ignore malformed log payloads
886
+ }
887
+ res.statusCode = 204;
888
+ res.end();
889
+ });
890
+ return;
891
+ }
892
+
734
893
  // Handle CORS preflight
735
894
  if (req.method === "OPTIONS") {
736
895
  res.setHeader("Access-Control-Allow-Origin", "*");