lopata 0.9.0 → 0.10.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.
@@ -1142,6 +1142,50 @@ function toError(err) {
1142
1142
  return err instanceof Error ? err : new Error(String(err));
1143
1143
  }
1144
1144
 
1145
+ // src/dashboard/components/copy-markdown-button.tsx
1146
+ function CopyMarkdownButton({ getMarkdown, title }) {
1147
+ const [copied, setCopied] = d2(false);
1148
+ const timerRef = A2(null);
1149
+ y2(() => {
1150
+ return () => {
1151
+ if (timerRef.current)
1152
+ clearTimeout(timerRef.current);
1153
+ };
1154
+ }, []);
1155
+ const handleCopy = (e3) => {
1156
+ e3.stopPropagation();
1157
+ navigator.clipboard.writeText(getMarkdown()).then(() => {
1158
+ setCopied(true);
1159
+ if (timerRef.current)
1160
+ clearTimeout(timerRef.current);
1161
+ timerRef.current = setTimeout(() => setCopied(false), 1500);
1162
+ });
1163
+ };
1164
+ return /* @__PURE__ */ u3("button", {
1165
+ onClick: handleCopy,
1166
+ class: "rounded-md px-2 py-1 text-xs font-medium text-text-muted hover:text-text-data hover:bg-panel-hover border border-transparent hover:border-border transition-all",
1167
+ title: title ?? "Copy as Markdown",
1168
+ children: copied ? "Copied!" : "Copy MD"
1169
+ }, undefined, false, undefined, this);
1170
+ }
1171
+ function tableToMarkdown(headers, rows) {
1172
+ const escape = (v3) => String(v3 ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
1173
+ const headerRow = `| ${headers.map(escape).join(" | ")} |`;
1174
+ const separator = `| ${headers.map(() => "---").join(" | ")} |`;
1175
+ const dataRows = rows.map((row) => `| ${row.map(escape).join(" | ")} |`);
1176
+ return [headerRow, separator, ...dataRows].join(`
1177
+ `);
1178
+ }
1179
+ function recordsToMarkdown(headers, rows) {
1180
+ return tableToMarkdown(headers, rows.map((row) => headers.map((h3) => row[h3])));
1181
+ }
1182
+ function keyValueToMarkdown(data) {
1183
+ const entries = Object.entries(data);
1184
+ if (entries.length === 0)
1185
+ return "";
1186
+ return tableToMarkdown(["Key", "Value"], entries);
1187
+ }
1188
+
1145
1189
  // src/dashboard/sql-browser/editable-cell.tsx
1146
1190
  function EditableCell({ value, onSave, foreignKey, onNavigateFK, onInspect, alignRight }) {
1147
1191
  const [editing, setEditing] = d2(false);
@@ -2354,6 +2398,9 @@ function TableDataView({ table, execQuery, onOpenInConsole, history: history2, b
2354
2398
  }, undefined, true, undefined, this)
2355
2399
  ]
2356
2400
  }, undefined, true, undefined, this),
2401
+ /* @__PURE__ */ u3(CopyMarkdownButton, {
2402
+ getMarkdown: () => recordsToMarkdown(displayCols, rows)
2403
+ }, undefined, false, undefined, this),
2357
2404
  /* @__PURE__ */ u3("button", {
2358
2405
  onClick: () => setShowInsert(!showInsert),
2359
2406
  class: `rounded-md px-3 py-1.5 text-sm font-medium transition-all ${showInsert ? "bg-panel-active text-text-data" : "bg-ink text-surface hover:opacity-80"}`,
@@ -3017,33 +3064,41 @@ function SqlConsoleTab({ execQuery, generateSql, initialSql, history: history2 }
3017
3064
  function ResultTable({ columns, rows }) {
3018
3065
  return /* @__PURE__ */ u3("div", {
3019
3066
  class: "bg-panel rounded-lg border border-border overflow-x-auto",
3020
- children: /* @__PURE__ */ u3("table", {
3021
- class: "w-full text-sm",
3022
- children: [
3023
- /* @__PURE__ */ u3("thead", {
3024
- children: /* @__PURE__ */ u3("tr", {
3025
- class: "border-b border-border-subtle",
3026
- children: columns.map((col) => /* @__PURE__ */ u3("th", {
3027
- class: "text-left px-4 py-2.5 font-medium text-xs text-text-muted uppercase tracking-wider font-mono",
3028
- children: col
3029
- }, col, false, undefined, this))
3030
- }, undefined, false, undefined, this)
3031
- }, undefined, false, undefined, this),
3032
- /* @__PURE__ */ u3("tbody", {
3033
- children: rows.map((row, i3) => /* @__PURE__ */ u3("tr", {
3034
- class: "group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors",
3035
- children: columns.map((col) => /* @__PURE__ */ u3("td", {
3036
- class: "px-4 py-2.5 font-mono text-xs",
3037
- children: row[col] === null ? /* @__PURE__ */ u3("span", {
3038
- class: "text-text-dim italic",
3039
- children: "NULL"
3040
- }, undefined, false, undefined, this) : String(row[col])
3041
- }, col, false, undefined, this))
3042
- }, i3, false, undefined, this))
3067
+ children: [
3068
+ /* @__PURE__ */ u3("div", {
3069
+ class: "flex justify-end px-2 pt-2",
3070
+ children: /* @__PURE__ */ u3(CopyMarkdownButton, {
3071
+ getMarkdown: () => recordsToMarkdown(columns, rows)
3043
3072
  }, undefined, false, undefined, this)
3044
- ]
3045
- }, undefined, true, undefined, this)
3046
- }, undefined, false, undefined, this);
3073
+ }, undefined, false, undefined, this),
3074
+ /* @__PURE__ */ u3("table", {
3075
+ class: "w-full text-sm",
3076
+ children: [
3077
+ /* @__PURE__ */ u3("thead", {
3078
+ children: /* @__PURE__ */ u3("tr", {
3079
+ class: "border-b border-border-subtle",
3080
+ children: columns.map((col) => /* @__PURE__ */ u3("th", {
3081
+ class: "text-left px-4 py-2.5 font-medium text-xs text-text-muted uppercase tracking-wider font-mono",
3082
+ children: col
3083
+ }, col, false, undefined, this))
3084
+ }, undefined, false, undefined, this)
3085
+ }, undefined, false, undefined, this),
3086
+ /* @__PURE__ */ u3("tbody", {
3087
+ children: rows.map((row, i3) => /* @__PURE__ */ u3("tr", {
3088
+ class: "group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors",
3089
+ children: columns.map((col) => /* @__PURE__ */ u3("td", {
3090
+ class: "px-4 py-2.5 font-mono text-xs",
3091
+ children: row[col] === null ? /* @__PURE__ */ u3("span", {
3092
+ class: "text-text-dim italic",
3093
+ children: "NULL"
3094
+ }, undefined, false, undefined, this) : String(row[col])
3095
+ }, col, false, undefined, this))
3096
+ }, i3, false, undefined, this))
3097
+ }, undefined, false, undefined, this)
3098
+ ]
3099
+ }, undefined, true, undefined, this)
3100
+ ]
3101
+ }, undefined, true, undefined, this);
3047
3102
  }
3048
3103
 
3049
3104
  // src/dashboard/sql-browser/sql-browser.tsx
@@ -3195,25 +3250,35 @@ function KeyValueTable({ data }) {
3195
3250
  children: "No entries"
3196
3251
  }, undefined, false, undefined, this);
3197
3252
  }
3198
- return /* @__PURE__ */ u3("table", {
3199
- class: "w-full text-sm",
3200
- children: /* @__PURE__ */ u3("tbody", {
3201
- children: entries.map(([key, value]) => /* @__PURE__ */ u3("tr", {
3202
- class: "border-b border-border-subtle last:border-0 hover:bg-panel-hover/50 transition-colors",
3203
- children: [
3204
- /* @__PURE__ */ u3("td", {
3205
- class: "px-4 py-2 font-medium text-text-secondary whitespace-nowrap align-top font-mono",
3206
- style: "width: 1%;",
3207
- children: key
3208
- }, undefined, false, undefined, this),
3209
- /* @__PURE__ */ u3("td", {
3210
- class: "px-4 py-2 text-ink break-all font-mono",
3211
- children: value
3212
- }, undefined, false, undefined, this)
3213
- ]
3214
- }, key, true, undefined, this))
3215
- }, undefined, false, undefined, this)
3216
- }, undefined, false, undefined, this);
3253
+ return /* @__PURE__ */ u3("div", {
3254
+ children: [
3255
+ /* @__PURE__ */ u3("div", {
3256
+ class: "flex justify-end px-2 pt-1",
3257
+ children: /* @__PURE__ */ u3(CopyMarkdownButton, {
3258
+ getMarkdown: () => keyValueToMarkdown(data)
3259
+ }, undefined, false, undefined, this)
3260
+ }, undefined, false, undefined, this),
3261
+ /* @__PURE__ */ u3("table", {
3262
+ class: "w-full text-sm",
3263
+ children: /* @__PURE__ */ u3("tbody", {
3264
+ children: entries.map(([key, value]) => /* @__PURE__ */ u3("tr", {
3265
+ class: "border-b border-border-subtle last:border-0 hover:bg-panel-hover/50 transition-colors",
3266
+ children: [
3267
+ /* @__PURE__ */ u3("td", {
3268
+ class: "px-4 py-2 font-medium text-text-secondary whitespace-nowrap align-top font-mono",
3269
+ style: "width: 1%;",
3270
+ children: key
3271
+ }, undefined, false, undefined, this),
3272
+ /* @__PURE__ */ u3("td", {
3273
+ class: "px-4 py-2 text-ink break-all font-mono",
3274
+ children: value
3275
+ }, undefined, false, undefined, this)
3276
+ ]
3277
+ }, key, true, undefined, this))
3278
+ }, undefined, false, undefined, this)
3279
+ }, undefined, false, undefined, this)
3280
+ ]
3281
+ }, undefined, true, undefined, this);
3217
3282
  }
3218
3283
  // src/dashboard/components/page-header.tsx
3219
3284
  function PageHeader({ title, subtitle, actions }) {
@@ -3341,30 +3406,38 @@ function StatusBadge({ status, colorMap }) {
3341
3406
  function Table({ headers, rows }) {
3342
3407
  return /* @__PURE__ */ u3("div", {
3343
3408
  class: "bg-panel rounded-lg border border-border overflow-x-auto",
3344
- children: /* @__PURE__ */ u3("table", {
3345
- class: "w-full text-sm",
3346
- children: [
3347
- /* @__PURE__ */ u3("thead", {
3348
- children: /* @__PURE__ */ u3("tr", {
3349
- class: "border-b border-border-subtle",
3350
- children: headers.map((h3) => /* @__PURE__ */ u3("th", {
3351
- class: "text-left px-4 py-3 font-mono font-medium text-xs text-text-muted uppercase tracking-wider",
3352
- children: h3
3353
- }, h3, false, undefined, this))
3354
- }, undefined, false, undefined, this)
3355
- }, undefined, false, undefined, this),
3356
- /* @__PURE__ */ u3("tbody", {
3357
- children: rows.map((row, i3) => /* @__PURE__ */ u3("tr", {
3358
- class: "group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors",
3359
- children: row.map((cell, j3) => /* @__PURE__ */ u3("td", {
3360
- class: "px-4 py-3",
3361
- children: cell
3362
- }, j3, false, undefined, this))
3363
- }, i3, false, undefined, this))
3409
+ children: [
3410
+ /* @__PURE__ */ u3("div", {
3411
+ class: "flex justify-end px-2 pt-2",
3412
+ children: /* @__PURE__ */ u3(CopyMarkdownButton, {
3413
+ getMarkdown: () => tableToMarkdown(headers, rows)
3364
3414
  }, undefined, false, undefined, this)
3365
- ]
3366
- }, undefined, true, undefined, this)
3367
- }, undefined, false, undefined, this);
3415
+ }, undefined, false, undefined, this),
3416
+ /* @__PURE__ */ u3("table", {
3417
+ class: "w-full text-sm",
3418
+ children: [
3419
+ /* @__PURE__ */ u3("thead", {
3420
+ children: /* @__PURE__ */ u3("tr", {
3421
+ class: "border-b border-border-subtle",
3422
+ children: headers.map((h3) => /* @__PURE__ */ u3("th", {
3423
+ class: "text-left px-4 py-3 font-mono font-medium text-xs text-text-muted uppercase tracking-wider",
3424
+ children: h3
3425
+ }, h3, false, undefined, this))
3426
+ }, undefined, false, undefined, this)
3427
+ }, undefined, false, undefined, this),
3428
+ /* @__PURE__ */ u3("tbody", {
3429
+ children: rows.map((row, i3) => /* @__PURE__ */ u3("tr", {
3430
+ class: "group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors",
3431
+ children: row.map((cell, j3) => /* @__PURE__ */ u3("td", {
3432
+ class: "px-4 py-3",
3433
+ children: cell
3434
+ }, j3, false, undefined, this))
3435
+ }, i3, false, undefined, this))
3436
+ }, undefined, false, undefined, this)
3437
+ ]
3438
+ }, undefined, true, undefined, this)
3439
+ ]
3440
+ }, undefined, true, undefined, this);
3368
3441
  }
3369
3442
  // src/dashboard/components/table-link.tsx
3370
3443
  function TableLink({ href, children, mono }) {
@@ -5675,11 +5748,25 @@ function ErrorList() {
5675
5748
  /* @__PURE__ */ u3(RefreshButton, {
5676
5749
  onClick: () => loadErrors()
5677
5750
  }, undefined, false, undefined, this),
5678
- errors.length > 0 && /* @__PURE__ */ u3("button", {
5679
- onClick: handleClear,
5680
- class: "rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
5681
- children: "Clear all"
5682
- }, undefined, false, undefined, this)
5751
+ errors.length > 0 && /* @__PURE__ */ u3(k, {
5752
+ children: [
5753
+ /* @__PURE__ */ u3(CopyMarkdownButton, {
5754
+ getMarkdown: () => tableToMarkdown(["Source", "Error", "Message", "Context", "Worker", "Time"], errors.map((err) => [
5755
+ err.source ?? "-",
5756
+ err.errorName,
5757
+ err.errorMessage,
5758
+ err.requestMethod && err.requestUrl ? `${err.requestMethod} ${err.requestUrl}` : "-",
5759
+ err.workerName ?? "-",
5760
+ formatTimestamp2(err.timestamp)
5761
+ ]))
5762
+ }, undefined, false, undefined, this),
5763
+ /* @__PURE__ */ u3("button", {
5764
+ onClick: handleClear,
5765
+ class: "rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
5766
+ children: "Clear all"
5767
+ }, undefined, false, undefined, this)
5768
+ ]
5769
+ }, undefined, true, undefined, this)
5683
5770
  ]
5684
5771
  }, undefined, true, undefined, this)
5685
5772
  ]
@@ -5889,11 +5976,20 @@ function ErrorDetailPage({ errorId }) {
5889
5976
  }, undefined, true, undefined, this)
5890
5977
  ]
5891
5978
  }, undefined, true, undefined, this),
5892
- /* @__PURE__ */ u3("button", {
5893
- onClick: handleDelete,
5894
- class: "rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
5895
- children: "Delete"
5896
- }, undefined, false, undefined, this)
5979
+ /* @__PURE__ */ u3("div", {
5980
+ class: "flex items-center gap-2",
5981
+ children: [
5982
+ /* @__PURE__ */ u3(CopyMarkdownButton, {
5983
+ getMarkdown: () => errorToMarkdown(detail),
5984
+ title: "Copy error as Markdown for LLM"
5985
+ }, undefined, false, undefined, this),
5986
+ /* @__PURE__ */ u3("button", {
5987
+ onClick: handleDelete,
5988
+ class: "rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
5989
+ children: "Delete"
5990
+ }, undefined, false, undefined, this)
5991
+ ]
5992
+ }, undefined, true, undefined, this)
5897
5993
  ]
5898
5994
  }, undefined, true, undefined, this),
5899
5995
  /* @__PURE__ */ u3("div", {
@@ -6296,6 +6392,60 @@ function truncateUrl(url) {
6296
6392
  return url.length > 50 ? url.slice(0, 50) + "..." : url;
6297
6393
  }
6298
6394
  }
6395
+ function errorToMarkdown(detail) {
6396
+ const { data } = detail;
6397
+ const lines = [];
6398
+ lines.push(`## ${data.error.name}: ${data.error.message}`);
6399
+ if (detail.source)
6400
+ lines.push(`**Source:** ${detail.source}`);
6401
+ if (data.runtime.workerName)
6402
+ lines.push(`**Worker:** ${data.runtime.workerName}`);
6403
+ lines.push("");
6404
+ if (data.error.stack) {
6405
+ lines.push("### Stack Trace");
6406
+ lines.push("```");
6407
+ lines.push(data.error.stack);
6408
+ lines.push("```");
6409
+ lines.push("");
6410
+ }
6411
+ if (data.error.frames.length > 0) {
6412
+ lines.push("### Source Code");
6413
+ for (const frame of data.error.frames) {
6414
+ lines.push(`#### ${frame.file}:${frame.line}:${frame.column}${frame.function ? ` in ${frame.function}` : ""}`);
6415
+ if (frame.source && frame.source.length > 0) {
6416
+ const startLine = frame.line - (frame.sourceLine ?? 0);
6417
+ lines.push("```");
6418
+ frame.source.forEach((line, i3) => {
6419
+ const lineNum = startLine + i3;
6420
+ const marker = i3 === frame.sourceLine ? ">" : " ";
6421
+ lines.push(`${marker} ${lineNum} | ${line}`);
6422
+ });
6423
+ lines.push("```");
6424
+ }
6425
+ lines.push("");
6426
+ }
6427
+ }
6428
+ if (data.request.method && data.request.url) {
6429
+ lines.push("### Request");
6430
+ lines.push(`${data.request.method} ${data.request.url}`);
6431
+ const headers = Object.entries(data.request.headers);
6432
+ if (headers.length > 0) {
6433
+ lines.push("");
6434
+ lines.push(keyValueToMarkdown(data.request.headers));
6435
+ }
6436
+ lines.push("");
6437
+ }
6438
+ if (data.bindings.length > 0) {
6439
+ lines.push("### Bindings");
6440
+ lines.push(tableToMarkdown(["Name", "Type"], data.bindings.map((b) => [b.name, b.type])));
6441
+ lines.push("");
6442
+ }
6443
+ lines.push("### Runtime");
6444
+ lines.push(`- **Bun:** ${data.runtime.bunVersion}`);
6445
+ lines.push(`- **Platform:** ${data.runtime.platform} / ${data.runtime.arch}`);
6446
+ return lines.join(`
6447
+ `);
6448
+ }
6299
6449
 
6300
6450
  // src/dashboard/views/generations.tsx
6301
6451
  var STATE_COLORS = {
@@ -7861,6 +8011,7 @@ function R2ObjectList({ bucket }) {
7861
8011
  // src/dashboard/views/routes.tsx
7862
8012
  var TYPE_COLORS = {
7863
8013
  route: "bg-emerald-500/15 text-emerald-500",
8014
+ host: "bg-blue-500/15 text-blue-500",
7864
8015
  fallback: "bg-panel-active text-text-data"
7865
8016
  };
7866
8017
  function RoutesView() {
@@ -7886,7 +8037,7 @@ function RoutesView() {
7886
8037
  children: r3.workerName
7887
8038
  }, undefined, false, undefined, this),
7888
8039
  /* @__PURE__ */ u3(StatusBadge, {
7889
- status: r3.isFallback ? "fallback" : "route",
8040
+ status: r3.isFallback ? "fallback" : r3.type === "host" ? "host" : "route",
7890
8041
  colorMap: TYPE_COLORS
7891
8042
  }, undefined, false, undefined, this)
7892
8043
  ])
@@ -1756,6 +1756,14 @@
1756
1756
  padding-top: calc(var(--spacing) * .5);
1757
1757
  }
1758
1758
 
1759
+ .pt-1 {
1760
+ padding-top: calc(var(--spacing) * 1);
1761
+ }
1762
+
1763
+ .pt-2 {
1764
+ padding-top: calc(var(--spacing) * 2);
1765
+ }
1766
+
1759
1767
  .pt-3 {
1760
1768
  padding-top: calc(var(--spacing) * 3);
1761
1769
  }
@@ -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-csyd2tq2.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-yq8n0mcf.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-jzyhpjad.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-hnsny9g7.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.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,6 +5,11 @@ export const handlers = {
5
5
  'routes.list'(_input: {}, ctx: HandlerContext): RouteInfo[] {
6
6
  const routes: RouteInfo[] = []
7
7
 
8
+ // Show host-based routes
9
+ for (const hr of ctx.hostRoutes) {
10
+ routes.push({ pattern: hr.pattern, workerName: hr.workerName, isFallback: false, type: 'host' })
11
+ }
12
+
8
13
  if (ctx.routeDispatcher) {
9
14
  for (const r of ctx.routeDispatcher.getRegisteredRoutes()) {
10
15
  routes.push({ pattern: r.pattern, workerName: r.workerName, isFallback: false })
package/src/api/index.ts CHANGED
@@ -6,9 +6,9 @@ import type { WorkerRegistry } from '../worker-registry'
6
6
  import { handlePreflight, withCors } from './cors'
7
7
  import { dispatch } from './dispatch'
8
8
  import { handleR2Download, handleR2Upload } from './r2'
9
- import type { HandlerContext } from './types'
9
+ import type { HandlerContext, HostRouteInfo } from './types'
10
10
 
11
- const ctx: HandlerContext = { config: null, manager: null, registry: null, lopataConfig: null, routeDispatcher: null }
11
+ const ctx: HandlerContext = { config: null, manager: null, registry: null, lopataConfig: null, routeDispatcher: null, hostRoutes: [] }
12
12
 
13
13
  export function setDashboardConfig(config: WranglerConfig): void {
14
14
  ctx.config = config
@@ -30,6 +30,10 @@ export function setRouteDispatcher(dispatcher: RouteDispatcher): void {
30
30
  ctx.routeDispatcher = dispatcher
31
31
  }
32
32
 
33
+ export function setHostRoutes(routes: HostRouteInfo[]): void {
34
+ ctx.hostRoutes = routes
35
+ }
36
+
33
37
  export function handleApiRequest(request: Request): Response | Promise<Response> {
34
38
  const url = new URL(request.url)
35
39
 
package/src/api/types.ts CHANGED
@@ -382,18 +382,25 @@ import type { LopataConfig } from '../lopata-config'
382
382
  import type { RouteDispatcher } from '../route-matcher'
383
383
  import type { WorkerRegistry } from '../worker-registry'
384
384
 
385
+ export interface HostRouteInfo {
386
+ pattern: string
387
+ workerName: string
388
+ }
389
+
385
390
  export interface HandlerContext {
386
391
  config: WranglerConfig | null
387
392
  manager: GenerationManager | null
388
393
  registry: WorkerRegistry | null
389
394
  lopataConfig: LopataConfig | null
390
395
  routeDispatcher: RouteDispatcher | null
396
+ hostRoutes: HostRouteInfo[]
391
397
  }
392
398
 
393
399
  export interface RouteInfo {
394
400
  pattern: string
395
401
  workerName: string
396
402
  isFallback: boolean
403
+ type?: 'path' | 'host'
397
404
  }
398
405
 
399
406
  /** Collect configs from all workers (registry) or fall back to single config. */
package/src/cli/dev.ts CHANGED
@@ -3,7 +3,15 @@ Error.stackTraceLimit = 50
3
3
 
4
4
  import '../plugin'
5
5
  import path from 'node:path'
6
- import { handleApiRequest, setDashboardConfig, setGenerationManager, setLopataConfig, setRouteDispatcher, setWorkerRegistry } from '../api'
6
+ import {
7
+ handleApiRequest,
8
+ setDashboardConfig,
9
+ setGenerationManager,
10
+ setHostRoutes,
11
+ setLopataConfig,
12
+ setRouteDispatcher,
13
+ setWorkerRegistry,
14
+ } from '../api'
7
15
  import { QueuePullConsumer } from '../bindings/queue'
8
16
  import type { AckRequest, PullRequest } from '../bindings/queue'
9
17
  import { CFWebSocket } from '../bindings/websocket-pair'
@@ -14,7 +22,7 @@ import { FileWatcher } from '../file-watcher'
14
22
  import { GenerationManager } from '../generation-manager'
15
23
  import { loadLopataConfig } from '../lopata-config'
16
24
  import { addCfProperty } from '../request-cf'
17
- import { RouteDispatcher } from '../route-matcher'
25
+ import { matchHost, RouteDispatcher } from '../route-matcher'
18
26
  import { getTraceStore } from '../tracing/store'
19
27
  import type { TraceEvent } from '../tracing/types'
20
28
  import { WorkerRegistry } from '../worker-registry'
@@ -35,6 +43,7 @@ export async function run(ctx: CliContext) {
35
43
  let manager: GenerationManager
36
44
  let routeDispatcher: RouteDispatcher | undefined
37
45
  let registry: WorkerRegistry | undefined
46
+ const hostRoutes: Array<{ pattern: string; manager: GenerationManager; workerName: string }> = []
38
47
 
39
48
  if (lopataConfig) {
40
49
  // ─── Multi-worker mode ─────────────────────────────────────────
@@ -143,6 +152,21 @@ export async function run(ctx: CliContext) {
143
152
  }
144
153
  }
145
154
 
155
+ // Build host-based routing map
156
+ for (const workerDef of lopataConfig.workers ?? []) {
157
+ if (!workerDef.hosts) continue
158
+ const auxMgr = registry.getManager(workerDef.name)
159
+ if (!auxMgr) continue
160
+ for (const host of workerDef.hosts) {
161
+ hostRoutes.push({ pattern: host, manager: auxMgr, workerName: workerDef.name })
162
+ console.log(`[lopata] Host route: ${host} → ${workerDef.name}`)
163
+ }
164
+ }
165
+
166
+ if (hostRoutes.length > 0) {
167
+ setHostRoutes(hostRoutes.map(hr => ({ pattern: hr.pattern, workerName: hr.workerName })))
168
+ }
169
+
146
170
  manager = mainManager
147
171
  setGenerationManager(manager)
148
172
  setWorkerRegistry(registry)
@@ -256,6 +280,19 @@ export async function run(ctx: CliContext) {
256
280
  return gen.callScheduled(cronExpr)
257
281
  }
258
282
 
283
+ // Host-based dispatch: match Host header against configured patterns
284
+ if (hostRoutes.length > 0) {
285
+ const hostHeader = request.headers.get('host') ?? ''
286
+ const hostname = hostHeader.split(':')[0] ?? ''
287
+ for (const hr of hostRoutes) {
288
+ if (matchHost(hostname, hr.pattern)) {
289
+ const gen = hr.manager.active
290
+ if (!gen) return new Response('No active generation', { status: 503 })
291
+ return (await gen.callFetch(request, server)) as Response
292
+ }
293
+ }
294
+ }
295
+
259
296
  // Delegate to active generation (route-based dispatch in multi-worker mode)
260
297
  const targetManager = routeDispatcher ? routeDispatcher.resolve(url.pathname) : manager
261
298
  const gen = targetManager.active
@@ -8,6 +8,7 @@ export interface LopataConfig {
8
8
  workers?: Array<{
9
9
  name: string
10
10
  config: string
11
+ hosts?: string[]
11
12
  }>
12
13
  /** Enable real cron scheduling based on wrangler triggers.crons (default: false) */
13
14
  cron?: boolean
@@ -61,6 +61,17 @@ export function matchRoute(pathname: string, pattern: string): boolean {
61
61
  return pathname === pattern
62
62
  }
63
63
 
64
+ /** Match a hostname against a host pattern. Supports exact match and `*.domain` wildcards. */
65
+ export function matchHost(hostname: string, pattern: string): boolean {
66
+ if (pattern === hostname) return true
67
+ if (pattern.startsWith('*.')) {
68
+ const suffix = pattern.slice(1) // ".localhost"
69
+ // Must have a subdomain — bare hostname doesn't match *.localhost
70
+ return hostname.endsWith(suffix) && hostname.length > suffix.length
71
+ }
72
+ return false
73
+ }
74
+
64
75
  /** Count the number of path segments in a pattern (ignoring trailing wildcard). */
65
76
  function segmentCount(pattern: string): number {
66
77
  const clean = pattern.replace(/\/?\*$/, '')
@@ -2,12 +2,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
2
2
  import { dirname, resolve } from 'node:path'
3
3
  import type { Plugin, ViteDevServer } from 'vite'
4
4
  import { FileWatcher } from '../file-watcher.ts'
5
- import { RouteDispatcher } from '../route-matcher.ts'
5
+ import type { RoutableManager } from '../route-matcher.ts'
6
+ import { matchHost, RouteDispatcher } from '../route-matcher.ts'
6
7
 
7
8
  interface DevServerPluginOptions {
8
9
  configPath?: string
9
10
  envName: string
10
- auxiliaryWorkers?: { configPath: string; name?: string }[]
11
+ auxiliaryWorkers?: { configPath: string; name?: string; hosts?: string[] }[]
11
12
  }
12
13
 
13
14
  /**
@@ -52,6 +53,8 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
52
53
 
53
54
  // Route dispatcher for multi-worker route-based dispatching
54
55
  let routeDispatcher: RouteDispatcher | undefined
56
+ // Host-based routing: map host patterns to managers
57
+ const hostRoutes: Array<{ pattern: string; manager: RoutableManager; workerName: string }> = []
55
58
 
56
59
  // Track current module to detect when Vite HMR invalidates it
57
60
  let currentModule: Record<string, unknown> | null = null
@@ -203,6 +206,33 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
203
206
  }
204
207
  }
205
208
 
209
+ /**
210
+ * Resolve an auxiliary worker for the given request.
211
+ * Checks host-based routes first, then path-based route dispatcher.
212
+ * Returns null if the request should be handled by the main worker.
213
+ */
214
+ function resolveAuxWorker(req: IncomingMessage, url: string): { manager: RoutableManager; workerName: string } | null {
215
+ // Host-based dispatch
216
+ if (hostRoutes.length > 0) {
217
+ const hostHeader = req.headers.host ?? ''
218
+ const hostname = hostHeader.split(':')[0] ?? ''
219
+ for (const hr of hostRoutes) {
220
+ if (matchHost(hostname, hr.pattern)) {
221
+ return hr
222
+ }
223
+ }
224
+ }
225
+ // Path-based dispatch
226
+ if (routeDispatcher) {
227
+ const parsedUrl = new URL(url, 'http://localhost')
228
+ const targetManager = routeDispatcher.resolve(parsedUrl.pathname)
229
+ if (!routeDispatcher.isFallback(targetManager)) {
230
+ return { manager: targetManager, workerName: (targetManager as any).config?.name ?? 'aux' }
231
+ }
232
+ }
233
+ return null
234
+ }
235
+
206
236
  return {
207
237
  name: 'lopata:dev-server',
208
238
 
@@ -421,6 +451,22 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
421
451
  }
422
452
  }
423
453
  apiMod.setRouteDispatcher(routeDispatcher)
454
+
455
+ // Build host-based routing map
456
+ for (const workerDef of options.auxiliaryWorkers) {
457
+ if (!workerDef.hosts) continue
458
+ const cached = auxConfigs.get(workerDef.configPath)
459
+ if (!cached) continue
460
+ const auxMgr = workerRegistry.getManager(cached.name)
461
+ if (!auxMgr) continue
462
+ for (const host of workerDef.hosts) {
463
+ hostRoutes.push({ pattern: host, manager: auxMgr, workerName: cached.name })
464
+ console.log(`[lopata:vite] Host route: ${host} → ${cached.name}`)
465
+ }
466
+ }
467
+ if (hostRoutes.length > 0) {
468
+ apiMod.setHostRoutes(hostRoutes.map(hr => ({ pattern: hr.pattern, workerName: hr.workerName })))
469
+ }
424
470
  }
425
471
 
426
472
  // 5. Set up WebSocket trace streaming on httpServer
@@ -471,26 +517,25 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
471
517
  return
472
518
  }
473
519
 
474
- // Route-based dispatch: if an aux worker matches, use its GenerationManager directly
475
- if (routeDispatcher) {
476
- const parsedUrl = new URL(url, 'http://localhost')
477
- const targetManager = routeDispatcher.resolve(parsedUrl.pathname)
478
- // If the resolved manager is not the main adapter, dispatch via aux worker
479
- if (!routeDispatcher.isFallback(targetManager)) {
480
- const gen = targetManager.active
520
+ // Aux worker dispatch: host-based first, then route-based
521
+ {
522
+ const resolved = resolveAuxWorker(req, url)
523
+ if (resolved) {
524
+ const gen = resolved.manager.active
481
525
  if (!gen) {
482
526
  if (!res.headersSent) {
483
527
  res.writeHead(503, { 'content-type': 'text/plain' })
484
- res.end('No active generation for matched route')
528
+ res.end('No active generation')
485
529
  }
486
530
  return
487
531
  }
488
532
  try {
489
533
  const request = nodeReqToRequest(req)
534
+ const parsedUrl = new URL(request.url)
490
535
  const response = await (startSpan as Function)({
491
536
  name: `${request.method} ${parsedUrl.pathname}`,
492
537
  kind: 'server',
493
- attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.worker': (targetManager as any).config?.name ?? 'aux' },
538
+ attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.worker': resolved.workerName },
494
539
  }, async () => {
495
540
  const resp = await gen.callFetch(request, null) as Response
496
541
  ;(setSpanAttribute as Function)('http.status_code', resp.status)
@@ -655,37 +700,35 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
655
700
  const request = nodeReqToRequest(req)
656
701
  const parsedUrl = new URL(request.url)
657
702
 
658
- // Route-based dispatch: if an aux worker matches, delegate the WebSocket upgrade to it
659
- if (routeDispatcher) {
660
- const targetManager = routeDispatcher.resolve(parsedUrl.pathname)
661
- if (!routeDispatcher.isFallback(targetManager)) {
662
- const gen = targetManager.active
663
- if (!gen) {
664
- socket.destroy()
665
- return
666
- }
667
- const response = await (startSpan as Function)({
668
- name: `WS ${parsedUrl.pathname}`,
669
- kind: 'server',
670
- attributes: {
671
- 'http.method': 'GET',
672
- 'http.url': request.url,
673
- 'lopata.worker': (targetManager as any).config?.name ?? 'aux',
674
- 'lopata.websocket': true,
675
- },
676
- }, async () => {
677
- return gen.callFetch(request, null) as Promise<Response & { webSocket?: InstanceType<typeof CFWebSocket> }>
678
- }) as Response & { webSocket?: InstanceType<typeof CFWebSocket> }
679
- const cfSocket = response.webSocket
680
- if (response.status !== 101 || !cfSocket || !(cfSocket instanceof CFWebSocket)) {
681
- socket.destroy()
682
- return
683
- }
684
- wss.handleUpgrade(req, socket, head, (ws: any) => {
685
- bridgeCfWebSocket(cfSocket, ws)
686
- })
703
+ // Aux worker dispatch for WebSocket: host-based first, then route-based
704
+ const resolved = resolveAuxWorker(req, req.url ?? '/')
705
+ if (resolved) {
706
+ const gen = resolved.manager.active
707
+ if (!gen) {
708
+ socket.destroy()
709
+ return
710
+ }
711
+ const response = await (startSpan as Function)({
712
+ name: `WS ${parsedUrl.pathname}`,
713
+ kind: 'server',
714
+ attributes: {
715
+ 'http.method': 'GET',
716
+ 'http.url': request.url,
717
+ 'lopata.worker': resolved.workerName,
718
+ 'lopata.websocket': true,
719
+ },
720
+ }, async () => {
721
+ return gen.callFetch(request, null) as Promise<Response & { webSocket?: InstanceType<typeof CFWebSocket> }>
722
+ }) as Response & { webSocket?: InstanceType<typeof CFWebSocket> }
723
+ const cfSocket = response.webSocket
724
+ if (response.status !== 101 || !cfSocket || !(cfSocket instanceof CFWebSocket)) {
725
+ socket.destroy()
687
726
  return
688
727
  }
728
+ wss.handleUpgrade(req, socket, head, (ws: any) => {
729
+ bridgeCfWebSocket(cfSocket, ws)
730
+ })
731
+ return
689
732
  }
690
733
 
691
734
  const activeModule = await ensureWorkerModule()
@@ -11,7 +11,7 @@ export interface LopataPluginConfig {
11
11
  /** Vite environment name for SSR. Default: "ssr" */
12
12
  viteEnvironment?: { name?: string }
13
13
  /** Auxiliary workers loaded via native Bun import (not through Vite). */
14
- auxiliaryWorkers?: { configPath: string; name?: string }[]
14
+ auxiliaryWorkers?: { configPath: string; name?: string; hosts?: string[] }[]
15
15
  }
16
16
 
17
17
  /**
@@ -68,6 +68,9 @@ export class RpcTarget {
68
68
  this[Symbol.for("lopata.RpcTarget")] = true;
69
69
  }
70
70
  }
71
+ export function waitUntil(promise) {
72
+ // Shim for build — at runtime, the real cloudflare:workers module provides this.
73
+ }
71
74
  `
72
75
  }
73
76