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 +37 -0
- package/dist/dashboard/{chunk-yq8n0mcf.js → chunk-1ekdqbs2.js} +243 -79
- package/dist/dashboard/{chunk-csyd2tq2.css → chunk-jzyhpjad.css} +8 -0
- package/dist/dashboard/index.html +1 -1
- package/package.json +1 -1
- package/src/api/handlers/routes.ts +5 -0
- package/src/api/index.ts +6 -2
- package/src/api/types.ts +7 -0
- package/src/cli/dev.ts +36 -9
- package/src/lopata-config.ts +1 -0
- package/src/route-matcher.ts +37 -7
- package/src/vite-plugin/dev-server-plugin.ts +77 -44
- package/src/vite-plugin/index.ts +1 -1
- package/src/vite-plugin/modules-plugin.ts +3 -0
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:
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
/* @__PURE__ */ u3(
|
|
3024
|
-
|
|
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
|
-
|
|
3046
|
-
|
|
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("
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
/* @__PURE__ */ u3("
|
|
3210
|
-
class: "
|
|
3211
|
-
children:
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
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:
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
/* @__PURE__ */ u3(
|
|
3348
|
-
|
|
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
|
-
|
|
3367
|
-
|
|
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(
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
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("
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
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-
|
|
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
|
@@ -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 {
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
260
|
-
const
|
|
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 })
|
package/src/lopata-config.ts
CHANGED
package/src/route-matcher.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
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':
|
|
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
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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()
|
package/src/vite-plugin/index.ts
CHANGED
|
@@ -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
|
/**
|