lopata 0.6.0 → 0.7.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.
@@ -5746,14 +5746,11 @@ function ErrorList() {
5746
5746
  /* @__PURE__ */ u3("td", {
5747
5747
  class: "px-4 py-2.5 text-right",
5748
5748
  children: err.traceId ? /* @__PURE__ */ u3("a", {
5749
- href: `#/traces?trace=${err.traceId}`,
5749
+ href: `#/traces/${err.traceId}`,
5750
5750
  onClick: (e3) => e3.stopPropagation(),
5751
5751
  class: "text-link hover:text-accent-lime text-xs font-mono",
5752
- children: [
5753
- err.traceId.slice(0, 8),
5754
- "..."
5755
- ]
5756
- }, undefined, true, undefined, this) : /* @__PURE__ */ u3("span", {
5752
+ children: err.traceId
5753
+ }, undefined, false, undefined, this) : /* @__PURE__ */ u3("span", {
5757
5754
  class: "text-text-dim text-xs",
5758
5755
  children: "-"
5759
5756
  }, undefined, false, undefined, this)
@@ -5911,14 +5908,30 @@ function ErrorDetailPage({ errorId }) {
5911
5908
  }, undefined, false, undefined, this)
5912
5909
  }, undefined, false, undefined, this)
5913
5910
  }, undefined, false, undefined, this),
5914
- traceSpans && traceSpans.length > 0 && /* @__PURE__ */ u3(Section, {
5911
+ detail.traceId && /* @__PURE__ */ u3(Section, {
5915
5912
  title: "Trace",
5916
5913
  open: true,
5917
- children: /* @__PURE__ */ u3(SimpleTraceWaterfall, {
5918
- spans: traceSpans,
5919
- highlightSpanId: detail.spanId
5920
- }, undefined, false, undefined, this)
5921
- }, undefined, false, undefined, this),
5914
+ children: [
5915
+ /* @__PURE__ */ u3("div", {
5916
+ class: "px-4 py-2.5 border-b border-border-subtle flex items-center gap-2",
5917
+ children: [
5918
+ /* @__PURE__ */ u3("span", {
5919
+ class: "text-xs text-text-muted",
5920
+ children: "Trace ID:"
5921
+ }, undefined, false, undefined, this),
5922
+ /* @__PURE__ */ u3("a", {
5923
+ href: `#/traces/${detail.traceId}`,
5924
+ class: "text-link hover:text-accent-lime text-xs font-mono",
5925
+ children: detail.traceId
5926
+ }, undefined, false, undefined, this)
5927
+ ]
5928
+ }, undefined, true, undefined, this),
5929
+ traceSpans && traceSpans.length > 0 && /* @__PURE__ */ u3(SimpleTraceWaterfall, {
5930
+ spans: traceSpans,
5931
+ highlightSpanId: detail.spanId
5932
+ }, undefined, false, undefined, this)
5933
+ ]
5934
+ }, undefined, true, undefined, this),
5922
5935
  data.request.method && data.request.url && /* @__PURE__ */ u3(Section, {
5923
5936
  title: "Request",
5924
5937
  open: true,
@@ -7582,6 +7595,14 @@ function ScheduledList() {
7582
7595
  }
7583
7596
 
7584
7597
  // src/dashboard/views/traces.tsx
7598
+ var SOURCE_BADGE_STYLES = {
7599
+ fetch: { bg: "var(--color-badge-blue-bg)", color: "var(--color-badge-blue-text)" },
7600
+ scheduled: { bg: "var(--color-badge-purple-bg)", color: "var(--color-badge-purple-text)" },
7601
+ queue: { bg: "var(--color-badge-orange-bg)", color: "var(--color-badge-orange-text)" },
7602
+ alarm: { bg: "var(--color-badge-yellow-bg)", color: "var(--color-badge-yellow-text)" },
7603
+ workflow: { bg: "var(--color-badge-emerald-bg)", color: "var(--color-badge-emerald-text)" }
7604
+ };
7605
+ var DEFAULT_BADGE_STYLE = { bg: "var(--color-badge-red-bg)", color: "var(--color-badge-red-text)" };
7585
7606
  var eventListeners = new Set;
7586
7607
  function onTraceEvents(fn) {
7587
7608
  eventListeners.add(fn);
@@ -7596,6 +7617,39 @@ function emitTraceEvents(events) {
7596
7617
  } catch {}
7597
7618
  }
7598
7619
  }
7620
+ function useTraceData(traceId) {
7621
+ const [spans, setSpans] = d2([]);
7622
+ const [events, setEvents] = d2([]);
7623
+ const [traceErrors, setTraceErrors] = d2([]);
7624
+ const [isLoading, setIsLoading] = d2(true);
7625
+ y2(() => {
7626
+ setIsLoading(true);
7627
+ rpc("traces.getTrace", { traceId }).then((data) => {
7628
+ setSpans(data.spans);
7629
+ setEvents(data.events);
7630
+ setIsLoading(false);
7631
+ });
7632
+ rpc("traces.errors", { traceId }).then(setTraceErrors).catch(() => {});
7633
+ }, [traceId]);
7634
+ y2(() => {
7635
+ return onTraceEvents((traceEvents) => {
7636
+ for (const ev of traceEvents) {
7637
+ if (ev.type === "span.start" && ev.span.traceId === traceId) {
7638
+ setSpans((prev) => {
7639
+ if (prev.some((s3) => s3.spanId === ev.span.spanId))
7640
+ return prev;
7641
+ return [...prev, ev.span];
7642
+ });
7643
+ } else if (ev.type === "span.end" && ev.span.traceId === traceId) {
7644
+ setSpans((prev) => prev.map((s3) => s3.spanId === ev.span.spanId ? ev.span : s3));
7645
+ } else if (ev.type === "span.event" && ev.event.traceId === traceId) {
7646
+ setEvents((prev) => [...prev, ev.event]);
7647
+ }
7648
+ }
7649
+ });
7650
+ }, [traceId]);
7651
+ return { spans, events, traceErrors, isLoading };
7652
+ }
7599
7653
  function useTraceStream() {
7600
7654
  const [traces, setTraces] = d2(new Map);
7601
7655
  const [wsStatus, setWsStatus] = d2("connecting");
@@ -7707,7 +7761,16 @@ var TIME_RANGE_OPTIONS = [
7707
7761
  { label: "24h", ms: 24 * 60 * 60 * 1000 },
7708
7762
  { label: "All", ms: 0 }
7709
7763
  ];
7710
- function TracesView() {
7764
+ function TracesView({ route }) {
7765
+ const traceIdFromRoute = route.startsWith("/traces/") ? route.slice("/traces/".length) : null;
7766
+ if (traceIdFromRoute) {
7767
+ return /* @__PURE__ */ u3(TraceDetailPage, {
7768
+ traceId: traceIdFromRoute
7769
+ }, undefined, false, undefined, this);
7770
+ }
7771
+ return /* @__PURE__ */ u3(TracesListView, {}, undefined, false, undefined, this);
7772
+ }
7773
+ function TracesListView() {
7711
7774
  const { traces, setFilter, wsStatus } = useTraceStream();
7712
7775
  const [selectedTraceId, setSelectedTraceId] = d2(null);
7713
7776
  const [pathFilter, setPathFilter] = d2("");
@@ -7794,10 +7857,7 @@ function TracesView() {
7794
7857
  ]
7795
7858
  }, undefined, true, undefined, this),
7796
7859
  /* @__PURE__ */ u3("button", {
7797
- onClick: () => {
7798
- clearTraces.mutate();
7799
- setSelectedTraceId(null);
7800
- },
7860
+ onClick: () => clearTraces.mutate(),
7801
7861
  class: "rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
7802
7862
  children: "Clear all"
7803
7863
  }, undefined, false, undefined, this)
@@ -8001,6 +8061,132 @@ function TracesView() {
8001
8061
  ]
8002
8062
  }, undefined, true, undefined, this);
8003
8063
  }
8064
+ function TraceDetailPage({ traceId }) {
8065
+ const { spans, events, traceErrors, isLoading } = useTraceData(traceId);
8066
+ const rootSpan = spans.find((s3) => !s3.parentSpanId);
8067
+ return /* @__PURE__ */ u3("div", {
8068
+ class: "p-4 sm:p-8 h-full flex flex-col",
8069
+ children: [
8070
+ /* @__PURE__ */ u3("div", {
8071
+ class: "flex items-center gap-3 mb-6",
8072
+ children: [
8073
+ /* @__PURE__ */ u3("button", {
8074
+ onClick: () => navigate("/traces"),
8075
+ class: "flex items-center gap-1.5 text-sm text-text-secondary hover:text-ink transition-colors",
8076
+ children: [
8077
+ /* @__PURE__ */ u3("svg", {
8078
+ class: "w-4 h-4",
8079
+ viewBox: "0 0 20 20",
8080
+ fill: "currentColor",
8081
+ children: /* @__PURE__ */ u3("path", {
8082
+ "fill-rule": "evenodd",
8083
+ d: "M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z",
8084
+ "clip-rule": "evenodd"
8085
+ }, undefined, false, undefined, this)
8086
+ }, undefined, false, undefined, this),
8087
+ "Back to traces"
8088
+ ]
8089
+ }, undefined, true, undefined, this),
8090
+ /* @__PURE__ */ u3("div", {
8091
+ class: "h-5 w-px bg-border"
8092
+ }, undefined, false, undefined, this),
8093
+ /* @__PURE__ */ u3("div", {
8094
+ children: [
8095
+ /* @__PURE__ */ u3("div", {
8096
+ class: "flex items-center gap-2",
8097
+ children: [
8098
+ rootSpan && /* @__PURE__ */ u3(TraceStatusBadge, {
8099
+ status: rootSpan.status
8100
+ }, undefined, false, undefined, this),
8101
+ /* @__PURE__ */ u3("h1", {
8102
+ class: "text-lg font-bold text-ink",
8103
+ children: rootSpan?.name ?? "Loading..."
8104
+ }, undefined, false, undefined, this)
8105
+ ]
8106
+ }, undefined, true, undefined, this),
8107
+ /* @__PURE__ */ u3("div", {
8108
+ class: "text-xs text-text-muted font-mono mt-0.5",
8109
+ children: [
8110
+ "Trace ",
8111
+ traceId,
8112
+ rootSpan?.workerName && /* @__PURE__ */ u3("span", {
8113
+ class: "ml-2 inline-flex px-2 py-0.5 rounded-md text-xs font-medium bg-panel-hover text-text-secondary",
8114
+ children: rootSpan.workerName
8115
+ }, undefined, false, undefined, this),
8116
+ rootSpan?.durationMs != null && /* @__PURE__ */ u3("span", {
8117
+ class: "ml-2 text-text-secondary",
8118
+ children: formatDuration(rootSpan.durationMs)
8119
+ }, undefined, false, undefined, this)
8120
+ ]
8121
+ }, undefined, true, undefined, this)
8122
+ ]
8123
+ }, undefined, true, undefined, this)
8124
+ ]
8125
+ }, undefined, true, undefined, this),
8126
+ /* @__PURE__ */ u3("div", {
8127
+ class: "flex-1 overflow-y-auto scrollbar-thin min-h-0",
8128
+ children: isLoading ? /* @__PURE__ */ u3("div", {
8129
+ class: "text-text-muted text-sm py-12 text-center",
8130
+ children: "Loading trace..."
8131
+ }, undefined, false, undefined, this) : /* @__PURE__ */ u3("div", {
8132
+ children: [
8133
+ traceErrors.length > 0 && /* @__PURE__ */ u3("div", {
8134
+ class: "mb-4",
8135
+ children: [
8136
+ /* @__PURE__ */ u3("div", {
8137
+ class: "text-xs font-medium text-text-muted uppercase tracking-wider mb-2",
8138
+ children: [
8139
+ "Errors (",
8140
+ traceErrors.length,
8141
+ ")"
8142
+ ]
8143
+ }, undefined, true, undefined, this),
8144
+ /* @__PURE__ */ u3("div", {
8145
+ class: "space-y-1",
8146
+ children: traceErrors.map((err) => /* @__PURE__ */ u3("a", {
8147
+ href: `#/errors/${err.id}`,
8148
+ class: "flex items-center gap-2 px-3 py-2 rounded-md text-xs no-underline transition-colors",
8149
+ style: { background: "var(--color-error-highlight)", borderColor: "var(--color-error-ring)" },
8150
+ children: [
8151
+ err.source && (() => {
8152
+ const s3 = SOURCE_BADGE_STYLES[err.source] ?? DEFAULT_BADGE_STYLE;
8153
+ return /* @__PURE__ */ u3("span", {
8154
+ class: "inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium",
8155
+ style: { background: s3.bg, color: s3.color },
8156
+ children: err.source
8157
+ }, undefined, false, undefined, this);
8158
+ })(),
8159
+ /* @__PURE__ */ u3("span", {
8160
+ class: "font-medium",
8161
+ style: { color: "var(--color-badge-red-text)" },
8162
+ children: err.errorName
8163
+ }, undefined, false, undefined, this),
8164
+ /* @__PURE__ */ u3("span", {
8165
+ style: { color: "var(--color-badge-red-text)" },
8166
+ class: "truncate",
8167
+ children: err.errorMessage
8168
+ }, undefined, false, undefined, this),
8169
+ /* @__PURE__ */ u3("span", {
8170
+ style: { color: "var(--color-badge-red-text)", opacity: 0.7 },
8171
+ class: "font-mono ml-auto flex-shrink-0",
8172
+ children: formatTimestamp(err.timestamp)
8173
+ }, undefined, false, undefined, this)
8174
+ ]
8175
+ }, err.id, true, undefined, this))
8176
+ }, undefined, false, undefined, this)
8177
+ ]
8178
+ }, undefined, true, undefined, this),
8179
+ /* @__PURE__ */ u3(TraceWaterfall, {
8180
+ spans,
8181
+ events,
8182
+ onAddAttributeFilter: () => {}
8183
+ }, undefined, false, undefined, this)
8184
+ ]
8185
+ }, undefined, true, undefined, this)
8186
+ }, undefined, false, undefined, this)
8187
+ ]
8188
+ }, undefined, true, undefined, this);
8189
+ }
8004
8190
  function SpansListTab() {
8005
8191
  const [spans, setSpans] = d2([]);
8006
8192
  const [cursor, setCursor] = d2(null);
@@ -8098,11 +8284,8 @@ function SpansListTab() {
8098
8284
  children: /* @__PURE__ */ u3("button", {
8099
8285
  onClick: () => setSelectedTraceId(span.traceId),
8100
8286
  class: "text-link hover:text-accent-lime text-xs font-mono",
8101
- children: [
8102
- span.traceId.slice(0, 8),
8103
- "..."
8104
- ]
8105
- }, undefined, true, undefined, this)
8287
+ children: span.traceId
8288
+ }, undefined, false, undefined, this)
8106
8289
  }, undefined, false, undefined, this)
8107
8290
  ]
8108
8291
  }, span.spanId, true, undefined, this))
@@ -8126,8 +8309,7 @@ function SpansListTab() {
8126
8309
  }, undefined, false, undefined, this),
8127
8310
  selectedTraceId && /* @__PURE__ */ u3(TraceDrawer, {
8128
8311
  traceId: selectedTraceId,
8129
- onClose: () => setSelectedTraceId(null),
8130
- onAddAttributeFilter: () => {}
8312
+ onClose: () => setSelectedTraceId(null)
8131
8313
  }, undefined, false, undefined, this)
8132
8314
  ]
8133
8315
  }, undefined, true, undefined, this);
@@ -8136,6 +8318,7 @@ function LogsListTab() {
8136
8318
  const [logs, setLogs] = d2([]);
8137
8319
  const [cursor, setCursor] = d2(null);
8138
8320
  const [isLoading, setIsLoading] = d2(true);
8321
+ const [selectedTraceId, setSelectedTraceId] = d2(null);
8139
8322
  const loadLogs = q2((cur) => {
8140
8323
  setIsLoading(true);
8141
8324
  rpc("traces.listLogs", { limit: 50, cursor: cur }).then((data) => {
@@ -8216,12 +8399,13 @@ function LogsListTab() {
8216
8399
  children: formatTimestamp(log.timestamp)
8217
8400
  }, undefined, false, undefined, this),
8218
8401
  /* @__PURE__ */ u3("td", {
8219
- class: "px-4 py-2.5 text-right font-mono text-xs text-text-muted",
8220
- children: [
8221
- log.traceId.slice(0, 8),
8222
- "..."
8223
- ]
8224
- }, undefined, true, undefined, this)
8402
+ class: "px-4 py-2.5 text-right",
8403
+ children: /* @__PURE__ */ u3("button", {
8404
+ onClick: () => setSelectedTraceId(log.traceId),
8405
+ class: "text-link hover:text-accent-lime text-xs font-mono",
8406
+ children: log.traceId
8407
+ }, undefined, false, undefined, this)
8408
+ }, undefined, false, undefined, this)
8225
8409
  ]
8226
8410
  }, log.id, true, undefined, this))
8227
8411
  }, undefined, false, undefined, this)
@@ -8241,49 +8425,16 @@ function LogsListTab() {
8241
8425
  isLoading && logs.length === 0 && /* @__PURE__ */ u3("div", {
8242
8426
  class: "text-text-muted text-sm text-center py-12",
8243
8427
  children: "Loading logs..."
8428
+ }, undefined, false, undefined, this),
8429
+ selectedTraceId && /* @__PURE__ */ u3(TraceDrawer, {
8430
+ traceId: selectedTraceId,
8431
+ onClose: () => setSelectedTraceId(null)
8244
8432
  }, undefined, false, undefined, this)
8245
8433
  ]
8246
8434
  }, undefined, true, undefined, this);
8247
8435
  }
8248
- var SOURCE_BADGE_STYLES = {
8249
- fetch: { bg: "var(--color-badge-blue-bg)", color: "var(--color-badge-blue-text)" },
8250
- scheduled: { bg: "var(--color-badge-purple-bg)", color: "var(--color-badge-purple-text)" },
8251
- queue: { bg: "var(--color-badge-orange-bg)", color: "var(--color-badge-orange-text)" },
8252
- alarm: { bg: "var(--color-badge-yellow-bg)", color: "var(--color-badge-yellow-text)" },
8253
- workflow: { bg: "var(--color-badge-emerald-bg)", color: "var(--color-badge-emerald-text)" }
8254
- };
8255
- var DEFAULT_BADGE_STYLE = { bg: "var(--color-badge-red-bg)", color: "var(--color-badge-red-text)" };
8256
8436
  function TraceDrawer({ traceId, onClose, onAddAttributeFilter }) {
8257
- const [spans, setSpans] = d2([]);
8258
- const [events, setEvents] = d2([]);
8259
- const [traceErrors, setTraceErrors] = d2([]);
8260
- const [isLoading, setIsLoading] = d2(true);
8261
- y2(() => {
8262
- setIsLoading(true);
8263
- rpc("traces.getTrace", { traceId }).then((data) => {
8264
- setSpans(data.spans);
8265
- setEvents(data.events);
8266
- setIsLoading(false);
8267
- });
8268
- rpc("traces.errors", { traceId }).then(setTraceErrors).catch(() => {});
8269
- }, [traceId]);
8270
- y2(() => {
8271
- return onTraceEvents((traceEvents) => {
8272
- for (const ev of traceEvents) {
8273
- if (ev.type === "span.start" && ev.span.traceId === traceId) {
8274
- setSpans((prev) => {
8275
- if (prev.some((s3) => s3.spanId === ev.span.spanId))
8276
- return prev;
8277
- return [...prev, ev.span];
8278
- });
8279
- } else if (ev.type === "span.end" && ev.span.traceId === traceId) {
8280
- setSpans((prev) => prev.map((s3) => s3.spanId === ev.span.spanId ? ev.span : s3));
8281
- } else if (ev.type === "span.event" && ev.event.traceId === traceId) {
8282
- setEvents((prev) => [...prev, ev.event]);
8283
- }
8284
- }
8285
- });
8286
- }, [traceId]);
8437
+ const { spans, events, traceErrors, isLoading } = useTraceData(traceId);
8287
8438
  return /* @__PURE__ */ u3(k, {
8288
8439
  children: [
8289
8440
  /* @__PURE__ */ u3("div", {
@@ -8302,8 +8453,7 @@ function TraceDrawer({ traceId, onClose, onAddAttributeFilter }) {
8302
8453
  class: "text-xs text-text-muted font-mono",
8303
8454
  children: [
8304
8455
  "Trace ",
8305
- traceId.slice(0, 12),
8306
- "..."
8456
+ traceId
8307
8457
  ]
8308
8458
  }, undefined, true, undefined, this),
8309
8459
  /* @__PURE__ */ u3("div", {
@@ -8312,11 +8462,34 @@ function TraceDrawer({ traceId, onClose, onAddAttributeFilter }) {
8312
8462
  }, undefined, false, undefined, this)
8313
8463
  ]
8314
8464
  }, undefined, true, undefined, this),
8315
- /* @__PURE__ */ u3("button", {
8316
- onClick: onClose,
8317
- class: "w-7 h-7 flex items-center justify-center rounded-md hover:bg-panel-hover transition-colors text-text-muted hover:text-ink",
8318
- children: "×"
8319
- }, undefined, false, undefined, this)
8465
+ /* @__PURE__ */ u3("div", {
8466
+ class: "flex items-center gap-1",
8467
+ children: [
8468
+ /* @__PURE__ */ u3("a", {
8469
+ href: `#/traces/${traceId}`,
8470
+ class: "w-7 h-7 flex items-center justify-center rounded-md hover:bg-panel-hover transition-colors text-text-muted hover:text-ink",
8471
+ title: "Open full page",
8472
+ children: /* @__PURE__ */ u3("svg", {
8473
+ class: "w-4 h-4",
8474
+ viewBox: "0 0 20 20",
8475
+ fill: "currentColor",
8476
+ children: [
8477
+ /* @__PURE__ */ u3("path", {
8478
+ d: "M4.75 5.75a.75.75 0 00.75.75h4.69l-4.22 4.22a.75.75 0 001.06 1.06L11.25 7.56v4.69a.75.75 0 001.5 0v-6.5a.75.75 0 00-.75-.75h-6.5a.75.75 0 00-.75.75z"
8479
+ }, undefined, false, undefined, this),
8480
+ /* @__PURE__ */ u3("path", {
8481
+ d: "M3 13.5a1.5 1.5 0 011.5-1.5h1.25a.75.75 0 010 1.5H4.5v4h4v-1.25a.75.75 0 011.5 0v1.25a1.5 1.5 0 01-1.5 1.5h-4A1.5 1.5 0 013 17.5v-4z"
8482
+ }, undefined, false, undefined, this)
8483
+ ]
8484
+ }, undefined, true, undefined, this)
8485
+ }, undefined, false, undefined, this),
8486
+ /* @__PURE__ */ u3("button", {
8487
+ onClick: onClose,
8488
+ class: "w-7 h-7 flex items-center justify-center rounded-md hover:bg-panel-hover transition-colors text-text-muted hover:text-ink",
8489
+ children: "×"
8490
+ }, undefined, false, undefined, this)
8491
+ ]
8492
+ }, undefined, true, undefined, this)
8320
8493
  ]
8321
8494
  }, undefined, true, undefined, this),
8322
8495
  /* @__PURE__ */ u3("div", {
@@ -8375,7 +8548,7 @@ function TraceDrawer({ traceId, onClose, onAddAttributeFilter }) {
8375
8548
  /* @__PURE__ */ u3(TraceWaterfall, {
8376
8549
  spans,
8377
8550
  events,
8378
- onAddAttributeFilter
8551
+ onAddAttributeFilter: onAddAttributeFilter ?? (() => {})
8379
8552
  }, undefined, false, undefined, this)
8380
8553
  ]
8381
8554
  }, undefined, true, undefined, this)
@@ -9361,7 +9534,9 @@ function App() {
9361
9534
  route
9362
9535
  }, undefined, false, undefined, this);
9363
9536
  if (route.startsWith("/traces"))
9364
- return /* @__PURE__ */ u3(TracesView, {}, undefined, false, undefined, this);
9537
+ return /* @__PURE__ */ u3(TracesView, {
9538
+ route
9539
+ }, undefined, false, undefined, this);
9365
9540
  if (route.startsWith("/workers"))
9366
9541
  return /* @__PURE__ */ u3(WorkersView, {}, undefined, false, undefined, this);
9367
9542
  if (route.startsWith("/kv"))
@@ -828,6 +828,10 @@
828
828
  width: 100%;
829
829
  }
830
830
 
831
+ .w-px {
832
+ width: 1px;
833
+ }
834
+
831
835
  .max-w-4xl {
832
836
  max-width: var(--container-4xl);
833
837
  }
@@ -1378,6 +1382,10 @@
1378
1382
  background-color: var(--color-blue-600);
1379
1383
  }
1380
1384
 
1385
+ .bg-border {
1386
+ background-color: var(--color-border);
1387
+ }
1388
+
1381
1389
  .bg-cyan-500\/15 {
1382
1390
  background-color: #00b7d726;
1383
1391
  }
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
10
10
 
11
- <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-3q3dhs4j.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-4y88h3dc.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-pqnphvm2.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-5nxa3jfc.js"></script></head>
12
12
  <body class="h-full bg-surface text-ink" style="font-family: system-ui, -apple-system, sans-serif;">
13
13
  <script>
14
14
  // Apply saved theme before first paint to prevent flash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,7 @@ export class BridgeWebSocket extends EventTarget {
28
28
  readonly wsId: string
29
29
  readyState = 1 // OPEN
30
30
  private _postMessage: (msg: WsBridgeOutbound) => void
31
+ private _attachment: any = null
31
32
 
32
33
  constructor(wsId: string, postMessage: (msg: WsBridgeOutbound) => void) {
33
34
  super()
@@ -35,6 +36,14 @@ export class BridgeWebSocket extends EventTarget {
35
36
  this._postMessage = postMessage
36
37
  }
37
38
 
39
+ serializeAttachment(attachment: any): void {
40
+ this._attachment = JSON.parse(JSON.stringify(attachment))
41
+ }
42
+
43
+ deserializeAttachment(): any | null {
44
+ return this._attachment
45
+ }
46
+
38
47
  send(data: string | ArrayBuffer): void {
39
48
  if (this.readyState !== 1) return
40
49
  this._postMessage({ type: 'ws-send', wsId: this.wsId, data })
@@ -602,6 +602,12 @@ export class DurableObjectStateImpl {
602
602
  if (this._acceptedWebSockets.size >= this._limits.maxConcurrentWebSockets) {
603
603
  throw new Error(`Exceeded max concurrent WebSocket connections (${this._limits.maxConcurrentWebSockets})`)
604
604
  }
605
+
606
+ // Implicitly accept the WebSocket (in CF production, ctx.acceptWebSocket handles this)
607
+ if ('accept' in ws && typeof ws.accept === 'function') {
608
+ ;(ws as any).accept()
609
+ }
610
+
605
611
  const entry: AcceptedWebSocket = { ws, tags: tagList, autoResponseTimestamp: null }
606
612
  this._acceptedWebSockets.add(entry)
607
613
 
@@ -41,6 +41,7 @@ export class CFWebSocket extends EventTarget {
41
41
  /** @internal */ _peer: CFWebSocket | null = null
42
42
  /** @internal */ _accepted = false
43
43
  /** @internal */ _eventQueue: WSEvent[] = []
44
+ /** @internal */ _attachment: any = null
44
45
 
45
46
  // Callback-style handlers (standard WebSocket compat)
46
47
  onopen: ((ev: Event) => void) | null = null
@@ -48,6 +49,18 @@ export class CFWebSocket extends EventTarget {
48
49
  onclose: ((ev: CloseEvent) => void) | null = null
49
50
  onerror: ((ev: Event) => void) | null = null
50
51
 
52
+ /**
53
+ * CF-specific: attach serializable data to this WebSocket.
54
+ * Survives hibernation in production; here it's just in-memory.
55
+ */
56
+ serializeAttachment(attachment: any): void {
57
+ this._attachment = JSON.parse(JSON.stringify(attachment))
58
+ }
59
+
60
+ deserializeAttachment(): any | null {
61
+ return this._attachment
62
+ }
63
+
51
64
  /**
52
65
  * CF-specific: begin dispatching events.
53
66
  * Must be called before messages can be sent or received.
package/src/cli/dev.ts CHANGED
@@ -333,6 +333,9 @@ export async function run(ctx: CliContext) {
333
333
  const ce = ev as CloseEvent
334
334
  ws.close(ce.code, ce.reason)
335
335
  })
336
+ // Accept the client side so events from server.send() are dispatched
337
+ // (in CF production the runtime handles this; here we bridge manually)
338
+ cfSocket.accept()
336
339
  },
337
340
  message(ws, message) {
338
341
  const data = ws.data as unknown as Record<string, unknown>
@@ -1,6 +1,7 @@
1
1
  import { SqliteCacheStorage } from './bindings/cache'
2
2
  import { FixedLengthStream, IdentityTransformStream } from './bindings/cf-streams'
3
3
  import { patchGlobalCrypto } from './bindings/crypto-extras'
4
+ import { WebSocketRequestResponsePair } from './bindings/durable-object'
4
5
  import { HTMLRewriter } from './bindings/html-rewriter'
5
6
  import { WebSocketPair } from './bindings/websocket-pair'
6
7
  import { getDatabase } from './db'
@@ -77,6 +78,26 @@ export function setupCloudflareGlobals() {
77
78
  configurable: true,
78
79
  })
79
80
 
81
+ // Register global `WebSocketRequestResponsePair` class (used by DO hibernation API)
82
+ Object.defineProperty(globalThis, 'WebSocketRequestResponsePair', {
83
+ value: WebSocketRequestResponsePair,
84
+ writable: false,
85
+ configurable: true,
86
+ })
87
+
88
+ // Patch Response to preserve CF-specific `webSocket` property from init
89
+ const OriginalResponse = globalThis.Response
90
+ globalThis.Response = class extends OriginalResponse {
91
+ webSocket?: InstanceType<typeof WebSocketPair>[0]
92
+
93
+ constructor(body?: any, init?: ResponseInit & { webSocket?: InstanceType<typeof WebSocketPair>[0] }) {
94
+ super(body, init)
95
+ if (init && 'webSocket' in init) {
96
+ this.webSocket = init.webSocket
97
+ }
98
+ }
99
+ }
100
+
80
101
  // Register global CF stream classes
81
102
  Object.defineProperty(globalThis, 'IdentityTransformStream', {
82
103
  value: IdentityTransformStream,
@@ -19,7 +19,7 @@ class LopataDevEnvironment extends DevEnvironment {
19
19
 
20
20
  get runner(): ModuleRunner {
21
21
  if (!this._runner) {
22
- this._runner = createServerModuleRunner(this, { hmr: false })
22
+ this._runner = createServerModuleRunner(this, { hmr: {} })
23
23
  }
24
24
  return this._runner
25
25
  }
@@ -42,15 +42,19 @@ export function configPlugin(envName: string): Plugin {
42
42
  name: 'lopata:config',
43
43
  config() {
44
44
  return {
45
+ // Disable Vite's SPA fallback (history API fallback) — Lopata handles
46
+ // all requests via worker.fetch(), so no index.html rewriting is needed.
47
+ appType: 'custom',
45
48
  server: {
46
49
  watch: {
47
- ignored: ['**/.lopata/**'],
50
+ ignored: ['**/.lopata/**', '**/.wrangler/**', '**/.react-router/**'],
48
51
  },
49
52
  },
50
53
  environments: {
51
54
  [envName]: {
52
55
  resolve: {
53
56
  externalConditions: ['workerd', 'worker'],
57
+ dedupe: ['react', 'react-dom', 'react-router', 'react-router-dom'],
54
58
  },
55
59
  dev: {
56
60
  createEnvironment(name, config) {
@@ -52,8 +52,53 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
52
52
  let currentModule: Record<string, unknown> | null = null
53
53
  // Serializes module reload — prevents concurrent wireClassRefs calls
54
54
  let reloadLock: Promise<void> | null = null
55
- // Files changed since last request — coalesced into a single invalidation batch
56
- let changedFiles: Set<string> = new Set()
55
+
56
+ /**
57
+ * Import the worker module through Vite's SSR runner and re-wire
58
+ * class refs when the module identity changes (HMR invalidation).
59
+ * Serialized via reloadLock to prevent concurrent wireClassRefs calls.
60
+ */
61
+ async function ensureWorkerModule(): Promise<Record<string, unknown>> {
62
+ const ssrEnv = server.environments[options.envName]
63
+ if (!ssrEnv || !('runner' in ssrEnv)) {
64
+ throw new Error(`SSR environment "${options.envName}" not found or has no runner`)
65
+ }
66
+
67
+ const entrypoint = resolve(server.config.root, config.main)
68
+
69
+ // Wait for any in-progress reload before importing
70
+ if (reloadLock) await reloadLock
71
+
72
+ const workerModule = await (ssrEnv as any).runner.import(entrypoint) as Record<string, unknown>
73
+
74
+ // Re-wire class refs when module changes (HMR invalidation)
75
+ if (workerModule !== currentModule) {
76
+ if (reloadLock) {
77
+ // Another request started reloading while we were importing — wait for it
78
+ await reloadLock
79
+ } else {
80
+ let resolveReload!: () => void
81
+ reloadLock = new Promise(r => {
82
+ resolveReload = r
83
+ })
84
+ try {
85
+ currentModule = workerModule
86
+ wireClassRefs(registry, workerModule, env, workerRegistry)
87
+ setGlobalEnv(env)
88
+ console.log('[lopata:vite] Worker module (re)loaded, classes wired')
89
+ } catch (err) {
90
+ // Reset so next request retries
91
+ currentModule = null
92
+ throw err
93
+ } finally {
94
+ reloadLock = null
95
+ resolveReload()
96
+ }
97
+ }
98
+ }
99
+
100
+ return currentModule ?? workerModule
101
+ }
57
102
 
58
103
  return {
59
104
  name: 'lopata:dev-server',
@@ -167,23 +212,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
167
212
  apiMod.setWorkerRegistry(workerRegistry)
168
213
  }
169
214
 
170
- // 5. Track SSR-relevant file changes.
171
- // Collect changed file paths; the next request will invalidate only
172
- // the affected modules (and their transitive importers) instead of
173
- // clearing the entire runner cache. HMR on the runner is disabled
174
- // (hmr: false in config-plugin) so there's no async race.
175
- server.watcher.on('change', (file) => {
176
- const ssrEnv = server.environments[options.envName]
177
- if (!ssrEnv) return
178
- const normalizedFile = file.replace(/\\/g, '/')
179
- const mods = ssrEnv.moduleGraph.getModulesByFile(normalizedFile)
180
- if (mods && mods.size > 0) {
181
- changedFiles.add(normalizedFile)
182
- currentModule = null
183
- }
184
- })
185
-
186
- // 6. Set up WebSocket trace streaming on httpServer
215
+ // 5. Set up WebSocket trace streaming on httpServer
187
216
  setupTraceWebSocket(server)
188
217
 
189
218
  // 6. Return middleware callback (post-middleware — runs after framework plugins)
@@ -232,63 +261,11 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
232
261
  }
233
262
 
234
263
  try {
235
- const ssrEnv = server.environments[options.envName]
236
- if (!ssrEnv || !('runner' in ssrEnv)) {
237
- console.error(`[lopata:vite] SSR environment "${options.envName}" not found or has no runner`)
238
- return next()
239
- }
240
-
241
- const entrypoint = resolve(server.config.root, config.main)
242
-
243
- // Wait for any in-progress reload before importing
244
- if (reloadLock) await reloadLock
245
-
246
- // Granular invalidation: only invalidate modules for changed
247
- // files and their transitive importers, instead of wiping the
248
- // entire runner cache. This preserves cached evaluations of
249
- // unchanged modules for faster re-evaluation.
250
- if (changedFiles.size > 0) {
251
- const files = changedFiles
252
- changedFiles = new Set()
253
- const runner = (ssrEnv as any).runner
254
- const count = invalidateChangedModules(runner.evaluatedModules, files)
255
- if (count > 0) {
256
- console.log(`[lopata:vite] Invalidated ${count} module(s) (${files.size} file(s) changed)`)
257
- }
258
- }
259
-
260
- const workerModule = await (ssrEnv as any).runner.import(entrypoint) as Record<string, unknown>
261
-
262
- // Re-wire class refs when module changes (HMR invalidation)
263
- if (workerModule !== currentModule) {
264
- if (reloadLock) {
265
- // Another request started reloading while we were importing — wait for it
266
- await reloadLock
267
- } else {
268
- let resolveReload!: () => void
269
- reloadLock = new Promise(r => {
270
- resolveReload = r
271
- })
272
- try {
273
- currentModule = workerModule
274
- wireClassRefs(registry, workerModule, env, workerRegistry)
275
- setGlobalEnv(env)
276
- console.log('[lopata:vite] Worker module (re)loaded, classes wired')
277
- } catch (err) {
278
- // Reset so next request retries
279
- currentModule = null
280
- throw err
281
- } finally {
282
- reloadLock = null
283
- resolveReload()
284
- }
285
- }
286
- }
264
+ const activeModule = await ensureWorkerModule()
287
265
 
288
266
  const request = nodeReqToRequest(req)
289
267
  const parsedUrl = new URL(request.url)
290
268
 
291
- const activeModule = currentModule ?? workerModule
292
269
  const handler = activeModule.default as Record<string, unknown>
293
270
  if (!handler || typeof handler.fetch !== 'function') {
294
271
  console.error('[lopata:vite] Worker module default export has no fetch() method')
@@ -350,98 +327,28 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
350
327
 
351
328
  // Dynamically import ws (available as Vite dependency)
352
329
  import('ws').then(({ WebSocketServer }) => {
353
- const wss = new WebSocketServer({ noServer: true })
330
+ const traceWss = new WebSocketServer({ noServer: true })
331
+ const workerWss = new WebSocketServer({ noServer: true })
354
332
 
355
333
  httpServer.on('upgrade', (req: IncomingMessage, socket: any, head: Buffer) => {
356
334
  const url = req.url ?? ''
357
- if (!url.startsWith('/__api/traces/ws')) return
358
-
359
- wss.handleUpgrade(req, socket, head, (ws: any) => {
360
- const store = getTraceStore()
361
- let filter: { path?: string; status?: string; attributeFilters?: Array<{ key: string; value: string; type: 'include' | 'exclude' }> } = {}
362
- let buffer: any[] = []
363
- const MAX_BUFFER = 1000
364
- const allowedTraces = new Set<string>()
365
- const excludedTraces = new Set<string>()
366
-
367
- function isRootSpanFiltered(span: { name: string; status: string; parentSpanId: string | null; attributes: Record<string, unknown> }): boolean {
368
- if (filter.status && filter.status !== 'all') {
369
- if (span.status !== 'unset' && span.status !== filter.status) return true
370
- }
371
- if (filter.path) {
372
- if (!matchGlob(span.name, filter.path)) return true
373
- }
374
- if (filter.attributeFilters && filter.attributeFilters.length > 0) {
375
- const attrs = span.attributes
376
- for (const af of filter.attributeFilters) {
377
- const val = attrs[af.key]
378
- const matches = val !== undefined && String(val).toLowerCase().includes(af.value.toLowerCase())
379
- if (af.type === 'include' && !matches) return true
380
- if (af.type === 'exclude' && matches) return true
381
- }
382
- }
383
- return false
384
- }
385
-
386
- const unsubscribe = store.subscribe((event: any) => {
387
- const traceId = event.type === 'span.event' ? event.event.traceId : event.span.traceId
388
- if ((event.type === 'span.start' || event.type === 'span.end') && event.span.parentSpanId === null) {
389
- if (isRootSpanFiltered(event.span)) {
390
- excludedTraces.add(traceId)
391
- allowedTraces.delete(traceId)
392
- return
393
- }
394
- excludedTraces.delete(traceId)
395
- allowedTraces.add(traceId)
396
- } else {
397
- if (excludedTraces.has(traceId)) return
398
- }
399
- if (buffer.length < MAX_BUFFER) {
400
- buffer.push(event)
401
- }
402
- })
403
-
404
- const interval = setInterval(() => {
405
- if (buffer.length > 0) {
406
- ws.send(JSON.stringify({ type: 'batch', events: buffer }))
407
- buffer = []
408
- }
409
- }, 500)
410
335
 
411
- // Parse filter from query params
412
- try {
413
- const reqUrl = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`)
414
- const statusParam = reqUrl.searchParams.get('status')
415
- const pathParam = reqUrl.searchParams.get('path')
416
- if (statusParam) filter.status = statusParam
417
- if (pathParam) filter.path = pathParam
418
- } catch {}
419
-
420
- let sinceMs = 15 * 60 * 1000
421
- const since = Date.now() - sinceMs
422
- const recent = store.getRecentTraces(since, 200, filter)
423
- ws.send(JSON.stringify({ type: 'initial', traces: recent }))
336
+ // Skip Vite HMR WebSocket — Vite uses sec-websocket-protocol
337
+ // "vite-hmr" / "vite-ping" to identify its connections
338
+ const wsProtocol = req.headers['sec-websocket-protocol']
339
+ if (wsProtocol === 'vite-hmr' || wsProtocol === 'vite-ping') return
424
340
 
425
- ws.on('message', (data: any) => {
426
- try {
427
- const msg = JSON.parse(typeof data === 'string' ? data : data.toString())
428
- if (msg.type === 'filter') {
429
- filter = { path: msg.path, status: msg.status, attributeFilters: msg.attributeFilters }
430
- if (msg.sinceMs !== undefined) sinceMs = msg.sinceMs
431
- allowedTraces.clear()
432
- excludedTraces.clear()
433
- const freshSince = sinceMs > 0 ? Date.now() - sinceMs : 0
434
- const freshTraces = store.getRecentTraces(freshSince, 200, filter)
435
- ws.send(JSON.stringify({ type: 'initial', traces: freshTraces }))
436
- }
437
- } catch {}
341
+ if (url.startsWith('/__api/traces/ws')) {
342
+ traceWss.handleUpgrade(req, socket, head, (ws: any) => {
343
+ handleTraceWebSocket(ws, req)
438
344
  })
345
+ return
346
+ }
439
347
 
440
- ws.on('close', () => {
441
- unsubscribe()
442
- clearInterval(interval)
443
- })
444
- })
348
+ // Worker WebSocket upgrade — bridge to CF WebSocketPair
349
+ if (req.headers.upgrade?.toLowerCase() === 'websocket') {
350
+ handleWorkerWebSocketUpgrade(workerWss, req, socket, head)
351
+ }
445
352
  })
446
353
 
447
354
  console.log('[lopata:vite] Dashboard: http://localhost:5173/__dashboard')
@@ -450,51 +357,165 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
450
357
  console.log('[lopata:vite] Dashboard available (trace streaming disabled — ws package not found)')
451
358
  })
452
359
  }
453
- }
454
360
 
455
- /**
456
- * Invalidate runner-evaluated modules for the given changed files and all
457
- * their transitive importers. Virtual modules (IDs starting with `\0`) are
458
- * skipped to preserve shimmed CF modules (see commit 75b1736).
459
- *
460
- * Returns the number of modules invalidated.
461
- */
462
- function invalidateChangedModules(
463
- evaluatedModules: { getModulesByFile(file: string): Iterable<{ id: string; importers: Set<any> }> | undefined; invalidateModule(node: any): void },
464
- changedFiles: Set<string>,
465
- ): number {
466
- const toInvalidate = new Set<{ id: string; importers: Set<any> }>()
467
-
468
- for (const file of changedFiles) {
469
- const nodes = evaluatedModules.getModulesByFile(file)
470
- if (!nodes) continue
471
- for (const node of nodes) {
472
- collectTransitiveImporters(node, toInvalidate)
361
+ function handleTraceWebSocket(ws: any, req: IncomingMessage) {
362
+ const store = getTraceStore()
363
+ let filter: { path?: string; status?: string; attributeFilters?: Array<{ key: string; value: string; type: 'include' | 'exclude' }> } = {}
364
+ let buffer: any[] = []
365
+ const MAX_BUFFER = 1000
366
+ const allowedTraces = new Set<string>()
367
+ const excludedTraces = new Set<string>()
368
+
369
+ function isRootSpanFiltered(span: { name: string; status: string; parentSpanId: string | null; attributes: Record<string, unknown> }): boolean {
370
+ if (filter.status && filter.status !== 'all') {
371
+ if (span.status !== 'unset' && span.status !== filter.status) return true
372
+ }
373
+ if (filter.path) {
374
+ if (!matchGlob(span.name, filter.path)) return true
375
+ }
376
+ if (filter.attributeFilters && filter.attributeFilters.length > 0) {
377
+ const attrs = span.attributes
378
+ for (const af of filter.attributeFilters) {
379
+ const val = attrs[af.key]
380
+ const matches = val !== undefined && String(val).toLowerCase().includes(af.value.toLowerCase())
381
+ if (af.type === 'include' && !matches) return true
382
+ if (af.type === 'exclude' && matches) return true
383
+ }
384
+ }
385
+ return false
473
386
  }
474
- }
475
387
 
476
- for (const node of toInvalidate) {
477
- evaluatedModules.invalidateModule(node)
388
+ const unsubscribe = store.subscribe((event: any) => {
389
+ const traceId = event.type === 'span.event' ? event.event.traceId : event.span.traceId
390
+ if ((event.type === 'span.start' || event.type === 'span.end') && event.span.parentSpanId === null) {
391
+ if (isRootSpanFiltered(event.span)) {
392
+ excludedTraces.add(traceId)
393
+ allowedTraces.delete(traceId)
394
+ return
395
+ }
396
+ excludedTraces.delete(traceId)
397
+ allowedTraces.add(traceId)
398
+ } else {
399
+ if (excludedTraces.has(traceId)) return
400
+ }
401
+ if (buffer.length < MAX_BUFFER) {
402
+ buffer.push(event)
403
+ }
404
+ })
405
+
406
+ const interval = setInterval(() => {
407
+ if (buffer.length > 0) {
408
+ ws.send(JSON.stringify({ type: 'batch', events: buffer }))
409
+ buffer = []
410
+ }
411
+ }, 500)
412
+
413
+ // Parse filter from query params
414
+ try {
415
+ const reqUrl = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`)
416
+ const statusParam = reqUrl.searchParams.get('status')
417
+ const pathParam = reqUrl.searchParams.get('path')
418
+ if (statusParam) filter.status = statusParam
419
+ if (pathParam) filter.path = pathParam
420
+ } catch {}
421
+
422
+ let sinceMs = 15 * 60 * 1000
423
+ const since = Date.now() - sinceMs
424
+ const recent = store.getRecentTraces(since, 200, filter)
425
+ ws.send(JSON.stringify({ type: 'initial', traces: recent }))
426
+
427
+ ws.on('message', (data: any) => {
428
+ try {
429
+ const msg = JSON.parse(typeof data === 'string' ? data : data.toString())
430
+ if (msg.type === 'filter') {
431
+ filter = { path: msg.path, status: msg.status, attributeFilters: msg.attributeFilters }
432
+ if (msg.sinceMs !== undefined) sinceMs = msg.sinceMs
433
+ allowedTraces.clear()
434
+ excludedTraces.clear()
435
+ const freshSince = sinceMs > 0 ? Date.now() - sinceMs : 0
436
+ const freshTraces = store.getRecentTraces(freshSince, 200, filter)
437
+ ws.send(JSON.stringify({ type: 'initial', traces: freshTraces }))
438
+ }
439
+ } catch {}
440
+ })
441
+
442
+ ws.on('close', () => {
443
+ unsubscribe()
444
+ clearInterval(interval)
445
+ })
478
446
  }
479
447
 
480
- return toInvalidate.size
481
- }
448
+ async function handleWorkerWebSocketUpgrade(wss: any, req: IncomingMessage, socket: any, head: Buffer) {
449
+ try {
450
+ const { CFWebSocket } = await import('../bindings/websocket-pair.ts')
482
451
 
483
- /**
484
- * Collect `node` and all its transitive importers into `result`.
485
- * Skips virtual modules (IDs starting with `\0`) — these are CF module shims
486
- * that must not be invalidated or traversed further.
487
- */
488
- function collectTransitiveImporters(
489
- node: { id: string; importers: Set<any> },
490
- result: Set<{ id: string; importers: Set<any> }>,
491
- ): void {
492
- if (result.has(node)) return
493
- // Skip virtual modules CF shims must stay cached
494
- if (node.id.startsWith('\0')) return
495
- result.add(node)
496
- for (const importer of node.importers) {
497
- collectTransitiveImporters(importer, result)
452
+ const activeModule = await ensureWorkerModule()
453
+ const handler = activeModule.default as Record<string, unknown>
454
+ if (!handler || typeof handler.fetch !== 'function') {
455
+ socket.destroy()
456
+ return
457
+ }
458
+
459
+ const request = nodeReqToRequest(req)
460
+ const ctx = new ExecutionContext()
461
+ const response = await runWithExecutionContext(ctx, async () => {
462
+ return (handler.fetch as Function).call(handler, request, env, ctx) as Response
463
+ })
464
+
465
+ const cfSocket = (response as Response & { webSocket?: InstanceType<typeof CFWebSocket> }).webSocket
466
+ if (response.status !== 101 || !cfSocket || !(cfSocket instanceof CFWebSocket)) {
467
+ socket.destroy()
468
+ return
469
+ }
470
+
471
+ // Complete the upgrade and bridge
472
+ wss.handleUpgrade(req, socket, head, (ws: any) => {
473
+ // CF → real WS
474
+ cfSocket.addEventListener('message', (ev: Event) => {
475
+ const msgData = (ev as MessageEvent).data
476
+ try {
477
+ ws.send(msgData)
478
+ } catch {}
479
+ })
480
+ cfSocket.addEventListener('close', (ev: Event) => {
481
+ const ce = ev as CloseEvent
482
+ try {
483
+ ws.close(ce.code, ce.reason)
484
+ } catch {}
485
+ })
486
+ // Accept the client side so events from server.send() are dispatched
487
+ cfSocket.accept()
488
+
489
+ // Real WS → CF
490
+ ws.on('message', (data: Buffer, isBinary: boolean) => {
491
+ const msgData = isBinary
492
+ ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
493
+ : data.toString('utf-8')
494
+ const evt = { type: 'message' as const, data: msgData }
495
+ if (cfSocket._peer?._accepted) {
496
+ cfSocket._peer._dispatchWSEvent(evt)
497
+ } else if (cfSocket._peer) {
498
+ cfSocket._peer._eventQueue.push(evt)
499
+ }
500
+ })
501
+
502
+ ws.on('close', (code: number, reason: Buffer) => {
503
+ if (cfSocket._peer && cfSocket._peer.readyState !== 3) {
504
+ const evt = { type: 'close' as const, code: code ?? 1000, reason: reason?.toString('utf-8') ?? '', wasClean: true }
505
+ if (cfSocket._peer._accepted) {
506
+ cfSocket._peer._dispatchWSEvent(evt)
507
+ } else {
508
+ cfSocket._peer._eventQueue.push(evt)
509
+ }
510
+ cfSocket._peer.readyState = 3
511
+ }
512
+ cfSocket.readyState = 3
513
+ })
514
+ })
515
+ } catch (err) {
516
+ console.error('[lopata:vite] Worker WebSocket upgrade failed:', err)
517
+ socket.destroy()
518
+ }
498
519
  }
499
520
  }
500
521