lopata 0.8.4 → 0.9.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.
@@ -930,6 +930,39 @@ var ICONS = {
930
930
  }, undefined, false, undefined, this)
931
931
  ]
932
932
  }, undefined, true, undefined, this),
933
+ routes: () => /* @__PURE__ */ u3("svg", {
934
+ width: "16",
935
+ height: "16",
936
+ viewBox: "0 0 16 16",
937
+ fill: "none",
938
+ stroke: "currentColor",
939
+ "stroke-width": "1.5",
940
+ "stroke-linecap": "round",
941
+ "stroke-linejoin": "round",
942
+ children: [
943
+ /* @__PURE__ */ u3("circle", {
944
+ cx: "4",
945
+ cy: "4",
946
+ r: "1.5"
947
+ }, undefined, false, undefined, this),
948
+ /* @__PURE__ */ u3("circle", {
949
+ cx: "12",
950
+ cy: "4",
951
+ r: "1.5"
952
+ }, undefined, false, undefined, this),
953
+ /* @__PURE__ */ u3("circle", {
954
+ cx: "12",
955
+ cy: "12",
956
+ r: "1.5"
957
+ }, undefined, false, undefined, this),
958
+ /* @__PURE__ */ u3("path", {
959
+ d: "M4 5.5v2c0 1.5 1 2.5 2.5 2.5h4"
960
+ }, undefined, false, undefined, this),
961
+ /* @__PURE__ */ u3("path", {
962
+ d: "M4 5.5v5c0 1 .5 1.5 1.5 1.5h5"
963
+ }, undefined, false, undefined, this)
964
+ ]
965
+ }, undefined, true, undefined, this),
933
966
  overview: () => /* @__PURE__ */ u3("svg", {
934
967
  width: "16",
935
968
  height: "16",
@@ -7825,6 +7858,43 @@ function R2ObjectList({ bucket }) {
7825
7858
  }, undefined, true, undefined, this);
7826
7859
  }
7827
7860
 
7861
+ // src/dashboard/views/routes.tsx
7862
+ var TYPE_COLORS = {
7863
+ route: "bg-emerald-500/15 text-emerald-500",
7864
+ fallback: "bg-panel-active text-text-data"
7865
+ };
7866
+ function RoutesView() {
7867
+ const { data: routes } = useQuery("routes.list");
7868
+ return /* @__PURE__ */ u3("div", {
7869
+ class: "p-4 sm:p-8",
7870
+ children: [
7871
+ /* @__PURE__ */ u3(PageHeader, {
7872
+ title: "Routes",
7873
+ subtitle: `${routes?.length ?? 0} route(s)`
7874
+ }, undefined, false, undefined, this),
7875
+ !routes?.length ? /* @__PURE__ */ u3(EmptyState, {
7876
+ message: "No routes configured"
7877
+ }, undefined, false, undefined, this) : /* @__PURE__ */ u3(Table, {
7878
+ headers: ["Pattern", "Worker", "Type"],
7879
+ rows: routes.map((r3) => [
7880
+ /* @__PURE__ */ u3("span", {
7881
+ class: "font-mono text-xs font-medium",
7882
+ children: r3.pattern
7883
+ }, undefined, false, undefined, this),
7884
+ /* @__PURE__ */ u3("span", {
7885
+ class: "text-text-secondary",
7886
+ children: r3.workerName
7887
+ }, undefined, false, undefined, this),
7888
+ /* @__PURE__ */ u3(StatusBadge, {
7889
+ status: r3.isFallback ? "fallback" : "route",
7890
+ colorMap: TYPE_COLORS
7891
+ }, undefined, false, undefined, this)
7892
+ ])
7893
+ }, undefined, false, undefined, this)
7894
+ ]
7895
+ }, undefined, true, undefined, this);
7896
+ }
7897
+
7828
7898
  // src/dashboard/views/scheduled.tsx
7829
7899
  function ScheduledView({ route }) {
7830
7900
  return /* @__PURE__ */ u3(ScheduledList, {}, undefined, false, undefined, this);
@@ -8958,7 +9028,7 @@ function DurationBar({ durationMs, maxDuration }) {
8958
9028
  }
8959
9029
 
8960
9030
  // src/dashboard/views/workers.tsx
8961
- var TYPE_COLORS = {
9031
+ var TYPE_COLORS2 = {
8962
9032
  kv: "bg-emerald-500/15 text-emerald-500",
8963
9033
  r2: "bg-blue-500/15 text-blue-400",
8964
9034
  d1: "bg-violet-500/15 text-violet-400",
@@ -9014,7 +9084,7 @@ function WorkersView() {
9014
9084
  rows: w3.bindings.map((b) => [
9015
9085
  /* @__PURE__ */ u3(StatusBadge, {
9016
9086
  status: b.type,
9017
- colorMap: TYPE_COLORS
9087
+ colorMap: TYPE_COLORS2
9018
9088
  }, undefined, false, undefined, this),
9019
9089
  /* @__PURE__ */ u3("span", {
9020
9090
  class: "font-mono text-xs font-medium",
@@ -9744,6 +9814,7 @@ var NAV_GROUPS = [
9744
9814
  label: "Compute",
9745
9815
  items: [
9746
9816
  { path: "/workers", label: "Workers", icon: "workers" },
9817
+ { path: "/routes", label: "Routes", icon: "routes" },
9747
9818
  { path: "/do", label: "Durable Objects", icon: "do" },
9748
9819
  { path: "/containers", label: "Containers", icon: "containers" },
9749
9820
  { path: "/workflows", label: "Workflows", icon: "workflows" },
@@ -9880,6 +9951,8 @@ function App() {
9880
9951
  }, undefined, false, undefined, this);
9881
9952
  if (route.startsWith("/workers"))
9882
9953
  return /* @__PURE__ */ u3(WorkersView, {}, undefined, false, undefined, this);
9954
+ if (route.startsWith("/routes"))
9955
+ return /* @__PURE__ */ u3(RoutesView, {}, undefined, false, undefined, this);
9883
9956
  if (route.startsWith("/generations"))
9884
9957
  return /* @__PURE__ */ u3(GenerationsView, {}, undefined, false, undefined, this);
9885
9958
  if (route.startsWith("/kv"))
@@ -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-yxzrcvyh.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-csyd2tq2.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-yq8n0mcf.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.8.4",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,6 +13,7 @@ import { handlers as kv } from './handlers/kv'
13
13
  import { handlers as overview } from './handlers/overview'
14
14
  import { handlers as queue } from './handlers/queue'
15
15
  import { handlers as r2 } from './handlers/r2'
16
+ import { handlers as routes } from './handlers/routes'
16
17
  import { handlers as scheduled } from './handlers/scheduled'
17
18
  import { handlers as traces } from './handlers/traces'
18
19
  import { handlers as warnings } from './handlers/warnings'
@@ -40,6 +41,7 @@ const allHandlers = {
40
41
  ...ai,
41
42
  ...analyticsEngine,
42
43
  ...warnings,
44
+ ...routes,
43
45
  }
44
46
 
45
47
  export type Procedures = {
@@ -0,0 +1,31 @@
1
+ import { extractPathPattern } from '../../route-matcher'
2
+ import type { HandlerContext, RouteInfo } from '../types'
3
+
4
+ export const handlers = {
5
+ 'routes.list'(_input: {}, ctx: HandlerContext): RouteInfo[] {
6
+ const routes: RouteInfo[] = []
7
+
8
+ if (ctx.routeDispatcher) {
9
+ for (const r of ctx.routeDispatcher.getRegisteredRoutes()) {
10
+ routes.push({ pattern: r.pattern, workerName: r.workerName, isFallback: false })
11
+ }
12
+ }
13
+
14
+ // In single-worker mode, show routes from config
15
+ if (!ctx.routeDispatcher && ctx.config?.routes) {
16
+ const workerName = ctx.config.name || 'main'
17
+ for (const route of ctx.config.routes) {
18
+ if (typeof route === 'object' && route.custom_domain) continue
19
+ routes.push({ pattern: extractPathPattern(route), workerName, isFallback: false })
20
+ }
21
+ }
22
+
23
+ // Add main/fallback worker entry
24
+ const mainName = ctx.registry
25
+ ? Array.from(ctx.registry.listManagers().keys())[0] ?? 'main'
26
+ : ctx.config?.name || 'main'
27
+ routes.push({ pattern: '/*', workerName: mainName, isFallback: true })
28
+
29
+ return routes
30
+ },
31
+ }
package/src/api/index.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import type { WranglerConfig } from '../config'
2
2
  import type { GenerationManager } from '../generation-manager'
3
3
  import type { LopataConfig } from '../lopata-config'
4
+ import type { RouteDispatcher } from '../route-matcher'
4
5
  import type { WorkerRegistry } from '../worker-registry'
5
6
  import { handlePreflight, withCors } from './cors'
6
7
  import { dispatch } from './dispatch'
7
8
  import { handleR2Download, handleR2Upload } from './r2'
8
9
  import type { HandlerContext } from './types'
9
10
 
10
- const ctx: HandlerContext = { config: null, manager: null, registry: null, lopataConfig: null }
11
+ const ctx: HandlerContext = { config: null, manager: null, registry: null, lopataConfig: null, routeDispatcher: null }
11
12
 
12
13
  export function setDashboardConfig(config: WranglerConfig): void {
13
14
  ctx.config = config
@@ -25,6 +26,10 @@ export function setLopataConfig(config: LopataConfig): void {
25
26
  ctx.lopataConfig = config
26
27
  }
27
28
 
29
+ export function setRouteDispatcher(dispatcher: RouteDispatcher): void {
30
+ ctx.routeDispatcher = dispatcher
31
+ }
32
+
28
33
  export function handleApiRequest(request: Request): Response | Promise<Response> {
29
34
  const url = new URL(request.url)
30
35
 
package/src/api/types.ts CHANGED
@@ -379,6 +379,7 @@ export type { SpanData, SpanEventData, TraceDetail, TraceEvent, TraceSummary } f
379
379
  import type { WranglerConfig } from '../config'
380
380
  import type { GenerationManager } from '../generation-manager'
381
381
  import type { LopataConfig } from '../lopata-config'
382
+ import type { RouteDispatcher } from '../route-matcher'
382
383
  import type { WorkerRegistry } from '../worker-registry'
383
384
 
384
385
  export interface HandlerContext {
@@ -386,6 +387,13 @@ export interface HandlerContext {
386
387
  manager: GenerationManager | null
387
388
  registry: WorkerRegistry | null
388
389
  lopataConfig: LopataConfig | null
390
+ routeDispatcher: RouteDispatcher | null
391
+ }
392
+
393
+ export interface RouteInfo {
394
+ pattern: string
395
+ workerName: string
396
+ isFallback: boolean
389
397
  }
390
398
 
391
399
  /** Collect configs from all workers (registry) or fall back to single config. */
package/src/cli/dev.ts CHANGED
@@ -3,7 +3,7 @@ Error.stackTraceLimit = 50
3
3
 
4
4
  import '../plugin'
5
5
  import path from 'node:path'
6
- import { handleApiRequest, setDashboardConfig, setGenerationManager, setLopataConfig, setWorkerRegistry } from '../api'
6
+ import { handleApiRequest, setDashboardConfig, setGenerationManager, setLopataConfig, setRouteDispatcher, setWorkerRegistry } from '../api'
7
7
  import { QueuePullConsumer } from '../bindings/queue'
8
8
  import type { AckRequest, PullRequest } from '../bindings/queue'
9
9
  import { CFWebSocket } from '../bindings/websocket-pair'
@@ -14,6 +14,7 @@ import { FileWatcher } from '../file-watcher'
14
14
  import { GenerationManager } from '../generation-manager'
15
15
  import { loadLopataConfig } from '../lopata-config'
16
16
  import { addCfProperty } from '../request-cf'
17
+ import { RouteDispatcher } from '../route-matcher'
17
18
  import { getTraceStore } from '../tracing/store'
18
19
  import type { TraceEvent } from '../tracing/types'
19
20
  import { WorkerRegistry } from '../worker-registry'
@@ -32,6 +33,8 @@ export async function run(ctx: CliContext) {
32
33
  const lopataConfig = await loadLopataConfig(baseDir)
33
34
 
34
35
  let manager: GenerationManager
36
+ let routeDispatcher: RouteDispatcher | undefined
37
+ let registry: WorkerRegistry | undefined
35
38
 
36
39
  if (lopataConfig) {
37
40
  // ─── Multi-worker mode ─────────────────────────────────────────
@@ -48,7 +51,7 @@ export async function run(ctx: CliContext) {
48
51
  console.warn(`[lopata] Unknown isolation mode "${lopataConfig.isolation}", using "dev"`)
49
52
  }
50
53
 
51
- const registry = new WorkerRegistry()
54
+ registry = new WorkerRegistry()
52
55
 
53
56
  // Load main worker config
54
57
  const mainConfig = await loadConfig(lopataConfig.main, envFlag)
@@ -67,9 +70,11 @@ export async function run(ctx: CliContext) {
67
70
  })
68
71
  registry.register(mainConfig.name, mainManager, true)
69
72
 
70
- // Load auxiliary workers
73
+ // Load auxiliary workers and collect their configs for route setup
74
+ const auxConfigs = new Map<string, import('../config').WranglerConfig>()
71
75
  for (const workerDef of lopataConfig.workers ?? []) {
72
76
  const auxConfig = await loadConfig(workerDef.config, envFlag)
77
+ auxConfigs.set(workerDef.name, auxConfig)
73
78
  const auxBaseDir = path.dirname(workerDef.config)
74
79
  console.log(`[lopata] Auxiliary worker: ${workerDef.name} (${auxConfig.name})`)
75
80
 
@@ -94,8 +99,17 @@ export async function run(ctx: CliContext) {
94
99
  // File watcher for aux worker
95
100
  const auxSrcDir = path.dirname(path.resolve(auxBaseDir, auxConfig.main))
96
101
  const auxWatcher = new FileWatcher(auxSrcDir, () => {
97
- auxManager.reload().then(gen => {
102
+ auxManager.reload().then(async gen => {
98
103
  console.log(`[lopata] Auxiliary worker "${workerDef.name}" reloaded → generation ${gen.id}`)
104
+ // Re-read config and update routes in case routes changed
105
+ if (routeDispatcher) {
106
+ try {
107
+ const freshConfig = await loadConfig(workerDef.config, envFlag)
108
+ routeDispatcher.addRoutes(freshConfig, auxManager, workerDef.name)
109
+ } catch (err) {
110
+ console.warn(`[lopata] Failed to re-read config for "${workerDef.name}" routes:`, err)
111
+ }
112
+ }
99
113
  }).catch(err => {
100
114
  console.error(`[lopata] Reload failed for "${workerDef.name}":`, err)
101
115
  })
@@ -109,9 +123,30 @@ export async function run(ctx: CliContext) {
109
123
  const firstGen = await mainManager.reload()
110
124
  console.log(`[lopata] Main worker → generation ${firstGen.id}`)
111
125
 
126
+ // Warn if main worker has routes — they are ignored because main is the fallback
127
+ if (mainConfig.routes && mainConfig.routes.length > 0) {
128
+ console.warn(
129
+ '[lopata] Warning: main worker has "routes" in config — these are ignored in multi-worker mode (main worker is the fallback for unmatched requests)',
130
+ )
131
+ }
132
+
133
+ // Build route dispatcher for route-based worker selection (aux workers only — main is the fallback)
134
+ routeDispatcher = new RouteDispatcher(mainManager)
135
+ for (const workerDef of lopataConfig.workers ?? []) {
136
+ const auxConfig = auxConfigs.get(workerDef.name)
137
+ const auxMgr = registry.getManager(workerDef.name)
138
+ if (auxConfig && auxMgr) routeDispatcher.addRoutes(auxConfig, auxMgr, workerDef.name)
139
+ }
140
+ if (routeDispatcher.hasRoutes()) {
141
+ for (const r of routeDispatcher.getRegisteredRoutes()) {
142
+ console.log(`[lopata] Route: ${r.pattern} → ${r.workerName}`)
143
+ }
144
+ }
145
+
112
146
  manager = mainManager
113
147
  setGenerationManager(manager)
114
148
  setWorkerRegistry(registry)
149
+ setRouteDispatcher(routeDispatcher)
115
150
 
116
151
  // File watcher for main worker
117
152
  const mainSrcDir = path.dirname(path.resolve(mainBaseDir, mainConfig.main))
@@ -201,9 +236,10 @@ export async function run(ctx: CliContext) {
201
236
  }
202
237
  }
203
238
 
204
- // Email handler: POST /cdn-cgi/handler/email?from=...&to=...
239
+ // Email handler: POST /cdn-cgi/handler/email?from=...&to=...&worker=<name>
205
240
  if (url.pathname === '/cdn-cgi/handler/email' && request.method === 'POST') {
206
- const gen = manager.active
241
+ const targetManager = resolveWorkerParam(url, registry, manager)
242
+ const gen = targetManager.active
207
243
  if (!gen) return new Response('No active generation', { status: 503 })
208
244
  const from = url.searchParams.get('from') ?? ''
209
245
  const to = url.searchParams.get('to') ?? ''
@@ -211,16 +247,18 @@ export async function run(ctx: CliContext) {
211
247
  return gen.callEmail(new Uint8Array(raw), from, to)
212
248
  }
213
249
 
214
- // Manual trigger: GET /cdn-cgi/handler/scheduled?cron=<expression>
250
+ // Manual trigger: GET /cdn-cgi/handler/scheduled?cron=<expression>&worker=<name>
215
251
  if (url.pathname === '/cdn-cgi/handler/scheduled') {
216
- const gen = manager.active
252
+ const targetManager = resolveWorkerParam(url, registry, manager)
253
+ const gen = targetManager.active
217
254
  if (!gen) return new Response('No active generation', { status: 503 })
218
255
  const cronExpr = url.searchParams.get('cron') ?? '* * * * *'
219
256
  return gen.callScheduled(cronExpr)
220
257
  }
221
258
 
222
- // Delegate to active generation
223
- const gen = manager.active
259
+ // Delegate to active generation (route-based dispatch in multi-worker mode)
260
+ const targetManager = routeDispatcher ? routeDispatcher.resolve(url.pathname) : manager
261
+ const gen = targetManager.active
224
262
  if (!gen) {
225
263
  return new Response('No active generation', { status: 503 })
226
264
  }
@@ -402,6 +440,18 @@ export async function run(ctx: CliContext) {
402
440
  await new Promise(() => {})
403
441
  }
404
442
 
443
+ /** Resolve a ?worker= query param to the target GenerationManager, falling back to the main manager. */
444
+ function resolveWorkerParam(url: URL, registry: WorkerRegistry | undefined, fallback: GenerationManager): GenerationManager {
445
+ const workerName = url.searchParams.get('worker')
446
+ if (!workerName || !registry) return fallback
447
+ const target = registry.getManager(workerName)
448
+ if (!target) {
449
+ console.warn(`[lopata] Unknown worker "${workerName}" in ?worker= param, using main worker`)
450
+ return fallback
451
+ }
452
+ return target
453
+ }
454
+
405
455
  function matchGlob(text: string, pattern: string): boolean {
406
456
  // Placeholder approach: protect ** before escaping special chars
407
457
  const regex = pattern
package/src/config.ts CHANGED
@@ -58,6 +58,7 @@ export interface WranglerConfig {
58
58
  instance_type?: string
59
59
  name?: string
60
60
  }[]
61
+ routes?: (string | { pattern: string; zone_name?: string; custom_domain?: boolean })[]
61
62
  analytics_engine_datasets?: { binding: string; dataset?: string }[]
62
63
  browser?: { binding: string }
63
64
  version_metadata?: { binding: string }
@@ -0,0 +1,181 @@
1
+ import type { WranglerConfig } from './config'
2
+
3
+ /** Minimal interface for the manager stored in route entries — allows both GenerationManager and Vite adapters. */
4
+ export interface RoutableManager {
5
+ readonly active: { callFetch(request: Request, server: unknown): Promise<Response | undefined> | Response | undefined } | null
6
+ }
7
+
8
+ /**
9
+ * Extract the path portion from a Cloudflare route pattern.
10
+ * Strips the domain prefix: `example.com/api/*` → `/api/*`
11
+ * Handles patterns that are already path-only: `/api/*` → `/api/*`
12
+ * Strips query strings and hash fragments from the result.
13
+ */
14
+ export function extractPathPattern(route: string | { pattern: string }): string {
15
+ let pattern = typeof route === 'string' ? route : route.pattern
16
+
17
+ // Strip protocol if present (e.g. `https://example.com/api/*`)
18
+ pattern = pattern.replace(/^https?:\/\//, '')
19
+
20
+ let path: string
21
+ if (pattern.startsWith('/')) {
22
+ path = pattern
23
+ } else {
24
+ // Strip domain (and optional port): find the first `/` after the domain
25
+ const slashIndex = pattern.indexOf('/')
26
+ if (slashIndex === -1) path = '/*' // Domain-only pattern like `example.com` matches everything
27
+ else path = pattern.slice(slashIndex)
28
+ }
29
+
30
+ // Strip query string and hash fragment
31
+ const qIndex = path.indexOf('?')
32
+ if (qIndex !== -1) path = path.slice(0, qIndex)
33
+ const hIndex = path.indexOf('#')
34
+ if (hIndex !== -1) path = path.slice(0, hIndex)
35
+
36
+ return path
37
+ }
38
+
39
+ /**
40
+ * Match a request pathname against a Cloudflare-style route pattern (path portion only).
41
+ * Supports trailing `*` as a wildcard that matches any suffix.
42
+ *
43
+ * Examples:
44
+ * - `/api/*` matches `/api/foo`, `/api/foo/bar`
45
+ * - `/api/users` matches only `/api/users`
46
+ * - `/*` matches everything
47
+ */
48
+ export function matchRoute(pathname: string, pattern: string): boolean {
49
+ if (pattern === '/*' || pattern === '*') return true
50
+
51
+ if (pattern.endsWith('/*')) {
52
+ const prefix = pattern.slice(0, -2)
53
+ return pathname.startsWith(prefix + '/')
54
+ }
55
+
56
+ if (pattern.endsWith('*')) {
57
+ const prefix = pattern.slice(0, -1)
58
+ return pathname.startsWith(prefix)
59
+ }
60
+
61
+ return pathname === pattern
62
+ }
63
+
64
+ /** Count the number of path segments in a pattern (ignoring trailing wildcard). */
65
+ function segmentCount(pattern: string): number {
66
+ const clean = pattern.replace(/\/?\*$/, '')
67
+ if (clean === '' || clean === '/') return 0
68
+ return clean.split('/').filter(Boolean).length
69
+ }
70
+
71
+ interface RouteEntry {
72
+ pattern: string
73
+ workerName: string
74
+ manager: RoutableManager
75
+ }
76
+
77
+ /**
78
+ * Dispatches requests to workers based on route patterns.
79
+ * Routes are sorted by specificity (most specific first).
80
+ *
81
+ * Only auxiliary workers should be added here — the main worker
82
+ * is the fallback and handles all unmatched requests.
83
+ */
84
+ export class RouteDispatcher {
85
+ private routes: RouteEntry[] = []
86
+ private sorted = true
87
+ private fallback: RoutableManager
88
+
89
+ constructor(fallback: RoutableManager) {
90
+ this.fallback = fallback
91
+ }
92
+
93
+ addRoutes(config: WranglerConfig, manager: RoutableManager, workerName: string): void {
94
+ if (!config.routes) return
95
+
96
+ // Clear existing routes for this worker to support re-registration (e.g. config reload)
97
+ const hadRoutes = this.routes.length > 0
98
+ this.routes = this.routes.filter(r => r.workerName !== workerName)
99
+ if (hadRoutes && this.routes.length === 0) this.sorted = true
100
+
101
+ for (const route of config.routes) {
102
+ // Skip custom_domain entries — they are domain ownership claims, not request routing patterns
103
+ if (typeof route === 'object' && route.custom_domain) continue
104
+
105
+ const rawPattern = typeof route === 'string' ? route : route.pattern
106
+ if (!rawPattern || rawPattern.trim() === '') {
107
+ console.warn(`[lopata] Warning: empty route pattern in worker "${workerName}" — skipping`)
108
+ continue
109
+ }
110
+
111
+ const pattern = extractPathPattern(route)
112
+
113
+ // Warn about mid-pattern wildcards (CF only supports trailing wildcards)
114
+ const starIndex = pattern.indexOf('*')
115
+ if (starIndex !== -1 && starIndex < pattern.length - 1) {
116
+ console.warn(`[lopata] Warning: route pattern "${pattern}" has a wildcard not at the end — Cloudflare only supports trailing wildcards`)
117
+ }
118
+
119
+ // Skip duplicate patterns from different workers (first registered wins)
120
+ const existing = this.routes.find(r => r.pattern === pattern)
121
+ if (existing) {
122
+ console.warn(
123
+ `[lopata] Warning: route pattern "${pattern}" is already registered by "${existing.workerName}" — skipping duplicate from "${workerName}"`,
124
+ )
125
+ continue
126
+ }
127
+
128
+ this.routes.push({ pattern, workerName, manager })
129
+ this.sorted = false
130
+ }
131
+ }
132
+
133
+ removeWorkerRoutes(workerName: string): void {
134
+ this.routes = this.routes.filter(r => r.workerName !== workerName)
135
+ }
136
+
137
+ private ensureSorted(): void {
138
+ if (this.sorted) return
139
+ this.routes.sort((a, b) => {
140
+ const aHasWild = a.pattern.includes('*')
141
+ const bHasWild = b.pattern.includes('*')
142
+ // Non-wildcard patterns are more specific
143
+ if (aHasWild !== bHasWild) return aHasWild ? 1 : -1
144
+ // More segments = more specific
145
+ const segDiff = segmentCount(b.pattern) - segmentCount(a.pattern)
146
+ if (segDiff !== 0) return segDiff
147
+ // Slash-star (`/api/*`) is more specific than bare-star (`/api*`)
148
+ // because it only matches path-separated suffixes
149
+ const aSlashStar = a.pattern.endsWith('/*')
150
+ const bSlashStar = b.pattern.endsWith('/*')
151
+ if (aSlashStar !== bSlashStar) return aSlashStar ? -1 : 1
152
+ // Longer pattern string as tiebreaker
153
+ return b.pattern.length - a.pattern.length
154
+ })
155
+ this.sorted = true
156
+ }
157
+
158
+ resolve(pathname: string): RoutableManager {
159
+ this.ensureSorted()
160
+ for (const entry of this.routes) {
161
+ if (matchRoute(pathname, entry.pattern)) {
162
+ return entry.manager
163
+ }
164
+ }
165
+ return this.fallback
166
+ }
167
+
168
+ /** Check whether the given manager is the fallback (main worker). */
169
+ isFallback(manager: RoutableManager): boolean {
170
+ return manager === this.fallback
171
+ }
172
+
173
+ hasRoutes(): boolean {
174
+ return this.routes.length > 0
175
+ }
176
+
177
+ getRegisteredRoutes(): Array<{ pattern: string; workerName: string }> {
178
+ this.ensureSorted()
179
+ return this.routes.map(r => ({ pattern: r.pattern, workerName: r.workerName }))
180
+ }
181
+ }
@@ -2,11 +2,12 @@ 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
6
 
6
7
  interface DevServerPluginOptions {
7
8
  configPath?: string
8
9
  envName: string
9
- auxiliaryWorkers?: { configPath: string }[]
10
+ auxiliaryWorkers?: { configPath: string; name?: string }[]
10
11
  }
11
12
 
12
13
  /**
@@ -49,6 +50,9 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
49
50
  let handleApiRequest: Function
50
51
  let getTraceStore: Function
51
52
 
53
+ // Route dispatcher for multi-worker route-based dispatching
54
+ let routeDispatcher: RouteDispatcher | undefined
55
+
52
56
  // Track current module to detect when Vite HMR invalidates it
53
57
  let currentModule: Record<string, unknown> | null = null
54
58
  // Serializes module reload — prevents concurrent wireClassRefs calls
@@ -275,11 +279,20 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
275
279
  apiMod.setDashboardConfig(config)
276
280
 
277
281
  // 3b. Create generation tracking adapter for dashboard
278
- const mainAdapter = {
282
+ const mainAdapter: import('../route-matcher.ts').RoutableManager & Record<string, unknown> = {
279
283
  config,
280
284
  gracePeriodMs: 0,
281
285
  get active() {
282
- return currentModule ? { workerModule: currentModule, env, registry } : null
286
+ return currentModule
287
+ ? {
288
+ workerModule: currentModule,
289
+ env,
290
+ registry,
291
+ callFetch(_request: Request, _server: unknown) {
292
+ throw new Error('Main worker in Vite mode should be dispatched via handleWorkerFetch, not callFetch')
293
+ },
294
+ }
295
+ : null
283
296
  },
284
297
  list() {
285
298
  return Array.from(viteGenerations.values()).map(g => ({
@@ -328,7 +341,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
328
341
  },
329
342
  setGracePeriod() {},
330
343
  }
331
- apiMod.setGenerationManager(mainAdapter as any)
344
+ apiMod.setGenerationManager(mainAdapter as any) // Dashboard adapter, not RoutableManager
332
345
 
333
346
  // 4. Set up auxiliary workers (if configured)
334
347
  if (options.auxiliaryWorkers && options.auxiliaryWorkers.length > 0) {
@@ -338,42 +351,76 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
338
351
  const { GenerationManager } = await import('../generation-manager.ts')
339
352
 
340
353
  workerRegistry = new WorkerRegistry()
341
- workerRegistry.register(config.name, mainAdapter as any, true)
354
+ workerRegistry.register(config.name, mainAdapter as any, true) // Dashboard adapter
342
355
 
356
+ const auxConfigs = new Map<string, { config: any; name: string }>()
343
357
  for (const workerDef of options.auxiliaryWorkers) {
344
358
  const auxConfigPath = resolve(projectRoot, workerDef.configPath)
345
359
  const auxBaseDir = dirname(auxConfigPath)
346
360
  const auxConfig = await configMod.loadConfig(auxConfigPath)
347
- console.log(`[lopata:vite] Auxiliary worker: ${auxConfig.name}`)
361
+ const workerName = workerDef.name ?? auxConfig.name
362
+ auxConfigs.set(workerDef.configPath, { config: auxConfig, name: workerName })
363
+ console.log(`[lopata:vite] Auxiliary worker: ${workerName}`)
348
364
 
349
365
  const auxManager = new GenerationManager(auxConfig, auxBaseDir, {
350
- workerName: auxConfig.name,
366
+ workerName,
351
367
  workerRegistry,
352
368
  isMain: false,
353
369
  })
354
- workerRegistry.register(auxConfig.name, auxManager)
370
+ workerRegistry.register(workerName, auxManager)
355
371
 
356
372
  try {
357
373
  const gen = await auxManager.reload()
358
- console.log(`[lopata:vite] Auxiliary worker "${auxConfig.name}" loaded (gen ${gen.id})`)
374
+ console.log(`[lopata:vite] Auxiliary worker "${workerName}" loaded (gen ${gen.id})`)
359
375
  } catch (err) {
360
- console.error(`[lopata:vite] Failed to load auxiliary worker "${auxConfig.name}":`, err)
376
+ console.error(`[lopata:vite] Failed to load auxiliary worker "${workerName}":`, err)
361
377
  }
362
378
 
363
379
  // File watcher for aux worker reload
364
380
  const auxSrcDir = dirname(resolve(auxBaseDir, auxConfig.main))
365
381
  const auxWatcher = new FileWatcher(auxSrcDir, () => {
366
- auxManager.reload().then(gen => {
367
- console.log(`[lopata:vite] Auxiliary worker "${auxConfig.name}" reloaded → generation ${gen.id}`)
382
+ auxManager.reload().then(async gen => {
383
+ console.log(`[lopata:vite] Auxiliary worker "${workerName}" reloaded → generation ${gen.id}`)
384
+ // Re-read config and update routes in case routes changed
385
+ if (routeDispatcher) {
386
+ try {
387
+ const freshConfig = await configMod.loadConfig(auxConfigPath)
388
+ routeDispatcher.addRoutes(freshConfig, auxManager, workerName)
389
+ } catch (err) {
390
+ console.warn(`[lopata:vite] Failed to re-read config for "${workerName}" routes:`, err)
391
+ }
392
+ }
368
393
  }).catch(err => {
369
- console.error(`[lopata:vite] Reload failed for "${auxConfig.name}":`, err)
394
+ console.error(`[lopata:vite] Reload failed for "${workerName}":`, err)
370
395
  })
371
396
  })
372
397
  auxWatcher.start()
373
- console.log(`[lopata:vite] Watching ${auxSrcDir} for changes (${auxConfig.name})`)
398
+ console.log(`[lopata:vite] Watching ${auxSrcDir} for changes (${workerName})`)
374
399
  }
375
400
 
376
401
  apiMod.setWorkerRegistry(workerRegistry)
402
+
403
+ // Warn if main worker has routes — they are ignored because main is the fallback
404
+ if (config.routes && config.routes.length > 0) {
405
+ console.warn(
406
+ '[lopata:vite] Warning: main worker has "routes" in config — these are ignored (main worker is the fallback for unmatched requests)',
407
+ )
408
+ }
409
+
410
+ // Build route dispatcher for aux workers with routes (main worker is the fallback)
411
+ routeDispatcher = new RouteDispatcher(mainAdapter)
412
+ for (const workerDef of options.auxiliaryWorkers) {
413
+ const cached = auxConfigs.get(workerDef.configPath)
414
+ if (!cached) continue
415
+ const auxMgr = workerRegistry.getManager(cached.name)
416
+ if (auxMgr) routeDispatcher.addRoutes(cached.config, auxMgr, cached.name)
417
+ }
418
+ if (routeDispatcher.hasRoutes()) {
419
+ for (const r of routeDispatcher.getRegisteredRoutes()) {
420
+ console.log(`[lopata:vite] Route: ${r.pattern} → ${r.workerName}`)
421
+ }
422
+ }
423
+ apiMod.setRouteDispatcher(routeDispatcher)
377
424
  }
378
425
 
379
426
  // 5. Set up WebSocket trace streaming on httpServer
@@ -424,6 +471,39 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
424
471
  return
425
472
  }
426
473
 
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
481
+ if (!gen) {
482
+ if (!res.headersSent) {
483
+ res.writeHead(503, { 'content-type': 'text/plain' })
484
+ res.end('No active generation for matched route')
485
+ }
486
+ return
487
+ }
488
+ try {
489
+ const request = nodeReqToRequest(req)
490
+ const response = await (startSpan as Function)({
491
+ name: `${request.method} ${parsedUrl.pathname}`,
492
+ kind: 'server',
493
+ attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.worker': (targetManager as any).config?.name ?? 'aux' },
494
+ }, async () => {
495
+ const resp = await gen.callFetch(request, null) as Response
496
+ ;(setSpanAttribute as Function)('http.status_code', resp.status)
497
+ return resp
498
+ }) as Response
499
+ writeResponse(response, res)
500
+ } catch (err) {
501
+ writeRequestError(res, err)
502
+ }
503
+ return
504
+ }
505
+ }
506
+
427
507
  try {
428
508
  await handleWorkerFetch(req, res, next)
429
509
  } catch (err) {
@@ -572,6 +652,42 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
572
652
  try {
573
653
  const { CFWebSocket } = await import('../bindings/websocket-pair.ts')
574
654
 
655
+ const request = nodeReqToRequest(req)
656
+ const parsedUrl = new URL(request.url)
657
+
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
+ })
687
+ return
688
+ }
689
+ }
690
+
575
691
  const activeModule = await ensureWorkerModule()
576
692
  const handler = activeModule.default as Record<string, unknown>
577
693
  if (!handler || typeof handler.fetch !== 'function') {
@@ -579,7 +695,6 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
579
695
  return
580
696
  }
581
697
 
582
- const request = nodeReqToRequest(req)
583
698
  const ctx = new ExecutionContext()
584
699
  const response = await runWithExecutionContext(ctx, async () => {
585
700
  return (handler.fetch as Function).call(handler, request, env, ctx) as Response
@@ -593,47 +708,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
593
708
 
594
709
  // Complete the upgrade and bridge
595
710
  wss.handleUpgrade(req, socket, head, (ws: any) => {
596
- // CF → real WS
597
- cfSocket.addEventListener('message', (ev: Event) => {
598
- const msgData = (ev as MessageEvent).data
599
- try {
600
- ws.send(msgData)
601
- } catch {}
602
- })
603
- cfSocket.addEventListener('close', (ev: Event) => {
604
- const ce = ev as CloseEvent
605
- try {
606
- ws.close(ce.code, ce.reason)
607
- } catch {}
608
- })
609
- // Accept the client side so events from server.send() are dispatched
610
- cfSocket.accept()
611
-
612
- // Real WS → CF
613
- ws.on('message', (data: Buffer, isBinary: boolean) => {
614
- const msgData = isBinary
615
- ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
616
- : data.toString('utf-8')
617
- const evt = { type: 'message' as const, data: msgData }
618
- if (cfSocket._peer?._accepted) {
619
- cfSocket._peer._dispatchWSEvent(evt)
620
- } else if (cfSocket._peer) {
621
- cfSocket._peer._eventQueue.push(evt)
622
- }
623
- })
624
-
625
- ws.on('close', (code: number, reason: Buffer) => {
626
- if (cfSocket._peer && cfSocket._peer.readyState !== 3) {
627
- const evt = { type: 'close' as const, code: code ?? 1000, reason: reason?.toString('utf-8') ?? '', wasClean: true }
628
- if (cfSocket._peer._accepted) {
629
- cfSocket._peer._dispatchWSEvent(evt)
630
- } else {
631
- cfSocket._peer._eventQueue.push(evt)
632
- }
633
- cfSocket._peer.readyState = 3
634
- }
635
- cfSocket.readyState = 3
636
- })
711
+ bridgeCfWebSocket(cfSocket, ws)
637
712
  })
638
713
  } catch (err) {
639
714
  console.error('[lopata:vite] Worker WebSocket upgrade failed:', err)
@@ -671,6 +746,51 @@ function stitchAsyncStack(err: Error, callerError: Error | null): void {
671
746
  err.stack += '\n --- async ---\n' + filtered.join('\n')
672
747
  }
673
748
 
749
+ /** Bridge a CFWebSocket (from worker response) to a real ws WebSocket. */
750
+ function bridgeCfWebSocket(cfSocket: any, ws: any): void {
751
+ // CF → real WS
752
+ cfSocket.addEventListener('message', (ev: Event) => {
753
+ const msgData = (ev as MessageEvent).data
754
+ try {
755
+ ws.send(msgData)
756
+ } catch {}
757
+ })
758
+ cfSocket.addEventListener('close', (ev: Event) => {
759
+ const ce = ev as CloseEvent
760
+ try {
761
+ ws.close(ce.code, ce.reason)
762
+ } catch {}
763
+ })
764
+ // Accept the client side so events from server.send() are dispatched
765
+ cfSocket.accept()
766
+
767
+ // Real WS → CF
768
+ ws.on('message', (data: Buffer, isBinary: boolean) => {
769
+ const msgData = isBinary
770
+ ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
771
+ : data.toString('utf-8')
772
+ const evt = { type: 'message' as const, data: msgData }
773
+ if (cfSocket._peer?._accepted) {
774
+ cfSocket._peer._dispatchWSEvent(evt)
775
+ } else if (cfSocket._peer) {
776
+ cfSocket._peer._eventQueue.push(evt)
777
+ }
778
+ })
779
+
780
+ ws.on('close', (code: number, reason: Buffer) => {
781
+ if (cfSocket._peer && cfSocket._peer.readyState !== 3) {
782
+ const evt = { type: 'close' as const, code: code ?? 1000, reason: reason?.toString('utf-8') ?? '', wasClean: true }
783
+ if (cfSocket._peer._accepted) {
784
+ cfSocket._peer._dispatchWSEvent(evt)
785
+ } else {
786
+ cfSocket._peer._eventQueue.push(evt)
787
+ }
788
+ cfSocket._peer.readyState = 3
789
+ }
790
+ cfSocket.readyState = 3
791
+ })
792
+ }
793
+
674
794
  function matchGlob(text: string, pattern: string): boolean {
675
795
  const regex = pattern
676
796
  .replace(/\*\*/g, '\0')
@@ -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 }[]
14
+ auxiliaryWorkers?: { configPath: string; name?: string }[]
15
15
  }
16
16
 
17
17
  /**