lopata 0.10.2 → 0.12.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.
@@ -7196,6 +7196,180 @@ function HomeView() {
7196
7196
  }, undefined, true, undefined, this);
7197
7197
  }
7198
7198
 
7199
+ // src/dashboard/views/hosts.tsx
7200
+ var STATUS_COLORS = {
7201
+ ok: "bg-emerald-500/15 text-emerald-500",
7202
+ missing: "bg-red-500/15 text-red-500",
7203
+ wrong_address: "bg-red-500/15 text-red-500",
7204
+ wildcard: "bg-yellow-500/15 text-yellow-500"
7205
+ };
7206
+ var STATUS_LABELS = {
7207
+ ok: "ok",
7208
+ missing: "missing",
7209
+ wrong_address: "wrong address",
7210
+ wildcard: "wildcard"
7211
+ };
7212
+ function CopyButton({ text, label }) {
7213
+ const [copied, setCopied] = d2(false);
7214
+ const timerRef = A2(null);
7215
+ y2(() => {
7216
+ return () => {
7217
+ if (timerRef.current)
7218
+ clearTimeout(timerRef.current);
7219
+ };
7220
+ }, []);
7221
+ const handleCopy = () => {
7222
+ navigator.clipboard.writeText(text).then(() => {
7223
+ setCopied(true);
7224
+ if (timerRef.current)
7225
+ clearTimeout(timerRef.current);
7226
+ timerRef.current = setTimeout(() => setCopied(false), 2000);
7227
+ });
7228
+ };
7229
+ return /* @__PURE__ */ u3("button", {
7230
+ onClick: handleCopy,
7231
+ class: "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono rounded-md border border-border bg-panel hover:bg-panel-hover text-text-secondary hover:text-ink transition-colors",
7232
+ children: copied ? /* @__PURE__ */ u3(k, {
7233
+ children: [
7234
+ /* @__PURE__ */ u3("svg", {
7235
+ class: "w-3.5 h-3.5 text-emerald-500",
7236
+ viewBox: "0 0 16 16",
7237
+ fill: "none",
7238
+ stroke: "currentColor",
7239
+ "stroke-width": "2",
7240
+ children: /* @__PURE__ */ u3("path", {
7241
+ d: "M3 8.5l3 3 7-7",
7242
+ "stroke-linecap": "round",
7243
+ "stroke-linejoin": "round"
7244
+ }, undefined, false, undefined, this)
7245
+ }, undefined, false, undefined, this),
7246
+ "Copied!"
7247
+ ]
7248
+ }, undefined, true, undefined, this) : /* @__PURE__ */ u3(k, {
7249
+ children: [
7250
+ /* @__PURE__ */ u3("svg", {
7251
+ class: "w-3.5 h-3.5",
7252
+ viewBox: "0 0 16 16",
7253
+ fill: "none",
7254
+ stroke: "currentColor",
7255
+ "stroke-width": "1.5",
7256
+ children: [
7257
+ /* @__PURE__ */ u3("rect", {
7258
+ x: "5",
7259
+ y: "5",
7260
+ width: "8",
7261
+ height: "8",
7262
+ rx: "1",
7263
+ "stroke-linecap": "round",
7264
+ "stroke-linejoin": "round"
7265
+ }, undefined, false, undefined, this),
7266
+ /* @__PURE__ */ u3("path", {
7267
+ d: "M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5",
7268
+ "stroke-linecap": "round",
7269
+ "stroke-linejoin": "round"
7270
+ }, undefined, false, undefined, this)
7271
+ ]
7272
+ }, undefined, true, undefined, this),
7273
+ label
7274
+ ]
7275
+ }, undefined, true, undefined, this)
7276
+ }, undefined, false, undefined, this);
7277
+ }
7278
+ function HostsView() {
7279
+ const { data } = useQuery("hosts.check");
7280
+ const results = data?.results ?? [];
7281
+ const failing = results.filter((r3) => r3.status === "missing" || r3.status === "wrong_address");
7282
+ const missingHostnames = [...new Set(failing.map((r3) => r3.hostname))];
7283
+ const hostsLine = `127.0.0.1 ${missingHostnames.join(" ")}`;
7284
+ const fixCommand = data?.hostsFilePath ? `sudo sh -c 'echo "${hostsLine}" >> ${data.hostsFilePath}'` : "";
7285
+ return /* @__PURE__ */ u3("div", {
7286
+ class: "p-4 sm:p-8",
7287
+ children: [
7288
+ /* @__PURE__ */ u3(PageHeader, {
7289
+ title: "Hosts Check",
7290
+ subtitle: data?.hostsFilePath ? `Reading ${data.hostsFilePath}` : "Checking host routing setup"
7291
+ }, undefined, false, undefined, this),
7292
+ data?.error ? /* @__PURE__ */ u3("div", {
7293
+ class: "rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400",
7294
+ children: data.error
7295
+ }, undefined, false, undefined, this) : !results.length ? /* @__PURE__ */ u3(EmptyState, {
7296
+ message: "No host routing configured. Add hosts to workers in lopata.config.ts."
7297
+ }, undefined, false, undefined, this) : /* @__PURE__ */ u3(k, {
7298
+ children: [
7299
+ /* @__PURE__ */ u3(Table, {
7300
+ headers: ["Hostname", "Worker", "Address", "Status"],
7301
+ rows: results.map((r3) => [
7302
+ /* @__PURE__ */ u3("span", {
7303
+ class: "font-mono text-xs font-medium",
7304
+ children: r3.hostname
7305
+ }, undefined, false, undefined, this),
7306
+ /* @__PURE__ */ u3("span", {
7307
+ class: "text-text-secondary",
7308
+ children: r3.workerName
7309
+ }, undefined, false, undefined, this),
7310
+ /* @__PURE__ */ u3("span", {
7311
+ class: "font-mono text-xs text-text-secondary",
7312
+ children: r3.status === "ok" ? r3.address : r3.status === "wrong_address" ? r3.address : "—"
7313
+ }, undefined, false, undefined, this),
7314
+ /* @__PURE__ */ u3(StatusBadge, {
7315
+ status: STATUS_LABELS[r3.status] ?? r3.status,
7316
+ colorMap: STATUS_COLORS
7317
+ }, undefined, false, undefined, this)
7318
+ ])
7319
+ }, undefined, false, undefined, this),
7320
+ missingHostnames.length > 0 && /* @__PURE__ */ u3("div", {
7321
+ class: "mt-6 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4",
7322
+ children: [
7323
+ /* @__PURE__ */ u3("div", {
7324
+ class: "text-sm font-medium text-yellow-500 mb-2",
7325
+ children: "Missing hosts file entries"
7326
+ }, undefined, false, undefined, this),
7327
+ /* @__PURE__ */ u3("div", {
7328
+ class: "text-xs text-text-secondary mb-3",
7329
+ children: "Modifying the hosts file requires root privileges. Copy the command below and run it in your terminal:"
7330
+ }, undefined, false, undefined, this),
7331
+ /* @__PURE__ */ u3("div", {
7332
+ class: "flex items-stretch gap-2",
7333
+ children: [
7334
+ /* @__PURE__ */ u3("pre", {
7335
+ class: "flex-1 text-xs font-mono bg-surface p-3 rounded border border-border select-all overflow-x-auto",
7336
+ children: fixCommand
7337
+ }, undefined, false, undefined, this),
7338
+ /* @__PURE__ */ u3(CopyButton, {
7339
+ text: fixCommand,
7340
+ label: "Copy command"
7341
+ }, undefined, false, undefined, this)
7342
+ ]
7343
+ }, undefined, true, undefined, this),
7344
+ /* @__PURE__ */ u3("div", {
7345
+ class: "mt-3 text-[11px] text-text-muted",
7346
+ children: [
7347
+ "Or manually add this line to ",
7348
+ data?.hostsFilePath,
7349
+ ":"
7350
+ ]
7351
+ }, undefined, true, undefined, this),
7352
+ /* @__PURE__ */ u3("div", {
7353
+ class: "flex items-stretch gap-2 mt-1",
7354
+ children: [
7355
+ /* @__PURE__ */ u3("pre", {
7356
+ class: "flex-1 text-xs font-mono bg-surface p-3 rounded border border-border select-all",
7357
+ children: hostsLine
7358
+ }, undefined, false, undefined, this),
7359
+ /* @__PURE__ */ u3(CopyButton, {
7360
+ text: hostsLine,
7361
+ label: "Copy line"
7362
+ }, undefined, false, undefined, this)
7363
+ ]
7364
+ }, undefined, true, undefined, this)
7365
+ ]
7366
+ }, undefined, true, undefined, this)
7367
+ ]
7368
+ }, undefined, true, undefined, this)
7369
+ ]
7370
+ }, undefined, true, undefined, this);
7371
+ }
7372
+
7199
7373
  // src/dashboard/views/kv.tsx
7200
7374
  function KvView({ route }) {
7201
7375
  const parts = route.split("/").filter(Boolean);
@@ -9982,6 +10156,7 @@ var NAV_GROUPS = [
9982
10156
  items: [
9983
10157
  { path: "/workers", label: "Workers", icon: "workers" },
9984
10158
  { path: "/routes", label: "Routes", icon: "routes" },
10159
+ { path: "/hosts", label: "Hosts Check", icon: "routes" },
9985
10160
  { path: "/do", label: "Durable Objects", icon: "do" },
9986
10161
  { path: "/containers", label: "Containers", icon: "containers" },
9987
10162
  { path: "/workflows", label: "Workflows", icon: "workflows" },
@@ -10120,6 +10295,8 @@ function App() {
10120
10295
  return /* @__PURE__ */ u3(WorkersView, {}, undefined, false, undefined, this);
10121
10296
  if (route.startsWith("/routes"))
10122
10297
  return /* @__PURE__ */ u3(RoutesView, {}, undefined, false, undefined, this);
10298
+ if (route.startsWith("/hosts"))
10299
+ return /* @__PURE__ */ u3(HostsView, {}, undefined, false, undefined, this);
10123
10300
  if (route.startsWith("/generations"))
10124
10301
  return /* @__PURE__ */ u3(GenerationsView, {}, undefined, false, undefined, this);
10125
10302
  if (route.startsWith("/kv"))
@@ -564,6 +564,10 @@
564
564
  margin-top: calc(var(--spacing) * 4);
565
565
  }
566
566
 
567
+ .mt-6 {
568
+ margin-top: calc(var(--spacing) * 6);
569
+ }
570
+
567
571
  .mt-8 {
568
572
  margin-top: calc(var(--spacing) * 8);
569
573
  }
@@ -684,6 +688,10 @@
684
688
  height: calc(var(--spacing) * 2.5);
685
689
  }
686
690
 
691
+ .h-3\.5 {
692
+ height: calc(var(--spacing) * 3.5);
693
+ }
694
+
687
695
  .h-4 {
688
696
  height: calc(var(--spacing) * 4);
689
697
  }
@@ -760,6 +768,10 @@
760
768
  width: calc(var(--spacing) * 2.5);
761
769
  }
762
770
 
771
+ .w-3\.5 {
772
+ width: calc(var(--spacing) * 3.5);
773
+ }
774
+
763
775
  .w-4 {
764
776
  width: calc(var(--spacing) * 4);
765
777
  }
@@ -969,6 +981,10 @@
969
981
  align-items: flex-start;
970
982
  }
971
983
 
984
+ .items-stretch {
985
+ align-items: stretch;
986
+ }
987
+
972
988
  .justify-between {
973
989
  justify-content: space-between;
974
990
  }
@@ -1607,6 +1623,16 @@
1607
1623
  background-color: var(--color-yellow-400);
1608
1624
  }
1609
1625
 
1626
+ .bg-yellow-500\/5 {
1627
+ background-color: #edb2000d;
1628
+ }
1629
+
1630
+ @supports (color: color-mix(in lab, red, red)) {
1631
+ .bg-yellow-500\/5 {
1632
+ background-color: color-mix(in oklab, var(--color-yellow-500) 5%, transparent);
1633
+ }
1634
+ }
1635
+
1610
1636
  .bg-yellow-500\/15 {
1611
1637
  background-color: #edb20026;
1612
1638
  }
@@ -2149,6 +2175,11 @@
2149
2175
  outline-style: none;
2150
2176
  }
2151
2177
 
2178
+ .select-all {
2179
+ -webkit-user-select: all;
2180
+ user-select: all;
2181
+ }
2182
+
2152
2183
  .select-none {
2153
2184
  -webkit-user-select: none;
2154
2185
  user-select: none;
@@ -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-jzyhpjad.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-jpd3vqkt.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-n1xkdmxt.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-5h0jr7cs.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.10.2",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,7 @@ import { handlers as durableObjects } from './handlers/do'
9
9
  import { handlers as email } from './handlers/email'
10
10
  import { handlers as errors } from './handlers/errors'
11
11
  import { handlers as generations } from './handlers/generations'
12
+ import { handlers as hosts } from './handlers/hosts'
12
13
  import { handlers as kv } from './handlers/kv'
13
14
  import { handlers as overview } from './handlers/overview'
14
15
  import { handlers as queue } from './handlers/queue'
@@ -42,6 +43,7 @@ const allHandlers = {
42
43
  ...analyticsEngine,
43
44
  ...warnings,
44
45
  ...routes,
46
+ ...hosts,
45
47
  }
46
48
 
47
49
  export type Procedures = {
@@ -0,0 +1,15 @@
1
+ import { checkHostPatterns, readSystemHostsFile } from '../../hosts-check'
2
+ import type { HandlerContext, HostsCheckResponse } from '../types'
3
+
4
+ export const handlers = {
5
+ 'hosts.check'(_input: {}, ctx: HandlerContext): HostsCheckResponse {
6
+ const hostsFile = readSystemHostsFile()
7
+
8
+ if ('error' in hostsFile) {
9
+ return { results: [], hostsFilePath: hostsFile.path, error: hostsFile.error }
10
+ }
11
+
12
+ const results = checkHostPatterns(hostsFile.entries, ctx.lopataConfig)
13
+ return { results, hostsFilePath: hostsFile.path }
14
+ },
15
+ }
package/src/api/types.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  export type { GenerationInfo } from '../generation'
4
4
  import type { GenerationInfo } from '../generation'
5
+ import type { HostCheckResult } from '../hosts-check'
5
6
 
6
7
  export interface Paginated<T> {
7
8
  items: T[]
@@ -396,6 +397,12 @@ export interface HandlerContext {
396
397
  hostRoutes: HostRouteInfo[]
397
398
  }
398
399
 
400
+ export interface HostsCheckResponse {
401
+ results: HostCheckResult[]
402
+ hostsFilePath: string
403
+ error?: string
404
+ }
405
+
399
406
  export interface RouteInfo {
400
407
  pattern: string
401
408
  workerName: string
package/src/cli/dev.ts CHANGED
@@ -140,6 +140,12 @@ export async function run(ctx: CliContext) {
140
140
 
141
141
  // Build route dispatcher (aux workers only — main is the fallback)
142
142
  routeDispatcher = new RouteDispatcher(mainManager)
143
+
144
+ // Register main worker host patterns so they take priority over wildcard aux hosts
145
+ if (lopataConfig.hosts?.length) {
146
+ routeDispatcher.addHostWorker(mainManager, mainConfig.name, lopataConfig.hosts)
147
+ }
148
+
143
149
  for (const workerDef of lopataConfig.workers ?? []) {
144
150
  const auxMgr = registry.getManager(workerDef.name)
145
151
  if (!auxMgr) continue
@@ -159,6 +165,11 @@ export async function run(ctx: CliContext) {
159
165
 
160
166
  // Expose host routes to the dashboard API
161
167
  const hostRoutes: Array<{ pattern: string; workerName: string }> = []
168
+ if (lopataConfig.hosts?.length) {
169
+ for (const host of lopataConfig.hosts) {
170
+ hostRoutes.push({ pattern: host, workerName: mainConfig.name })
171
+ }
172
+ }
162
173
  for (const workerDef of lopataConfig.workers ?? []) {
163
174
  if (!workerDef.hosts) continue
164
175
  for (const host of workerDef.hosts) {
@@ -0,0 +1,78 @@
1
+ import { checkHostPatterns, readSystemHostsFile } from '../hosts-check'
2
+ import { loadLopataConfig } from '../lopata-config'
3
+ import type { CliContext } from './context'
4
+
5
+ export async function run(_ctx: CliContext, args: string[]) {
6
+ const action = args[0]
7
+
8
+ if (action !== 'check') {
9
+ console.error('Usage: lopata hosts check')
10
+ process.exit(1)
11
+ }
12
+
13
+ const baseDir = process.cwd()
14
+ const lopataConfig = await loadLopataConfig(baseDir)
15
+
16
+ if (!lopataConfig?.workers?.some(w => w.hosts?.length)) {
17
+ console.log('No host routing configured. Nothing to check.')
18
+ console.log('Host routing is configured via the "hosts" field in lopata.config.ts workers.')
19
+ return
20
+ }
21
+
22
+ const hostsFile = readSystemHostsFile()
23
+ if ('error' in hostsFile) {
24
+ console.error(hostsFile.error)
25
+ console.error('Make sure you have read permissions.')
26
+ process.exit(1)
27
+ }
28
+
29
+ const results = checkHostPatterns(hostsFile.entries, lopataConfig)
30
+ const hostsPath = hostsFile.path
31
+
32
+ console.log(`Hosts file: ${hostsPath}`)
33
+ console.log('')
34
+
35
+ for (const r of results) {
36
+ switch (r.status) {
37
+ case 'ok':
38
+ console.log(` ✓ ${r.hostname} (worker: ${r.workerName}) → ${r.address}`)
39
+ break
40
+ case 'missing':
41
+ console.log(` ✗ ${r.hostname} (worker: ${r.workerName}) — not found in hosts file`)
42
+ break
43
+ case 'wrong_address':
44
+ console.log(` ✗ ${r.hostname} (worker: ${r.workerName}) — points to ${r.address}, expected 127.0.0.1`)
45
+ break
46
+ case 'wildcard':
47
+ console.log(` ⚠ ${r.hostname} (worker: ${r.workerName}) — wildcard pattern, cannot be checked in hosts file`)
48
+ console.log(` You need to add specific subdomains to your hosts file manually.`)
49
+ if (process.platform === 'darwin') {
50
+ console.log(` Alternatively, use dnsmasq or a local DNS resolver to handle wildcard domains.`)
51
+ }
52
+ break
53
+ }
54
+ }
55
+
56
+ console.log('')
57
+
58
+ const failing = results.filter(r => r.status === 'missing' || r.status === 'wrong_address')
59
+ if (failing.length > 0) {
60
+ console.log('Some hostnames are missing or misconfigured.')
61
+ console.log('')
62
+ console.log('Add the missing entries to your hosts file:')
63
+ console.log('')
64
+
65
+ const uniqueHostnames = [...new Set(failing.map(m => m.hostname))]
66
+ console.log(` ${hostsPath}:`)
67
+ console.log(` 127.0.0.1 ${uniqueHostnames.join(' ')}`)
68
+ console.log('')
69
+
70
+ if (process.platform !== 'win32') {
71
+ console.log(`Run: sudo sh -c 'echo "127.0.0.1 ${uniqueHostnames.join(' ')}" >> ${hostsPath}'`)
72
+ } else {
73
+ console.log('Open Notepad as Administrator and add the line above to the hosts file.')
74
+ }
75
+ } else if (!results.some(r => r.status === 'wildcard')) {
76
+ console.log('All host routes are correctly configured.')
77
+ }
78
+ }
package/src/cli.ts CHANGED
@@ -55,6 +55,11 @@ switch (command) {
55
55
  await mod.run(ctx, commandArgs.slice(1))
56
56
  break
57
57
  }
58
+ case 'hosts': {
59
+ const mod = await import('./cli/hosts')
60
+ await mod.run(ctx, commandArgs.slice(1))
61
+ break
62
+ }
58
63
  case 'trace': {
59
64
  const mod = await import('./cli/traces')
60
65
  await mod.run(ctx, commandArgs.slice(1))
@@ -91,6 +96,7 @@ Commands:
91
96
  queues message purge <queue> Purge queue messages
92
97
  cache list List cache names
93
98
  cache purge [--name CACHE] Purge cache entries
99
+ hosts check Check hosts file for configured host routes
94
100
  trace list [options] List traces (--limit, --since, --search, --cursor)
95
101
  trace get <traceId> Get trace detail as JSON
96
102
 
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import type { LopataConfig } from './lopata-config'
3
+
4
+ export interface HostsEntry {
5
+ address: string
6
+ hostnames: string[]
7
+ }
8
+
9
+ export interface HostCheckResult {
10
+ hostname: string
11
+ workerName: string
12
+ status: 'ok' | 'missing' | 'wrong_address' | 'wildcard'
13
+ address?: string
14
+ }
15
+
16
+ const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', 'localhost'])
17
+
18
+ export function getHostsFilePath(): string {
19
+ if (process.platform === 'win32') {
20
+ return 'C:\\Windows\\System32\\drivers\\etc\\hosts'
21
+ }
22
+ return '/etc/hosts'
23
+ }
24
+
25
+ export function parseHostsFile(content: string): HostsEntry[] {
26
+ const entries: HostsEntry[] = []
27
+ for (const line of content.split('\n')) {
28
+ const trimmed = line.trim()
29
+ if (trimmed === '' || trimmed.startsWith('#')) continue
30
+ const parts = trimmed.split(/\s+/)
31
+ if (parts.length < 2) continue
32
+ entries.push({ address: parts[0]!, hostnames: parts.slice(1) })
33
+ }
34
+ return entries
35
+ }
36
+
37
+ export function readSystemHostsFile(): { path: string; entries: HostsEntry[] } | { path: string; error: string } {
38
+ const path = getHostsFilePath()
39
+ try {
40
+ const content = readFileSync(path, 'utf-8')
41
+ return { path, entries: parseHostsFile(content) }
42
+ } catch {
43
+ return { path, error: `Could not read hosts file at ${path}` }
44
+ }
45
+ }
46
+
47
+ export function checkHostPatterns(
48
+ hostsEntries: HostsEntry[],
49
+ lopataConfig: LopataConfig | null,
50
+ ): HostCheckResult[] {
51
+ const results: HostCheckResult[] = []
52
+
53
+ if (!lopataConfig?.workers) return results
54
+
55
+ for (const worker of lopataConfig.workers) {
56
+ if (!worker.hosts) continue
57
+ for (const host of worker.hosts) {
58
+ if (host.startsWith('*.')) {
59
+ results.push({ hostname: host, workerName: worker.name, status: 'wildcard' })
60
+ continue
61
+ }
62
+
63
+ const entry = hostsEntries.find(e => e.hostnames.includes(host))
64
+ if (!entry) {
65
+ results.push({ hostname: host, workerName: worker.name, status: 'missing' })
66
+ } else if (!LOCALHOST_ADDRESSES.has(entry.address)) {
67
+ results.push({ hostname: host, workerName: worker.name, status: 'wrong_address', address: entry.address })
68
+ } else {
69
+ results.push({ hostname: host, workerName: worker.name, status: 'ok', address: entry.address })
70
+ }
71
+ }
72
+ }
73
+
74
+ return results
75
+ }
@@ -4,6 +4,8 @@ import { dirname, join, resolve } from 'node:path'
4
4
  export interface LopataConfig {
5
5
  /** Path to the main worker's wrangler config (HTTP entrypoint) */
6
6
  main: string
7
+ /** Host patterns that route to the main worker (takes priority over wildcard auxiliary hosts). */
8
+ hosts?: string[]
7
9
  /** Auxiliary workers, each with a service name and wrangler config path */
8
10
  workers?: Array<{
9
11
  name: string
@@ -77,6 +77,12 @@ export function matchHost(hostname: string, pattern: string): boolean {
77
77
  return false
78
78
  }
79
79
 
80
+ /** Check if host patterns contain any wildcard entries (e.g. `*.localhost`). */
81
+ function hasWildcardHost(patterns?: string[]): boolean {
82
+ if (!patterns) return false
83
+ return patterns.some(p => p.includes('*'))
84
+ }
85
+
80
86
  /** Count the number of path segments in a pattern (ignoring trailing wildcard). */
81
87
  function segmentCount(pattern: string): number {
82
88
  const clean = pattern.replace(/\/?\*$/, '')
@@ -90,6 +96,8 @@ interface RouteEntry {
90
96
  manager: RoutableManager
91
97
  /** When set, this route only matches requests whose hostname matches one of these patterns. */
92
98
  hostPatterns?: string[]
99
+ /** Pre-computed: true if any hostPattern contains a wildcard. Used for sort ordering. */
100
+ hasWildcardHost: boolean
93
101
  }
94
102
 
95
103
  /**
@@ -143,7 +151,7 @@ export class RouteDispatcher {
143
151
  continue
144
152
  }
145
153
 
146
- this.routes.push({ pattern, workerName, manager, hostPatterns })
154
+ this.routes.push({ pattern, workerName, manager, hostPatterns, hasWildcardHost: hasWildcardHost(hostPatterns) })
147
155
  this.sorted = false
148
156
  }
149
157
  }
@@ -152,7 +160,7 @@ export class RouteDispatcher {
152
160
  addHostWorker(manager: RoutableManager, workerName: string, hostPatterns: string[]): void {
153
161
  // Clear existing routes for this worker to support re-registration
154
162
  this.routes = this.routes.filter(r => r.workerName !== workerName)
155
- this.routes.push({ pattern: '/*', workerName, manager, hostPatterns })
163
+ this.routes.push({ pattern: '/*', workerName, manager, hostPatterns, hasWildcardHost: hasWildcardHost(hostPatterns) })
156
164
  this.sorted = false
157
165
  }
158
166
 
@@ -175,6 +183,8 @@ export class RouteDispatcher {
175
183
  const aSlashStar = a.pattern.endsWith('/*')
176
184
  const bSlashStar = b.pattern.endsWith('/*')
177
185
  if (aSlashStar !== bSlashStar) return aSlashStar ? -1 : 1
186
+ // Exact host patterns beat wildcard host patterns (e.g. admin.localhost before *.localhost)
187
+ if (a.hasWildcardHost !== b.hasWildcardHost) return a.hasWildcardHost ? 1 : -1
178
188
  // Longer pattern string as tiebreaker
179
189
  return b.pattern.length - a.pattern.length
180
190
  })
@@ -8,6 +8,7 @@ import { extractHostname, RouteDispatcher } from '../route-matcher.ts'
8
8
  interface DevServerPluginOptions {
9
9
  configPath?: string
10
10
  envName: string
11
+ hosts?: string[]
11
12
  auxiliaryWorkers?: { configPath: string; name?: string; hosts?: string[] }[]
12
13
  }
13
14
 
@@ -425,6 +426,12 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
425
426
 
426
427
  // Build route dispatcher (aux workers only — main is the fallback)
427
428
  routeDispatcher = new RouteDispatcher(mainAdapter)
429
+
430
+ // Register main worker host patterns so they take priority over wildcard aux hosts
431
+ if (options.hosts?.length) {
432
+ routeDispatcher.addHostWorker(mainAdapter, config.name, options.hosts)
433
+ }
434
+
428
435
  for (const workerDef of options.auxiliaryWorkers) {
429
436
  const cached = auxConfigs.get(workerDef.configPath)
430
437
  if (!cached) continue
@@ -446,6 +453,11 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
446
453
 
447
454
  // Expose host routes to the dashboard API
448
455
  const hostRoutes: Array<{ pattern: string; workerName: string }> = []
456
+ if (options.hosts?.length) {
457
+ for (const host of options.hosts) {
458
+ hostRoutes.push({ pattern: host, workerName: config.name })
459
+ }
460
+ }
449
461
  for (const workerDef of options.auxiliaryWorkers) {
450
462
  if (!workerDef.hosts) continue
451
463
  const cached = auxConfigs.get(workerDef.configPath)
@@ -10,6 +10,8 @@ export interface LopataPluginConfig {
10
10
  configPath?: string
11
11
  /** Vite environment name for SSR. Default: "ssr" */
12
12
  viteEnvironment?: { name?: string }
13
+ /** Host patterns that route to the main worker (takes priority over wildcard auxiliary hosts). */
14
+ hosts?: string[]
13
15
  /** Auxiliary workers loaded via native Bun import (not through Vite). */
14
16
  auxiliaryWorkers?: { configPath: string; name?: string; hosts?: string[] }[]
15
17
  }
@@ -36,6 +38,7 @@ export function lopata(config?: LopataPluginConfig): Plugin[] {
36
38
  devServerPlugin({
37
39
  configPath: config?.configPath,
38
40
  envName,
41
+ hosts: config?.hosts,
39
42
  auxiliaryWorkers: config?.auxiliaryWorkers,
40
43
  }),
41
44
  reactRouterPlugin(),