lopata 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -198,6 +198,43 @@ export default {
198
198
 
199
199
  Workers can call each other via service bindings configured in their respective `wrangler.jsonc`. Both HTTP (`binding.fetch()`) and RPC (`binding.myMethod()`) modes are supported, including promise pipelining.
200
200
 
201
+ ### Route patterns
202
+
203
+ In multi-worker setups, auxiliary workers can use Cloudflare-style `routes` in their `wrangler.jsonc` to handle specific URL patterns. The main worker acts as a fallback for unmatched requests.
204
+
205
+ ```jsonc
206
+ // workers/api/wrangler.jsonc
207
+ {
208
+ "name": "api-worker",
209
+ "main": "src/index.ts",
210
+ "routes": [
211
+ "example.com/api/*",
212
+ { "pattern": "example.com/webhooks/*" }
213
+ ]
214
+ }
215
+ ```
216
+
217
+ Routes support trailing `*` wildcards (`/api/*` matches `/api/foo` and `/api/foo/bar`). When multiple routes match, the most specific one wins (more path segments > fewer, exact match > wildcard). The domain portion is stripped — only the path is matched locally.
218
+
219
+ ### Host-based routing
220
+
221
+ For routing by hostname (e.g. subdomains), use `hosts` in `lopata.config.ts`:
222
+
223
+ ```ts
224
+ export default {
225
+ main: './wrangler.jsonc',
226
+ workers: [
227
+ {
228
+ name: 'api-worker',
229
+ config: './workers/api/wrangler.jsonc',
230
+ hosts: ['api.localhost', '*.api.localhost'],
231
+ },
232
+ ],
233
+ }
234
+ ```
235
+
236
+ Wildcard patterns like `*.localhost` match any subdomain. Requests matching a host pattern are routed to that worker regardless of path-based routes.
237
+
201
238
  ## Vite plugin
202
239
 
203
240
  The Vite plugin is a drop-in replacement for `@cloudflare/vite-plugin`. It provides:
@@ -1142,6 +1142,63 @@ 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 extractText(v3) {
1172
+ if (v3 == null || v3 === false || v3 === true)
1173
+ return "";
1174
+ if (typeof v3 === "string" || typeof v3 === "number")
1175
+ return String(v3);
1176
+ if (Array.isArray(v3))
1177
+ return v3.map(extractText).join("");
1178
+ if (typeof v3 === "object" && "props" in v3) {
1179
+ const props = v3.props;
1180
+ return extractText(props.children);
1181
+ }
1182
+ return String(v3);
1183
+ }
1184
+ function tableToMarkdown(headers, rows) {
1185
+ const escape = (v3) => extractText(v3).replace(/\|/g, "\\|").replace(/\n/g, " ");
1186
+ const headerRow = `| ${headers.map(escape).join(" | ")} |`;
1187
+ const separator = `| ${headers.map(() => "---").join(" | ")} |`;
1188
+ const dataRows = rows.map((row) => `| ${row.map(escape).join(" | ")} |`);
1189
+ return [headerRow, separator, ...dataRows].join(`
1190
+ `);
1191
+ }
1192
+ function recordsToMarkdown(headers, rows) {
1193
+ return tableToMarkdown(headers, rows.map((row) => headers.map((h3) => row[h3])));
1194
+ }
1195
+ function keyValueToMarkdown(data) {
1196
+ const entries = Object.entries(data);
1197
+ if (entries.length === 0)
1198
+ return "";
1199
+ return tableToMarkdown(["Key", "Value"], entries);
1200
+ }
1201
+
1145
1202
  // src/dashboard/sql-browser/editable-cell.tsx
1146
1203
  function EditableCell({ value, onSave, foreignKey, onNavigateFK, onInspect, alignRight }) {
1147
1204
  const [editing, setEditing] = d2(false);
@@ -2354,6 +2411,9 @@ function TableDataView({ table, execQuery, onOpenInConsole, history: history2, b
2354
2411
  }, undefined, true, undefined, this)
2355
2412
  ]
2356
2413
  }, undefined, true, undefined, this),
2414
+ /* @__PURE__ */ u3(CopyMarkdownButton, {
2415
+ getMarkdown: () => recordsToMarkdown(displayCols, rows)
2416
+ }, undefined, false, undefined, this),
2357
2417
  /* @__PURE__ */ u3("button", {
2358
2418
  onClick: () => setShowInsert(!showInsert),
2359
2419
  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 +3077,41 @@ function SqlConsoleTab({ execQuery, generateSql, initialSql, history: history2 }
3017
3077
  function ResultTable({ columns, rows }) {
3018
3078
  return /* @__PURE__ */ u3("div", {
3019
3079
  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))
3080
+ children: [
3081
+ /* @__PURE__ */ u3("div", {
3082
+ class: "flex justify-end px-2 pt-2",
3083
+ children: /* @__PURE__ */ u3(CopyMarkdownButton, {
3084
+ getMarkdown: () => recordsToMarkdown(columns, rows)
3043
3085
  }, undefined, false, undefined, this)
3044
- ]
3045
- }, undefined, true, undefined, this)
3046
- }, undefined, false, undefined, this);
3086
+ }, undefined, false, undefined, this),
3087
+ /* @__PURE__ */ u3("table", {
3088
+ class: "w-full text-sm",
3089
+ children: [
3090
+ /* @__PURE__ */ u3("thead", {
3091
+ children: /* @__PURE__ */ u3("tr", {
3092
+ class: "border-b border-border-subtle",
3093
+ children: columns.map((col) => /* @__PURE__ */ u3("th", {
3094
+ class: "text-left px-4 py-2.5 font-medium text-xs text-text-muted uppercase tracking-wider font-mono",
3095
+ children: col
3096
+ }, col, false, undefined, this))
3097
+ }, undefined, false, undefined, this)
3098
+ }, undefined, false, undefined, this),
3099
+ /* @__PURE__ */ u3("tbody", {
3100
+ children: rows.map((row, i3) => /* @__PURE__ */ u3("tr", {
3101
+ class: "group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors",
3102
+ children: columns.map((col) => /* @__PURE__ */ u3("td", {
3103
+ class: "px-4 py-2.5 font-mono text-xs",
3104
+ children: row[col] === null ? /* @__PURE__ */ u3("span", {
3105
+ class: "text-text-dim italic",
3106
+ children: "NULL"
3107
+ }, undefined, false, undefined, this) : String(row[col])
3108
+ }, col, false, undefined, this))
3109
+ }, i3, false, undefined, this))
3110
+ }, undefined, false, undefined, this)
3111
+ ]
3112
+ }, undefined, true, undefined, this)
3113
+ ]
3114
+ }, undefined, true, undefined, this);
3047
3115
  }
3048
3116
 
3049
3117
  // src/dashboard/sql-browser/sql-browser.tsx
@@ -3195,25 +3263,35 @@ function KeyValueTable({ data }) {
3195
3263
  children: "No entries"
3196
3264
  }, undefined, false, undefined, this);
3197
3265
  }
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);
3266
+ return /* @__PURE__ */ u3("div", {
3267
+ children: [
3268
+ /* @__PURE__ */ u3("div", {
3269
+ class: "flex justify-end px-2 pt-1",
3270
+ children: /* @__PURE__ */ u3(CopyMarkdownButton, {
3271
+ getMarkdown: () => keyValueToMarkdown(data)
3272
+ }, undefined, false, undefined, this)
3273
+ }, undefined, false, undefined, this),
3274
+ /* @__PURE__ */ u3("table", {
3275
+ class: "w-full text-sm",
3276
+ children: /* @__PURE__ */ u3("tbody", {
3277
+ children: entries.map(([key, value]) => /* @__PURE__ */ u3("tr", {
3278
+ class: "border-b border-border-subtle last:border-0 hover:bg-panel-hover/50 transition-colors",
3279
+ children: [
3280
+ /* @__PURE__ */ u3("td", {
3281
+ class: "px-4 py-2 font-medium text-text-secondary whitespace-nowrap align-top font-mono",
3282
+ style: "width: 1%;",
3283
+ children: key
3284
+ }, undefined, false, undefined, this),
3285
+ /* @__PURE__ */ u3("td", {
3286
+ class: "px-4 py-2 text-ink break-all font-mono",
3287
+ children: value
3288
+ }, undefined, false, undefined, this)
3289
+ ]
3290
+ }, key, true, undefined, this))
3291
+ }, undefined, false, undefined, this)
3292
+ }, undefined, false, undefined, this)
3293
+ ]
3294
+ }, undefined, true, undefined, this);
3217
3295
  }
3218
3296
  // src/dashboard/components/page-header.tsx
3219
3297
  function PageHeader({ title, subtitle, actions }) {
@@ -3341,30 +3419,38 @@ function StatusBadge({ status, colorMap }) {
3341
3419
  function Table({ headers, rows }) {
3342
3420
  return /* @__PURE__ */ u3("div", {
3343
3421
  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))
3422
+ children: [
3423
+ /* @__PURE__ */ u3("div", {
3424
+ class: "flex justify-end px-2 pt-2",
3425
+ children: /* @__PURE__ */ u3(CopyMarkdownButton, {
3426
+ getMarkdown: () => tableToMarkdown(headers, rows)
3364
3427
  }, undefined, false, undefined, this)
3365
- ]
3366
- }, undefined, true, undefined, this)
3367
- }, undefined, false, undefined, this);
3428
+ }, undefined, false, undefined, this),
3429
+ /* @__PURE__ */ u3("table", {
3430
+ class: "w-full text-sm",
3431
+ children: [
3432
+ /* @__PURE__ */ u3("thead", {
3433
+ children: /* @__PURE__ */ u3("tr", {
3434
+ class: "border-b border-border-subtle",
3435
+ children: headers.map((h3) => /* @__PURE__ */ u3("th", {
3436
+ class: "text-left px-4 py-3 font-mono font-medium text-xs text-text-muted uppercase tracking-wider",
3437
+ children: h3
3438
+ }, h3, false, undefined, this))
3439
+ }, undefined, false, undefined, this)
3440
+ }, undefined, false, undefined, this),
3441
+ /* @__PURE__ */ u3("tbody", {
3442
+ children: rows.map((row, i3) => /* @__PURE__ */ u3("tr", {
3443
+ class: "group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors",
3444
+ children: row.map((cell, j3) => /* @__PURE__ */ u3("td", {
3445
+ class: "px-4 py-3",
3446
+ children: cell
3447
+ }, j3, false, undefined, this))
3448
+ }, i3, false, undefined, this))
3449
+ }, undefined, false, undefined, this)
3450
+ ]
3451
+ }, undefined, true, undefined, this)
3452
+ ]
3453
+ }, undefined, true, undefined, this);
3368
3454
  }
3369
3455
  // src/dashboard/components/table-link.tsx
3370
3456
  function TableLink({ href, children, mono }) {
@@ -5675,11 +5761,25 @@ function ErrorList() {
5675
5761
  /* @__PURE__ */ u3(RefreshButton, {
5676
5762
  onClick: () => loadErrors()
5677
5763
  }, 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)
5764
+ errors.length > 0 && /* @__PURE__ */ u3(k, {
5765
+ children: [
5766
+ /* @__PURE__ */ u3(CopyMarkdownButton, {
5767
+ getMarkdown: () => tableToMarkdown(["Source", "Error", "Message", "Context", "Worker", "Time"], errors.map((err) => [
5768
+ err.source ?? "-",
5769
+ err.errorName,
5770
+ err.errorMessage,
5771
+ err.requestMethod && err.requestUrl ? `${err.requestMethod} ${err.requestUrl}` : "-",
5772
+ err.workerName ?? "-",
5773
+ formatTimestamp2(err.timestamp)
5774
+ ]))
5775
+ }, undefined, false, undefined, this),
5776
+ /* @__PURE__ */ u3("button", {
5777
+ onClick: handleClear,
5778
+ class: "rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
5779
+ children: "Clear all"
5780
+ }, undefined, false, undefined, this)
5781
+ ]
5782
+ }, undefined, true, undefined, this)
5683
5783
  ]
5684
5784
  }, undefined, true, undefined, this)
5685
5785
  ]
@@ -5889,11 +5989,20 @@ function ErrorDetailPage({ errorId }) {
5889
5989
  }, undefined, true, undefined, this)
5890
5990
  ]
5891
5991
  }, 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)
5992
+ /* @__PURE__ */ u3("div", {
5993
+ class: "flex items-center gap-2",
5994
+ children: [
5995
+ /* @__PURE__ */ u3(CopyMarkdownButton, {
5996
+ getMarkdown: () => errorToMarkdown(detail),
5997
+ title: "Copy error as Markdown for LLM"
5998
+ }, undefined, false, undefined, this),
5999
+ /* @__PURE__ */ u3("button", {
6000
+ onClick: handleDelete,
6001
+ class: "rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary btn-danger transition-all",
6002
+ children: "Delete"
6003
+ }, undefined, false, undefined, this)
6004
+ ]
6005
+ }, undefined, true, undefined, this)
5897
6006
  ]
5898
6007
  }, undefined, true, undefined, this),
5899
6008
  /* @__PURE__ */ u3("div", {
@@ -6296,6 +6405,60 @@ function truncateUrl(url) {
6296
6405
  return url.length > 50 ? url.slice(0, 50) + "..." : url;
6297
6406
  }
6298
6407
  }
6408
+ function errorToMarkdown(detail) {
6409
+ const { data } = detail;
6410
+ const lines = [];
6411
+ lines.push(`## ${data.error.name}: ${data.error.message}`);
6412
+ if (detail.source)
6413
+ lines.push(`**Source:** ${detail.source}`);
6414
+ if (data.runtime.workerName)
6415
+ lines.push(`**Worker:** ${data.runtime.workerName}`);
6416
+ lines.push("");
6417
+ if (data.error.stack) {
6418
+ lines.push("### Stack Trace");
6419
+ lines.push("```");
6420
+ lines.push(data.error.stack);
6421
+ lines.push("```");
6422
+ lines.push("");
6423
+ }
6424
+ if (data.error.frames.length > 0) {
6425
+ lines.push("### Source Code");
6426
+ for (const frame of data.error.frames) {
6427
+ lines.push(`#### ${frame.file}:${frame.line}:${frame.column}${frame.function ? ` in ${frame.function}` : ""}`);
6428
+ if (frame.source && frame.source.length > 0) {
6429
+ const startLine = frame.line - (frame.sourceLine ?? 0);
6430
+ lines.push("```");
6431
+ frame.source.forEach((line, i3) => {
6432
+ const lineNum = startLine + i3;
6433
+ const marker = i3 === frame.sourceLine ? ">" : " ";
6434
+ lines.push(`${marker} ${lineNum} | ${line}`);
6435
+ });
6436
+ lines.push("```");
6437
+ }
6438
+ lines.push("");
6439
+ }
6440
+ }
6441
+ if (data.request.method && data.request.url) {
6442
+ lines.push("### Request");
6443
+ lines.push(`${data.request.method} ${data.request.url}`);
6444
+ const headers = Object.entries(data.request.headers);
6445
+ if (headers.length > 0) {
6446
+ lines.push("");
6447
+ lines.push(keyValueToMarkdown(data.request.headers));
6448
+ }
6449
+ lines.push("");
6450
+ }
6451
+ if (data.bindings.length > 0) {
6452
+ lines.push("### Bindings");
6453
+ lines.push(tableToMarkdown(["Name", "Type"], data.bindings.map((b) => [b.name, b.type])));
6454
+ lines.push("");
6455
+ }
6456
+ lines.push("### Runtime");
6457
+ lines.push(`- **Bun:** ${data.runtime.bunVersion}`);
6458
+ lines.push(`- **Platform:** ${data.runtime.platform} / ${data.runtime.arch}`);
6459
+ return lines.join(`
6460
+ `);
6461
+ }
6299
6462
 
6300
6463
  // src/dashboard/views/generations.tsx
6301
6464
  var STATE_COLORS = {
@@ -7861,6 +8024,7 @@ function R2ObjectList({ bucket }) {
7861
8024
  // src/dashboard/views/routes.tsx
7862
8025
  var TYPE_COLORS = {
7863
8026
  route: "bg-emerald-500/15 text-emerald-500",
8027
+ host: "bg-blue-500/15 text-blue-500",
7864
8028
  fallback: "bg-panel-active text-text-data"
7865
8029
  };
7866
8030
  function RoutesView() {
@@ -7886,7 +8050,7 @@ function RoutesView() {
7886
8050
  children: r3.workerName
7887
8051
  }, undefined, false, undefined, this),
7888
8052
  /* @__PURE__ */ u3(StatusBadge, {
7889
- status: r3.isFallback ? "fallback" : "route",
8053
+ status: r3.isFallback ? "fallback" : r3.type === "host" ? "host" : "route",
7890
8054
  colorMap: TYPE_COLORS
7891
8055
  }, undefined, false, undefined, this)
7892
8056
  ])
@@ -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-1ekdqbs2.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.1",
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 { extractHostname, 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'
@@ -105,7 +113,7 @@ export async function run(ctx: CliContext) {
105
113
  if (routeDispatcher) {
106
114
  try {
107
115
  const freshConfig = await loadConfig(workerDef.config, envFlag)
108
- routeDispatcher.addRoutes(freshConfig, auxManager, workerDef.name)
116
+ routeDispatcher.addRoutes(freshConfig, auxManager, workerDef.name, workerDef.hosts)
109
117
  } catch (err) {
110
118
  console.warn(`[lopata] Failed to re-read config for "${workerDef.name}" routes:`, err)
111
119
  }
@@ -130,18 +138,36 @@ export async function run(ctx: CliContext) {
130
138
  )
131
139
  }
132
140
 
133
- // Build route dispatcher for route-based worker selection (aux workers only — main is the fallback)
141
+ // Build route dispatcher (aux workers only — main is the fallback)
134
142
  routeDispatcher = new RouteDispatcher(mainManager)
135
143
  for (const workerDef of lopataConfig.workers ?? []) {
136
- const auxConfig = auxConfigs.get(workerDef.name)
137
144
  const auxMgr = registry.getManager(workerDef.name)
138
- if (auxConfig && auxMgr) routeDispatcher.addRoutes(auxConfig, auxMgr, workerDef.name)
145
+ if (!auxMgr) continue
146
+ const auxConfig = auxConfigs.get(workerDef.name)
147
+ if (auxConfig) routeDispatcher.addRoutes(auxConfig, auxMgr, workerDef.name, workerDef.hosts)
148
+ // Workers with hosts but no wrangler routes still need a catch-all entry
149
+ if (workerDef.hosts && (!auxConfig?.routes || auxConfig.routes.length === 0)) {
150
+ routeDispatcher.addHostWorker(auxMgr, workerDef.name, workerDef.hosts)
151
+ }
139
152
  }
140
153
  if (routeDispatcher.hasRoutes()) {
141
154
  for (const r of routeDispatcher.getRegisteredRoutes()) {
142
- console.log(`[lopata] Route: ${r.pattern} ${r.workerName}`)
155
+ const hostInfo = r.hostPatterns ? ` (hosts: ${r.hostPatterns.join(', ')})` : ''
156
+ console.log(`[lopata] Route: ${r.pattern} → ${r.workerName}${hostInfo}`)
157
+ }
158
+ }
159
+
160
+ // Expose host routes to the dashboard API
161
+ const hostRoutes: Array<{ pattern: string; workerName: string }> = []
162
+ for (const workerDef of lopataConfig.workers ?? []) {
163
+ if (!workerDef.hosts) continue
164
+ for (const host of workerDef.hosts) {
165
+ hostRoutes.push({ pattern: host, workerName: workerDef.name })
143
166
  }
144
167
  }
168
+ if (hostRoutes.length > 0) {
169
+ setHostRoutes(hostRoutes)
170
+ }
145
171
 
146
172
  manager = mainManager
147
173
  setGenerationManager(manager)
@@ -256,8 +282,9 @@ export async function run(ctx: CliContext) {
256
282
  return gen.callScheduled(cronExpr)
257
283
  }
258
284
 
259
- // Delegate to active generation (route-based dispatch in multi-worker mode)
260
- const targetManager = routeDispatcher ? routeDispatcher.resolve(url.pathname) : manager
285
+ // Delegate to active generation (host + route dispatch in multi-worker mode)
286
+ const reqHostname = extractHostname(request.headers.get('host') ?? '')
287
+ const targetManager = routeDispatcher ? routeDispatcher.resolve(url.pathname, reqHostname) : manager
261
288
  const gen = targetManager.active
262
289
  if (!gen) {
263
290
  return new Response('No active generation', { status: 503 })
@@ -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,22 @@ export function matchRoute(pathname: string, pattern: string): boolean {
61
61
  return pathname === pattern
62
62
  }
63
63
 
64
+ /** Extract hostname from a Host header value, stripping the port if present. */
65
+ export function extractHostname(hostHeader: string): string {
66
+ return hostHeader.split(':')[0] ?? ''
67
+ }
68
+
69
+ /** Match a hostname against a host pattern. Supports exact match and `*.domain` wildcards. */
70
+ export function matchHost(hostname: string, pattern: string): boolean {
71
+ if (pattern === hostname) return true
72
+ if (pattern.startsWith('*.')) {
73
+ const suffix = pattern.slice(1) // ".localhost"
74
+ // Must have a subdomain — bare hostname doesn't match *.localhost
75
+ return hostname.endsWith(suffix) && hostname.length > suffix.length
76
+ }
77
+ return false
78
+ }
79
+
64
80
  /** Count the number of path segments in a pattern (ignoring trailing wildcard). */
65
81
  function segmentCount(pattern: string): number {
66
82
  const clean = pattern.replace(/\/?\*$/, '')
@@ -72,6 +88,8 @@ interface RouteEntry {
72
88
  pattern: string
73
89
  workerName: string
74
90
  manager: RoutableManager
91
+ /** When set, this route only matches requests whose hostname matches one of these patterns. */
92
+ hostPatterns?: string[]
75
93
  }
76
94
 
77
95
  /**
@@ -90,7 +108,7 @@ export class RouteDispatcher {
90
108
  this.fallback = fallback
91
109
  }
92
110
 
93
- addRoutes(config: WranglerConfig, manager: RoutableManager, workerName: string): void {
111
+ addRoutes(config: WranglerConfig, manager: RoutableManager, workerName: string, hostPatterns?: string[]): void {
94
112
  if (!config.routes) return
95
113
 
96
114
  // Clear existing routes for this worker to support re-registration (e.g. config reload)
@@ -116,8 +134,8 @@ export class RouteDispatcher {
116
134
  console.warn(`[lopata] Warning: route pattern "${pattern}" has a wildcard not at the end — Cloudflare only supports trailing wildcards`)
117
135
  }
118
136
 
119
- // Skip duplicate patterns from different workers (first registered wins)
120
- const existing = this.routes.find(r => r.pattern === pattern)
137
+ // Skip duplicate patterns from different workers when they share the same host scope (first registered wins)
138
+ const existing = this.routes.find(r => r.pattern === pattern && !r.hostPatterns && !hostPatterns)
121
139
  if (existing) {
122
140
  console.warn(
123
141
  `[lopata] Warning: route pattern "${pattern}" is already registered by "${existing.workerName}" — skipping duplicate from "${workerName}"`,
@@ -125,11 +143,19 @@ export class RouteDispatcher {
125
143
  continue
126
144
  }
127
145
 
128
- this.routes.push({ pattern, workerName, manager })
146
+ this.routes.push({ pattern, workerName, manager, hostPatterns })
129
147
  this.sorted = false
130
148
  }
131
149
  }
132
150
 
151
+ /** Register a worker that handles all paths for the given host patterns (no wrangler routes needed). */
152
+ addHostWorker(manager: RoutableManager, workerName: string, hostPatterns: string[]): void {
153
+ // Clear existing routes for this worker to support re-registration
154
+ this.routes = this.routes.filter(r => r.workerName !== workerName)
155
+ this.routes.push({ pattern: '/*', workerName, manager, hostPatterns })
156
+ this.sorted = false
157
+ }
158
+
133
159
  removeWorkerRoutes(workerName: string): void {
134
160
  this.routes = this.routes.filter(r => r.workerName !== workerName)
135
161
  }
@@ -155,9 +181,13 @@ export class RouteDispatcher {
155
181
  this.sorted = true
156
182
  }
157
183
 
158
- resolve(pathname: string): RoutableManager {
184
+ resolve(pathname: string, hostname?: string): RoutableManager {
159
185
  this.ensureSorted()
160
186
  for (const entry of this.routes) {
187
+ // If route has host constraints, skip unless hostname matches one of them
188
+ if (entry.hostPatterns) {
189
+ if (hostname === undefined || !entry.hostPatterns.some(hp => matchHost(hostname, hp))) continue
190
+ }
161
191
  if (matchRoute(pathname, entry.pattern)) {
162
192
  return entry.manager
163
193
  }
@@ -174,8 +204,8 @@ export class RouteDispatcher {
174
204
  return this.routes.length > 0
175
205
  }
176
206
 
177
- getRegisteredRoutes(): Array<{ pattern: string; workerName: string }> {
207
+ getRegisteredRoutes(): Array<{ pattern: string; workerName: string; hostPatterns?: string[] }> {
178
208
  this.ensureSorted()
179
- return this.routes.map(r => ({ pattern: r.pattern, workerName: r.workerName }))
209
+ return this.routes.map(r => ({ pattern: r.pattern, workerName: r.workerName, hostPatterns: r.hostPatterns }))
180
210
  }
181
211
  }
@@ -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 { extractHostname, 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
  /**
@@ -203,6 +204,21 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
203
204
  }
204
205
  }
205
206
 
207
+ /**
208
+ * Resolve an auxiliary worker for the given request.
209
+ * Returns null if the request should be handled by the main worker.
210
+ */
211
+ function resolveAuxWorker(req: IncomingMessage, url: string): { manager: RoutableManager; workerName: string } | null {
212
+ if (!routeDispatcher) return null
213
+ const parsedUrl = new URL(url, 'http://localhost')
214
+ const hostname = extractHostname(req.headers.host ?? '')
215
+ const targetManager = routeDispatcher.resolve(parsedUrl.pathname, hostname)
216
+ if (!routeDispatcher.isFallback(targetManager)) {
217
+ return { manager: targetManager, workerName: (targetManager as any).config?.name ?? 'aux' }
218
+ }
219
+ return null
220
+ }
221
+
206
222
  return {
207
223
  name: 'lopata:dev-server',
208
224
 
@@ -385,7 +401,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
385
401
  if (routeDispatcher) {
386
402
  try {
387
403
  const freshConfig = await configMod.loadConfig(auxConfigPath)
388
- routeDispatcher.addRoutes(freshConfig, auxManager, workerName)
404
+ routeDispatcher.addRoutes(freshConfig, auxManager, workerName, workerDef.hosts)
389
405
  } catch (err) {
390
406
  console.warn(`[lopata:vite] Failed to re-read config for "${workerName}" routes:`, err)
391
407
  }
@@ -407,20 +423,40 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
407
423
  )
408
424
  }
409
425
 
410
- // Build route dispatcher for aux workers with routes (main worker is the fallback)
426
+ // Build route dispatcher (aux workers only main is the fallback)
411
427
  routeDispatcher = new RouteDispatcher(mainAdapter)
412
428
  for (const workerDef of options.auxiliaryWorkers) {
413
429
  const cached = auxConfigs.get(workerDef.configPath)
414
430
  if (!cached) continue
415
431
  const auxMgr = workerRegistry.getManager(cached.name)
416
- if (auxMgr) routeDispatcher.addRoutes(cached.config, auxMgr, cached.name)
432
+ if (!auxMgr) continue
433
+ routeDispatcher.addRoutes(cached.config, auxMgr, cached.name, workerDef.hosts)
434
+ // Workers with hosts but no wrangler routes still need a catch-all entry
435
+ if (workerDef.hosts && (!cached.config.routes || cached.config.routes.length === 0)) {
436
+ routeDispatcher.addHostWorker(auxMgr, cached.name, workerDef.hosts)
437
+ }
417
438
  }
418
439
  if (routeDispatcher.hasRoutes()) {
419
440
  for (const r of routeDispatcher.getRegisteredRoutes()) {
420
- console.log(`[lopata:vite] Route: ${r.pattern} ${r.workerName}`)
441
+ const hostInfo = r.hostPatterns ? ` (hosts: ${r.hostPatterns.join(', ')})` : ''
442
+ console.log(`[lopata:vite] Route: ${r.pattern} → ${r.workerName}${hostInfo}`)
421
443
  }
422
444
  }
423
445
  apiMod.setRouteDispatcher(routeDispatcher)
446
+
447
+ // Expose host routes to the dashboard API
448
+ const hostRoutes: Array<{ pattern: string; workerName: string }> = []
449
+ for (const workerDef of options.auxiliaryWorkers) {
450
+ if (!workerDef.hosts) continue
451
+ const cached = auxConfigs.get(workerDef.configPath)
452
+ if (!cached) continue
453
+ for (const host of workerDef.hosts) {
454
+ hostRoutes.push({ pattern: host, workerName: cached.name })
455
+ }
456
+ }
457
+ if (hostRoutes.length > 0) {
458
+ apiMod.setHostRoutes(hostRoutes)
459
+ }
424
460
  }
425
461
 
426
462
  // 5. Set up WebSocket trace streaming on httpServer
@@ -471,26 +507,25 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
471
507
  return
472
508
  }
473
509
 
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
510
+ // Aux worker dispatch: host-based first, then route-based
511
+ {
512
+ const resolved = resolveAuxWorker(req, url)
513
+ if (resolved) {
514
+ const gen = resolved.manager.active
481
515
  if (!gen) {
482
516
  if (!res.headersSent) {
483
517
  res.writeHead(503, { 'content-type': 'text/plain' })
484
- res.end('No active generation for matched route')
518
+ res.end('No active generation')
485
519
  }
486
520
  return
487
521
  }
488
522
  try {
489
523
  const request = nodeReqToRequest(req)
524
+ const parsedUrl = new URL(request.url)
490
525
  const response = await (startSpan as Function)({
491
526
  name: `${request.method} ${parsedUrl.pathname}`,
492
527
  kind: 'server',
493
- attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.worker': (targetManager as any).config?.name ?? 'aux' },
528
+ attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.worker': resolved.workerName },
494
529
  }, async () => {
495
530
  const resp = await gen.callFetch(request, null) as Response
496
531
  ;(setSpanAttribute as Function)('http.status_code', resp.status)
@@ -655,37 +690,35 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
655
690
  const request = nodeReqToRequest(req)
656
691
  const parsedUrl = new URL(request.url)
657
692
 
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
- })
693
+ // Aux worker dispatch for WebSocket: host-based first, then route-based
694
+ const resolved = resolveAuxWorker(req, req.url ?? '/')
695
+ if (resolved) {
696
+ const gen = resolved.manager.active
697
+ if (!gen) {
698
+ socket.destroy()
699
+ return
700
+ }
701
+ const response = await (startSpan as Function)({
702
+ name: `WS ${parsedUrl.pathname}`,
703
+ kind: 'server',
704
+ attributes: {
705
+ 'http.method': 'GET',
706
+ 'http.url': request.url,
707
+ 'lopata.worker': resolved.workerName,
708
+ 'lopata.websocket': true,
709
+ },
710
+ }, async () => {
711
+ return gen.callFetch(request, null) as Promise<Response & { webSocket?: InstanceType<typeof CFWebSocket> }>
712
+ }) as Response & { webSocket?: InstanceType<typeof CFWebSocket> }
713
+ const cfSocket = response.webSocket
714
+ if (response.status !== 101 || !cfSocket || !(cfSocket instanceof CFWebSocket)) {
715
+ socket.destroy()
687
716
  return
688
717
  }
718
+ wss.handleUpgrade(req, socket, head, (ws: any) => {
719
+ bridgeCfWebSocket(cfSocket, ws)
720
+ })
721
+ return
689
722
  }
690
723
 
691
724
  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