lopata 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -198,6 +198,43 @@ export default {
198
198
 
199
199
  Workers can call each other via service bindings configured in their respective `wrangler.jsonc`. Both HTTP (`binding.fetch()`) and RPC (`binding.myMethod()`) modes are supported, including promise pipelining.
200
200
 
201
+ ### Route patterns
202
+
203
+ In multi-worker setups, auxiliary workers can use Cloudflare-style `routes` in their `wrangler.jsonc` to handle specific URL patterns. The main worker acts as a fallback for unmatched requests.
204
+
205
+ ```jsonc
206
+ // workers/api/wrangler.jsonc
207
+ {
208
+ "name": "api-worker",
209
+ "main": "src/index.ts",
210
+ "routes": [
211
+ "example.com/api/*",
212
+ { "pattern": "example.com/webhooks/*" }
213
+ ]
214
+ }
215
+ ```
216
+
217
+ Routes support trailing `*` wildcards (`/api/*` matches `/api/foo` and `/api/foo/bar`). When multiple routes match, the most specific one wins (more path segments > fewer, exact match > wildcard). The domain portion is stripped — only the path is matched locally.
218
+
219
+ ### Host-based routing
220
+
221
+ For routing by hostname (e.g. subdomains), use `hosts` in `lopata.config.ts`:
222
+
223
+ ```ts
224
+ export default {
225
+ main: './wrangler.jsonc',
226
+ workers: [
227
+ {
228
+ name: 'api-worker',
229
+ config: './workers/api/wrangler.jsonc',
230
+ hosts: ['api.localhost', '*.api.localhost'],
231
+ },
232
+ ],
233
+ }
234
+ ```
235
+
236
+ Wildcard patterns like `*.localhost` match any subdomain. Requests matching a host pattern are routed to that worker regardless of path-based routes.
237
+
201
238
  ## Vite plugin
202
239
 
203
240
  The Vite plugin is a drop-in replacement for `@cloudflare/vite-plugin`. It provides:
@@ -1168,8 +1168,24 @@ function CopyMarkdownButton({ getMarkdown, title }) {
1168
1168
  children: copied ? "Copied!" : "Copy MD"
1169
1169
  }, undefined, false, undefined, this);
1170
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 vnode = v3;
1180
+ if (typeof vnode.type === "function") {
1181
+ return extractText(vnode.type(vnode.props));
1182
+ }
1183
+ return extractText(vnode.props.children);
1184
+ }
1185
+ return String(v3);
1186
+ }
1171
1187
  function tableToMarkdown(headers, rows) {
1172
- const escape = (v3) => String(v3 ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
1188
+ const escape = (v3) => extractText(v3).replace(/\|/g, "\\|").replace(/\n/g, " ");
1173
1189
  const headerRow = `| ${headers.map(escape).join(" | ")} |`;
1174
1190
  const separator = `| ${headers.map(() => "---").join(" | ")} |`;
1175
1191
  const dataRows = rows.map((row) => `| ${row.map(escape).join(" | ")} |`);
@@ -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-hnsny9g7.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="/__dashboard/assets/chunk-jzyhpjad.css"><script type="module" crossorigin src="/__dashboard/assets/chunk-jpd3vqkt.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.0",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,6 +12,7 @@ export const handlers = {
12
12
 
13
13
  if (ctx.routeDispatcher) {
14
14
  for (const r of ctx.routeDispatcher.getRegisteredRoutes()) {
15
+ if (r.hostPatterns) continue // already shown as host routes
15
16
  routes.push({ pattern: r.pattern, workerName: r.workerName, isFallback: false })
16
17
  }
17
18
  }
package/src/cli/dev.ts CHANGED
@@ -22,7 +22,7 @@ import { FileWatcher } from '../file-watcher'
22
22
  import { GenerationManager } from '../generation-manager'
23
23
  import { loadLopataConfig } from '../lopata-config'
24
24
  import { addCfProperty } from '../request-cf'
25
- import { matchHost, RouteDispatcher } from '../route-matcher'
25
+ import { extractHostname, RouteDispatcher } from '../route-matcher'
26
26
  import { getTraceStore } from '../tracing/store'
27
27
  import type { TraceEvent } from '../tracing/types'
28
28
  import { WorkerRegistry } from '../worker-registry'
@@ -43,7 +43,6 @@ export async function run(ctx: CliContext) {
43
43
  let manager: GenerationManager
44
44
  let routeDispatcher: RouteDispatcher | undefined
45
45
  let registry: WorkerRegistry | undefined
46
- const hostRoutes: Array<{ pattern: string; manager: GenerationManager; workerName: string }> = []
47
46
 
48
47
  if (lopataConfig) {
49
48
  // ─── Multi-worker mode ─────────────────────────────────────────
@@ -114,7 +113,7 @@ export async function run(ctx: CliContext) {
114
113
  if (routeDispatcher) {
115
114
  try {
116
115
  const freshConfig = await loadConfig(workerDef.config, envFlag)
117
- routeDispatcher.addRoutes(freshConfig, auxManager, workerDef.name)
116
+ routeDispatcher.addRoutes(freshConfig, auxManager, workerDef.name, workerDef.hosts)
118
117
  } catch (err) {
119
118
  console.warn(`[lopata] Failed to re-read config for "${workerDef.name}" routes:`, err)
120
119
  }
@@ -139,32 +138,35 @@ export async function run(ctx: CliContext) {
139
138
  )
140
139
  }
141
140
 
142
- // Build route dispatcher for route-based worker selection (aux workers only — main is the fallback)
141
+ // Build route dispatcher (aux workers only — main is the fallback)
143
142
  routeDispatcher = new RouteDispatcher(mainManager)
144
143
  for (const workerDef of lopataConfig.workers ?? []) {
145
- const auxConfig = auxConfigs.get(workerDef.name)
146
144
  const auxMgr = registry.getManager(workerDef.name)
147
- if (auxConfig && auxMgr) routeDispatcher.addRoutes(auxConfig, auxMgr, workerDef.name)
145
+ if (!auxMgr) continue
146
+ const auxConfig = auxConfigs.get(workerDef.name)
147
+ if (auxConfig) routeDispatcher.addRoutes(auxConfig, auxMgr, workerDef.name, workerDef.hosts)
148
+ // Workers with hosts but no wrangler routes still need a catch-all entry
149
+ if (workerDef.hosts && (!auxConfig?.routes || auxConfig.routes.length === 0)) {
150
+ routeDispatcher.addHostWorker(auxMgr, workerDef.name, workerDef.hosts)
151
+ }
148
152
  }
149
153
  if (routeDispatcher.hasRoutes()) {
150
154
  for (const r of routeDispatcher.getRegisteredRoutes()) {
151
- console.log(`[lopata] Route: ${r.pattern} ${r.workerName}`)
155
+ const hostInfo = r.hostPatterns ? ` (hosts: ${r.hostPatterns.join(', ')})` : ''
156
+ console.log(`[lopata] Route: ${r.pattern} → ${r.workerName}${hostInfo}`)
152
157
  }
153
158
  }
154
159
 
155
- // Build host-based routing map
160
+ // Expose host routes to the dashboard API
161
+ const hostRoutes: Array<{ pattern: string; workerName: string }> = []
156
162
  for (const workerDef of lopataConfig.workers ?? []) {
157
163
  if (!workerDef.hosts) continue
158
- const auxMgr = registry.getManager(workerDef.name)
159
- if (!auxMgr) continue
160
164
  for (const host of workerDef.hosts) {
161
- hostRoutes.push({ pattern: host, manager: auxMgr, workerName: workerDef.name })
162
- console.log(`[lopata] Host route: ${host} → ${workerDef.name}`)
165
+ hostRoutes.push({ pattern: host, workerName: workerDef.name })
163
166
  }
164
167
  }
165
-
166
168
  if (hostRoutes.length > 0) {
167
- setHostRoutes(hostRoutes.map(hr => ({ pattern: hr.pattern, workerName: hr.workerName })))
169
+ setHostRoutes(hostRoutes)
168
170
  }
169
171
 
170
172
  manager = mainManager
@@ -280,21 +282,9 @@ export async function run(ctx: CliContext) {
280
282
  return gen.callScheduled(cronExpr)
281
283
  }
282
284
 
283
- // Host-based dispatch: match Host header against configured patterns
284
- if (hostRoutes.length > 0) {
285
- const hostHeader = request.headers.get('host') ?? ''
286
- const hostname = hostHeader.split(':')[0] ?? ''
287
- for (const hr of hostRoutes) {
288
- if (matchHost(hostname, hr.pattern)) {
289
- const gen = hr.manager.active
290
- if (!gen) return new Response('No active generation', { status: 503 })
291
- return (await gen.callFetch(request, server)) as Response
292
- }
293
- }
294
- }
295
-
296
- // Delegate to active generation (route-based dispatch in multi-worker mode)
297
- const targetManager = routeDispatcher ? routeDispatcher.resolve(url.pathname) : manager
285
+ // Delegate to active generation (host + route dispatch in multi-worker mode)
286
+ const reqHostname = extractHostname(request.headers.get('host') ?? '')
287
+ const targetManager = routeDispatcher ? routeDispatcher.resolve(url.pathname, reqHostname) : manager
298
288
  const gen = targetManager.active
299
289
  if (!gen) {
300
290
  return new Response('No active generation', { status: 503 })
@@ -61,6 +61,11 @@ 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
+
64
69
  /** Match a hostname against a host pattern. Supports exact match and `*.domain` wildcards. */
65
70
  export function matchHost(hostname: string, pattern: string): boolean {
66
71
  if (pattern === hostname) return true
@@ -83,6 +88,8 @@ interface RouteEntry {
83
88
  pattern: string
84
89
  workerName: string
85
90
  manager: RoutableManager
91
+ /** When set, this route only matches requests whose hostname matches one of these patterns. */
92
+ hostPatterns?: string[]
86
93
  }
87
94
 
88
95
  /**
@@ -101,7 +108,7 @@ export class RouteDispatcher {
101
108
  this.fallback = fallback
102
109
  }
103
110
 
104
- addRoutes(config: WranglerConfig, manager: RoutableManager, workerName: string): void {
111
+ addRoutes(config: WranglerConfig, manager: RoutableManager, workerName: string, hostPatterns?: string[]): void {
105
112
  if (!config.routes) return
106
113
 
107
114
  // Clear existing routes for this worker to support re-registration (e.g. config reload)
@@ -127,8 +134,8 @@ export class RouteDispatcher {
127
134
  console.warn(`[lopata] Warning: route pattern "${pattern}" has a wildcard not at the end — Cloudflare only supports trailing wildcards`)
128
135
  }
129
136
 
130
- // Skip duplicate patterns from different workers (first registered wins)
131
- 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)
132
139
  if (existing) {
133
140
  console.warn(
134
141
  `[lopata] Warning: route pattern "${pattern}" is already registered by "${existing.workerName}" — skipping duplicate from "${workerName}"`,
@@ -136,11 +143,19 @@ export class RouteDispatcher {
136
143
  continue
137
144
  }
138
145
 
139
- this.routes.push({ pattern, workerName, manager })
146
+ this.routes.push({ pattern, workerName, manager, hostPatterns })
140
147
  this.sorted = false
141
148
  }
142
149
  }
143
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
+
144
159
  removeWorkerRoutes(workerName: string): void {
145
160
  this.routes = this.routes.filter(r => r.workerName !== workerName)
146
161
  }
@@ -166,9 +181,13 @@ export class RouteDispatcher {
166
181
  this.sorted = true
167
182
  }
168
183
 
169
- resolve(pathname: string): RoutableManager {
184
+ resolve(pathname: string, hostname?: string): RoutableManager {
170
185
  this.ensureSorted()
171
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
+ }
172
191
  if (matchRoute(pathname, entry.pattern)) {
173
192
  return entry.manager
174
193
  }
@@ -185,8 +204,8 @@ export class RouteDispatcher {
185
204
  return this.routes.length > 0
186
205
  }
187
206
 
188
- getRegisteredRoutes(): Array<{ pattern: string; workerName: string }> {
207
+ getRegisteredRoutes(): Array<{ pattern: string; workerName: string; hostPatterns?: string[] }> {
189
208
  this.ensureSorted()
190
- 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 }))
191
210
  }
192
211
  }
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
3
3
  import type { Plugin, ViteDevServer } from 'vite'
4
4
  import { FileWatcher } from '../file-watcher.ts'
5
5
  import type { RoutableManager } from '../route-matcher.ts'
6
- import { matchHost, RouteDispatcher } from '../route-matcher.ts'
6
+ import { extractHostname, RouteDispatcher } from '../route-matcher.ts'
7
7
 
8
8
  interface DevServerPluginOptions {
9
9
  configPath?: string
@@ -53,8 +53,6 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
53
53
 
54
54
  // Route dispatcher for multi-worker route-based dispatching
55
55
  let routeDispatcher: RouteDispatcher | undefined
56
- // Host-based routing: map host patterns to managers
57
- const hostRoutes: Array<{ pattern: string; manager: RoutableManager; workerName: string }> = []
58
56
 
59
57
  // Track current module to detect when Vite HMR invalidates it
60
58
  let currentModule: Record<string, unknown> | null = null
@@ -208,27 +206,15 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
208
206
 
209
207
  /**
210
208
  * Resolve an auxiliary worker for the given request.
211
- * Checks host-based routes first, then path-based route dispatcher.
212
209
  * Returns null if the request should be handled by the main worker.
213
210
  */
214
211
  function resolveAuxWorker(req: IncomingMessage, url: string): { manager: RoutableManager; workerName: string } | null {
215
- // Host-based dispatch
216
- if (hostRoutes.length > 0) {
217
- const hostHeader = req.headers.host ?? ''
218
- const hostname = hostHeader.split(':')[0] ?? ''
219
- for (const hr of hostRoutes) {
220
- if (matchHost(hostname, hr.pattern)) {
221
- return hr
222
- }
223
- }
224
- }
225
- // Path-based dispatch
226
- if (routeDispatcher) {
227
- const parsedUrl = new URL(url, 'http://localhost')
228
- const targetManager = routeDispatcher.resolve(parsedUrl.pathname)
229
- if (!routeDispatcher.isFallback(targetManager)) {
230
- return { manager: targetManager, workerName: (targetManager as any).config?.name ?? 'aux' }
231
- }
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' }
232
218
  }
233
219
  return null
234
220
  }
@@ -415,7 +401,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
415
401
  if (routeDispatcher) {
416
402
  try {
417
403
  const freshConfig = await configMod.loadConfig(auxConfigPath)
418
- routeDispatcher.addRoutes(freshConfig, auxManager, workerName)
404
+ routeDispatcher.addRoutes(freshConfig, auxManager, workerName, workerDef.hosts)
419
405
  } catch (err) {
420
406
  console.warn(`[lopata:vite] Failed to re-read config for "${workerName}" routes:`, err)
421
407
  }
@@ -437,35 +423,39 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
437
423
  )
438
424
  }
439
425
 
440
- // Build route dispatcher for aux workers with routes (main worker is the fallback)
426
+ // Build route dispatcher (aux workers only main is the fallback)
441
427
  routeDispatcher = new RouteDispatcher(mainAdapter)
442
428
  for (const workerDef of options.auxiliaryWorkers) {
443
429
  const cached = auxConfigs.get(workerDef.configPath)
444
430
  if (!cached) continue
445
431
  const auxMgr = workerRegistry.getManager(cached.name)
446
- if (auxMgr) routeDispatcher.addRoutes(cached.config, auxMgr, cached.name)
432
+ if (!auxMgr) continue
433
+ routeDispatcher.addRoutes(cached.config, auxMgr, cached.name, workerDef.hosts)
434
+ // Workers with hosts but no wrangler routes still need a catch-all entry
435
+ if (workerDef.hosts && (!cached.config.routes || cached.config.routes.length === 0)) {
436
+ routeDispatcher.addHostWorker(auxMgr, cached.name, workerDef.hosts)
437
+ }
447
438
  }
448
439
  if (routeDispatcher.hasRoutes()) {
449
440
  for (const r of routeDispatcher.getRegisteredRoutes()) {
450
- console.log(`[lopata:vite] Route: ${r.pattern} ${r.workerName}`)
441
+ const hostInfo = r.hostPatterns ? ` (hosts: ${r.hostPatterns.join(', ')})` : ''
442
+ console.log(`[lopata:vite] Route: ${r.pattern} → ${r.workerName}${hostInfo}`)
451
443
  }
452
444
  }
453
445
  apiMod.setRouteDispatcher(routeDispatcher)
454
446
 
455
- // Build host-based routing map
447
+ // Expose host routes to the dashboard API
448
+ const hostRoutes: Array<{ pattern: string; workerName: string }> = []
456
449
  for (const workerDef of options.auxiliaryWorkers) {
457
450
  if (!workerDef.hosts) continue
458
451
  const cached = auxConfigs.get(workerDef.configPath)
459
452
  if (!cached) continue
460
- const auxMgr = workerRegistry.getManager(cached.name)
461
- if (!auxMgr) continue
462
453
  for (const host of workerDef.hosts) {
463
- hostRoutes.push({ pattern: host, manager: auxMgr, workerName: cached.name })
464
- console.log(`[lopata:vite] Host route: ${host} → ${cached.name}`)
454
+ hostRoutes.push({ pattern: host, workerName: cached.name })
465
455
  }
466
456
  }
467
457
  if (hostRoutes.length > 0) {
468
- apiMod.setHostRoutes(hostRoutes.map(hr => ({ pattern: hr.pattern, workerName: hr.workerName })))
458
+ apiMod.setHostRoutes(hostRoutes)
469
459
  }
470
460
  }
471
461