lopata 0.8.4 → 0.10.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.
package/src/cli/dev.ts CHANGED
@@ -3,7 +3,15 @@ Error.stackTraceLimit = 50
3
3
 
4
4
  import '../plugin'
5
5
  import path from 'node:path'
6
- import { handleApiRequest, setDashboardConfig, setGenerationManager, setLopataConfig, setWorkerRegistry } from '../api'
6
+ import {
7
+ handleApiRequest,
8
+ setDashboardConfig,
9
+ setGenerationManager,
10
+ setHostRoutes,
11
+ setLopataConfig,
12
+ setRouteDispatcher,
13
+ setWorkerRegistry,
14
+ } from '../api'
7
15
  import { QueuePullConsumer } from '../bindings/queue'
8
16
  import type { AckRequest, PullRequest } from '../bindings/queue'
9
17
  import { CFWebSocket } from '../bindings/websocket-pair'
@@ -14,6 +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'
25
+ import { matchHost, RouteDispatcher } from '../route-matcher'
17
26
  import { getTraceStore } from '../tracing/store'
18
27
  import type { TraceEvent } from '../tracing/types'
19
28
  import { WorkerRegistry } from '../worker-registry'
@@ -32,6 +41,9 @@ export async function run(ctx: CliContext) {
32
41
  const lopataConfig = await loadLopataConfig(baseDir)
33
42
 
34
43
  let manager: GenerationManager
44
+ let routeDispatcher: RouteDispatcher | undefined
45
+ let registry: WorkerRegistry | undefined
46
+ const hostRoutes: Array<{ pattern: string; manager: GenerationManager; workerName: string }> = []
35
47
 
36
48
  if (lopataConfig) {
37
49
  // ─── Multi-worker mode ─────────────────────────────────────────
@@ -48,7 +60,7 @@ export async function run(ctx: CliContext) {
48
60
  console.warn(`[lopata] Unknown isolation mode "${lopataConfig.isolation}", using "dev"`)
49
61
  }
50
62
 
51
- const registry = new WorkerRegistry()
63
+ registry = new WorkerRegistry()
52
64
 
53
65
  // Load main worker config
54
66
  const mainConfig = await loadConfig(lopataConfig.main, envFlag)
@@ -67,9 +79,11 @@ export async function run(ctx: CliContext) {
67
79
  })
68
80
  registry.register(mainConfig.name, mainManager, true)
69
81
 
70
- // Load auxiliary workers
82
+ // Load auxiliary workers and collect their configs for route setup
83
+ const auxConfigs = new Map<string, import('../config').WranglerConfig>()
71
84
  for (const workerDef of lopataConfig.workers ?? []) {
72
85
  const auxConfig = await loadConfig(workerDef.config, envFlag)
86
+ auxConfigs.set(workerDef.name, auxConfig)
73
87
  const auxBaseDir = path.dirname(workerDef.config)
74
88
  console.log(`[lopata] Auxiliary worker: ${workerDef.name} (${auxConfig.name})`)
75
89
 
@@ -94,8 +108,17 @@ export async function run(ctx: CliContext) {
94
108
  // File watcher for aux worker
95
109
  const auxSrcDir = path.dirname(path.resolve(auxBaseDir, auxConfig.main))
96
110
  const auxWatcher = new FileWatcher(auxSrcDir, () => {
97
- auxManager.reload().then(gen => {
111
+ auxManager.reload().then(async gen => {
98
112
  console.log(`[lopata] Auxiliary worker "${workerDef.name}" reloaded → generation ${gen.id}`)
113
+ // Re-read config and update routes in case routes changed
114
+ if (routeDispatcher) {
115
+ try {
116
+ const freshConfig = await loadConfig(workerDef.config, envFlag)
117
+ routeDispatcher.addRoutes(freshConfig, auxManager, workerDef.name)
118
+ } catch (err) {
119
+ console.warn(`[lopata] Failed to re-read config for "${workerDef.name}" routes:`, err)
120
+ }
121
+ }
99
122
  }).catch(err => {
100
123
  console.error(`[lopata] Reload failed for "${workerDef.name}":`, err)
101
124
  })
@@ -109,9 +132,45 @@ export async function run(ctx: CliContext) {
109
132
  const firstGen = await mainManager.reload()
110
133
  console.log(`[lopata] Main worker → generation ${firstGen.id}`)
111
134
 
135
+ // Warn if main worker has routes — they are ignored because main is the fallback
136
+ if (mainConfig.routes && mainConfig.routes.length > 0) {
137
+ console.warn(
138
+ '[lopata] Warning: main worker has "routes" in config — these are ignored in multi-worker mode (main worker is the fallback for unmatched requests)',
139
+ )
140
+ }
141
+
142
+ // Build route dispatcher for route-based worker selection (aux workers only — main is the fallback)
143
+ routeDispatcher = new RouteDispatcher(mainManager)
144
+ for (const workerDef of lopataConfig.workers ?? []) {
145
+ const auxConfig = auxConfigs.get(workerDef.name)
146
+ const auxMgr = registry.getManager(workerDef.name)
147
+ if (auxConfig && auxMgr) routeDispatcher.addRoutes(auxConfig, auxMgr, workerDef.name)
148
+ }
149
+ if (routeDispatcher.hasRoutes()) {
150
+ for (const r of routeDispatcher.getRegisteredRoutes()) {
151
+ console.log(`[lopata] Route: ${r.pattern} → ${r.workerName}`)
152
+ }
153
+ }
154
+
155
+ // Build host-based routing map
156
+ for (const workerDef of lopataConfig.workers ?? []) {
157
+ if (!workerDef.hosts) continue
158
+ const auxMgr = registry.getManager(workerDef.name)
159
+ if (!auxMgr) continue
160
+ 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}`)
163
+ }
164
+ }
165
+
166
+ if (hostRoutes.length > 0) {
167
+ setHostRoutes(hostRoutes.map(hr => ({ pattern: hr.pattern, workerName: hr.workerName })))
168
+ }
169
+
112
170
  manager = mainManager
113
171
  setGenerationManager(manager)
114
172
  setWorkerRegistry(registry)
173
+ setRouteDispatcher(routeDispatcher)
115
174
 
116
175
  // File watcher for main worker
117
176
  const mainSrcDir = path.dirname(path.resolve(mainBaseDir, mainConfig.main))
@@ -201,9 +260,10 @@ export async function run(ctx: CliContext) {
201
260
  }
202
261
  }
203
262
 
204
- // Email handler: POST /cdn-cgi/handler/email?from=...&to=...
263
+ // Email handler: POST /cdn-cgi/handler/email?from=...&to=...&worker=<name>
205
264
  if (url.pathname === '/cdn-cgi/handler/email' && request.method === 'POST') {
206
- const gen = manager.active
265
+ const targetManager = resolveWorkerParam(url, registry, manager)
266
+ const gen = targetManager.active
207
267
  if (!gen) return new Response('No active generation', { status: 503 })
208
268
  const from = url.searchParams.get('from') ?? ''
209
269
  const to = url.searchParams.get('to') ?? ''
@@ -211,16 +271,31 @@ export async function run(ctx: CliContext) {
211
271
  return gen.callEmail(new Uint8Array(raw), from, to)
212
272
  }
213
273
 
214
- // Manual trigger: GET /cdn-cgi/handler/scheduled?cron=<expression>
274
+ // Manual trigger: GET /cdn-cgi/handler/scheduled?cron=<expression>&worker=<name>
215
275
  if (url.pathname === '/cdn-cgi/handler/scheduled') {
216
- const gen = manager.active
276
+ const targetManager = resolveWorkerParam(url, registry, manager)
277
+ const gen = targetManager.active
217
278
  if (!gen) return new Response('No active generation', { status: 503 })
218
279
  const cronExpr = url.searchParams.get('cron') ?? '* * * * *'
219
280
  return gen.callScheduled(cronExpr)
220
281
  }
221
282
 
222
- // Delegate to active generation
223
- const gen = manager.active
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
298
+ const gen = targetManager.active
224
299
  if (!gen) {
225
300
  return new Response('No active generation', { status: 503 })
226
301
  }
@@ -402,6 +477,18 @@ export async function run(ctx: CliContext) {
402
477
  await new Promise(() => {})
403
478
  }
404
479
 
480
+ /** Resolve a ?worker= query param to the target GenerationManager, falling back to the main manager. */
481
+ function resolveWorkerParam(url: URL, registry: WorkerRegistry | undefined, fallback: GenerationManager): GenerationManager {
482
+ const workerName = url.searchParams.get('worker')
483
+ if (!workerName || !registry) return fallback
484
+ const target = registry.getManager(workerName)
485
+ if (!target) {
486
+ console.warn(`[lopata] Unknown worker "${workerName}" in ?worker= param, using main worker`)
487
+ return fallback
488
+ }
489
+ return target
490
+ }
491
+
405
492
  function matchGlob(text: string, pattern: string): boolean {
406
493
  // Placeholder approach: protect ** before escaping special chars
407
494
  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 }
@@ -8,6 +8,7 @@ export interface LopataConfig {
8
8
  workers?: Array<{
9
9
  name: string
10
10
  config: string
11
+ hosts?: string[]
11
12
  }>
12
13
  /** Enable real cron scheduling based on wrangler triggers.crons (default: false) */
13
14
  cron?: boolean
@@ -0,0 +1,192 @@
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
+ /** Match a hostname against a host pattern. Supports exact match and `*.domain` wildcards. */
65
+ export function matchHost(hostname: string, pattern: string): boolean {
66
+ if (pattern === hostname) return true
67
+ if (pattern.startsWith('*.')) {
68
+ const suffix = pattern.slice(1) // ".localhost"
69
+ // Must have a subdomain — bare hostname doesn't match *.localhost
70
+ return hostname.endsWith(suffix) && hostname.length > suffix.length
71
+ }
72
+ return false
73
+ }
74
+
75
+ /** Count the number of path segments in a pattern (ignoring trailing wildcard). */
76
+ function segmentCount(pattern: string): number {
77
+ const clean = pattern.replace(/\/?\*$/, '')
78
+ if (clean === '' || clean === '/') return 0
79
+ return clean.split('/').filter(Boolean).length
80
+ }
81
+
82
+ interface RouteEntry {
83
+ pattern: string
84
+ workerName: string
85
+ manager: RoutableManager
86
+ }
87
+
88
+ /**
89
+ * Dispatches requests to workers based on route patterns.
90
+ * Routes are sorted by specificity (most specific first).
91
+ *
92
+ * Only auxiliary workers should be added here — the main worker
93
+ * is the fallback and handles all unmatched requests.
94
+ */
95
+ export class RouteDispatcher {
96
+ private routes: RouteEntry[] = []
97
+ private sorted = true
98
+ private fallback: RoutableManager
99
+
100
+ constructor(fallback: RoutableManager) {
101
+ this.fallback = fallback
102
+ }
103
+
104
+ addRoutes(config: WranglerConfig, manager: RoutableManager, workerName: string): void {
105
+ if (!config.routes) return
106
+
107
+ // Clear existing routes for this worker to support re-registration (e.g. config reload)
108
+ const hadRoutes = this.routes.length > 0
109
+ this.routes = this.routes.filter(r => r.workerName !== workerName)
110
+ if (hadRoutes && this.routes.length === 0) this.sorted = true
111
+
112
+ for (const route of config.routes) {
113
+ // Skip custom_domain entries — they are domain ownership claims, not request routing patterns
114
+ if (typeof route === 'object' && route.custom_domain) continue
115
+
116
+ const rawPattern = typeof route === 'string' ? route : route.pattern
117
+ if (!rawPattern || rawPattern.trim() === '') {
118
+ console.warn(`[lopata] Warning: empty route pattern in worker "${workerName}" — skipping`)
119
+ continue
120
+ }
121
+
122
+ const pattern = extractPathPattern(route)
123
+
124
+ // Warn about mid-pattern wildcards (CF only supports trailing wildcards)
125
+ const starIndex = pattern.indexOf('*')
126
+ if (starIndex !== -1 && starIndex < pattern.length - 1) {
127
+ console.warn(`[lopata] Warning: route pattern "${pattern}" has a wildcard not at the end — Cloudflare only supports trailing wildcards`)
128
+ }
129
+
130
+ // Skip duplicate patterns from different workers (first registered wins)
131
+ const existing = this.routes.find(r => r.pattern === pattern)
132
+ if (existing) {
133
+ console.warn(
134
+ `[lopata] Warning: route pattern "${pattern}" is already registered by "${existing.workerName}" — skipping duplicate from "${workerName}"`,
135
+ )
136
+ continue
137
+ }
138
+
139
+ this.routes.push({ pattern, workerName, manager })
140
+ this.sorted = false
141
+ }
142
+ }
143
+
144
+ removeWorkerRoutes(workerName: string): void {
145
+ this.routes = this.routes.filter(r => r.workerName !== workerName)
146
+ }
147
+
148
+ private ensureSorted(): void {
149
+ if (this.sorted) return
150
+ this.routes.sort((a, b) => {
151
+ const aHasWild = a.pattern.includes('*')
152
+ const bHasWild = b.pattern.includes('*')
153
+ // Non-wildcard patterns are more specific
154
+ if (aHasWild !== bHasWild) return aHasWild ? 1 : -1
155
+ // More segments = more specific
156
+ const segDiff = segmentCount(b.pattern) - segmentCount(a.pattern)
157
+ if (segDiff !== 0) return segDiff
158
+ // Slash-star (`/api/*`) is more specific than bare-star (`/api*`)
159
+ // because it only matches path-separated suffixes
160
+ const aSlashStar = a.pattern.endsWith('/*')
161
+ const bSlashStar = b.pattern.endsWith('/*')
162
+ if (aSlashStar !== bSlashStar) return aSlashStar ? -1 : 1
163
+ // Longer pattern string as tiebreaker
164
+ return b.pattern.length - a.pattern.length
165
+ })
166
+ this.sorted = true
167
+ }
168
+
169
+ resolve(pathname: string): RoutableManager {
170
+ this.ensureSorted()
171
+ for (const entry of this.routes) {
172
+ if (matchRoute(pathname, entry.pattern)) {
173
+ return entry.manager
174
+ }
175
+ }
176
+ return this.fallback
177
+ }
178
+
179
+ /** Check whether the given manager is the fallback (main worker). */
180
+ isFallback(manager: RoutableManager): boolean {
181
+ return manager === this.fallback
182
+ }
183
+
184
+ hasRoutes(): boolean {
185
+ return this.routes.length > 0
186
+ }
187
+
188
+ getRegisteredRoutes(): Array<{ pattern: string; workerName: string }> {
189
+ this.ensureSorted()
190
+ return this.routes.map(r => ({ pattern: r.pattern, workerName: r.workerName }))
191
+ }
192
+ }