lopata 0.8.3 → 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.
- package/dist/dashboard/{chunk-yxzrcvyh.js → chunk-yq8n0mcf.js} +75 -2
- package/dist/dashboard/index.html +1 -1
- package/package.json +1 -1
- package/src/api/dispatch.ts +2 -0
- package/src/api/handlers/routes.ts +31 -0
- package/src/api/index.ts +6 -1
- package/src/api/types.ts +8 -0
- package/src/cli/dev.ts +60 -10
- package/src/config.ts +1 -0
- package/src/route-matcher.ts +181 -0
- package/src/vite-plugin/config-plugin.ts +1 -1
- package/src/vite-plugin/dev-server-plugin.ts +268 -115
- package/src/vite-plugin/index.ts +1 -1
|
@@ -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
|
|
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:
|
|
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-
|
|
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
package/src/api/dispatch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -49,7 +49,7 @@ export function configPlugin(envName: string): Plugin {
|
|
|
49
49
|
watch: {
|
|
50
50
|
usePolling: true,
|
|
51
51
|
interval: 500,
|
|
52
|
-
ignored: ['**/.lopata/**', '**/.wrangler/**', '**/.react-router/**'],
|
|
52
|
+
ignored: ['**/.lopata/**', '**/.wrangler/**', '**/.react-router/**', '**/*.tmp.*'],
|
|
53
53
|
},
|
|
54
54
|
},
|
|
55
55
|
environments: {
|
|
@@ -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
|
|
@@ -130,6 +134,75 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
130
134
|
return currentModule ?? workerModule
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Dispatch a request through the worker's fetch() handler with tracing
|
|
139
|
+
* and generation tracking. Throws on HMR race conditions so the caller
|
|
140
|
+
* can retry.
|
|
141
|
+
*/
|
|
142
|
+
async function handleWorkerFetch(req: IncomingMessage, res: ServerResponse, next: Function): Promise<void> {
|
|
143
|
+
const activeModule = await ensureWorkerModule()
|
|
144
|
+
const genId = currentGenerationId
|
|
145
|
+
genActiveRequests.set(genId, (genActiveRequests.get(genId) ?? 0) + 1)
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const request = nodeReqToRequest(req)
|
|
149
|
+
const parsedUrl = new URL(request.url)
|
|
150
|
+
|
|
151
|
+
const handler = activeModule.default as Record<string, unknown>
|
|
152
|
+
if (!handler || typeof handler.fetch !== 'function') {
|
|
153
|
+
console.error('[lopata:vite] Worker module default export has no fetch() method')
|
|
154
|
+
next()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Capture caller stack before entering the worker (for async stack stitching)
|
|
159
|
+
const callerStack = new Error()
|
|
160
|
+
|
|
161
|
+
const ctx = new ExecutionContext()
|
|
162
|
+
const response = await (startSpan as Function)({
|
|
163
|
+
name: `${request.method} ${parsedUrl.pathname}`,
|
|
164
|
+
kind: 'server',
|
|
165
|
+
attributes: { 'http.method': request.method, 'http.url': request.url, 'lopata.generation_id': genId },
|
|
166
|
+
}, () =>
|
|
167
|
+
runWithExecutionContext(ctx, async () => {
|
|
168
|
+
try {
|
|
169
|
+
const resp = await (handler.fetch as Function).call(handler, request, env, ctx) as Response
|
|
170
|
+
;(setSpanAttribute as Function)('http.status_code', resp.status)
|
|
171
|
+
|
|
172
|
+
// Intercept React Router error boundary responses with lopata error page
|
|
173
|
+
const routeError = (globalThis as any).__lopata_routeError
|
|
174
|
+
delete (globalThis as any).__lopata_routeError
|
|
175
|
+
if (routeError) {
|
|
176
|
+
if (routeError instanceof Error) {
|
|
177
|
+
stitchAsyncStack(routeError, callerStack)
|
|
178
|
+
}
|
|
179
|
+
console.error('[lopata:vite] Route error:\n' + (routeError instanceof Error ? routeError.stack : String(routeError)))
|
|
180
|
+
return (renderErrorPage as Function)(routeError, request, env, config)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ctx._awaitAll().catch(() => {})
|
|
184
|
+
return resp
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (isHmrRaceError(err)) {
|
|
187
|
+
currentModule = null
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
if (err instanceof Error) {
|
|
191
|
+
stitchAsyncStack(err, callerStack)
|
|
192
|
+
}
|
|
193
|
+
console.error('[lopata:vite] Request error:\n' + (err instanceof Error ? err.stack : String(err)))
|
|
194
|
+
return (renderErrorPage as Function)(err, request, env, config)
|
|
195
|
+
}
|
|
196
|
+
})) as Response
|
|
197
|
+
|
|
198
|
+
writeResponse(response, res)
|
|
199
|
+
} finally {
|
|
200
|
+
const count = genActiveRequests.get(genId) ?? 1
|
|
201
|
+
if (count <= 1) genActiveRequests.delete(genId)
|
|
202
|
+
else genActiveRequests.set(genId, count - 1)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
133
206
|
return {
|
|
134
207
|
name: 'lopata:dev-server',
|
|
135
208
|
|
|
@@ -206,11 +279,20 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
206
279
|
apiMod.setDashboardConfig(config)
|
|
207
280
|
|
|
208
281
|
// 3b. Create generation tracking adapter for dashboard
|
|
209
|
-
const mainAdapter = {
|
|
282
|
+
const mainAdapter: import('../route-matcher.ts').RoutableManager & Record<string, unknown> = {
|
|
210
283
|
config,
|
|
211
284
|
gracePeriodMs: 0,
|
|
212
285
|
get active() {
|
|
213
|
-
return currentModule
|
|
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
|
|
214
296
|
},
|
|
215
297
|
list() {
|
|
216
298
|
return Array.from(viteGenerations.values()).map(g => ({
|
|
@@ -259,7 +341,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
259
341
|
},
|
|
260
342
|
setGracePeriod() {},
|
|
261
343
|
}
|
|
262
|
-
apiMod.setGenerationManager(mainAdapter as any)
|
|
344
|
+
apiMod.setGenerationManager(mainAdapter as any) // Dashboard adapter, not RoutableManager
|
|
263
345
|
|
|
264
346
|
// 4. Set up auxiliary workers (if configured)
|
|
265
347
|
if (options.auxiliaryWorkers && options.auxiliaryWorkers.length > 0) {
|
|
@@ -269,42 +351,76 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
269
351
|
const { GenerationManager } = await import('../generation-manager.ts')
|
|
270
352
|
|
|
271
353
|
workerRegistry = new WorkerRegistry()
|
|
272
|
-
workerRegistry.register(config.name, mainAdapter as any, true)
|
|
354
|
+
workerRegistry.register(config.name, mainAdapter as any, true) // Dashboard adapter
|
|
273
355
|
|
|
356
|
+
const auxConfigs = new Map<string, { config: any; name: string }>()
|
|
274
357
|
for (const workerDef of options.auxiliaryWorkers) {
|
|
275
358
|
const auxConfigPath = resolve(projectRoot, workerDef.configPath)
|
|
276
359
|
const auxBaseDir = dirname(auxConfigPath)
|
|
277
360
|
const auxConfig = await configMod.loadConfig(auxConfigPath)
|
|
278
|
-
|
|
361
|
+
const workerName = workerDef.name ?? auxConfig.name
|
|
362
|
+
auxConfigs.set(workerDef.configPath, { config: auxConfig, name: workerName })
|
|
363
|
+
console.log(`[lopata:vite] Auxiliary worker: ${workerName}`)
|
|
279
364
|
|
|
280
365
|
const auxManager = new GenerationManager(auxConfig, auxBaseDir, {
|
|
281
|
-
workerName
|
|
366
|
+
workerName,
|
|
282
367
|
workerRegistry,
|
|
283
368
|
isMain: false,
|
|
284
369
|
})
|
|
285
|
-
workerRegistry.register(
|
|
370
|
+
workerRegistry.register(workerName, auxManager)
|
|
286
371
|
|
|
287
372
|
try {
|
|
288
373
|
const gen = await auxManager.reload()
|
|
289
|
-
console.log(`[lopata:vite] Auxiliary worker "${
|
|
374
|
+
console.log(`[lopata:vite] Auxiliary worker "${workerName}" loaded (gen ${gen.id})`)
|
|
290
375
|
} catch (err) {
|
|
291
|
-
console.error(`[lopata:vite] Failed to load auxiliary worker "${
|
|
376
|
+
console.error(`[lopata:vite] Failed to load auxiliary worker "${workerName}":`, err)
|
|
292
377
|
}
|
|
293
378
|
|
|
294
379
|
// File watcher for aux worker reload
|
|
295
380
|
const auxSrcDir = dirname(resolve(auxBaseDir, auxConfig.main))
|
|
296
381
|
const auxWatcher = new FileWatcher(auxSrcDir, () => {
|
|
297
|
-
auxManager.reload().then(gen => {
|
|
298
|
-
console.log(`[lopata:vite] Auxiliary worker "${
|
|
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
|
+
}
|
|
299
393
|
}).catch(err => {
|
|
300
|
-
console.error(`[lopata:vite] Reload failed for "${
|
|
394
|
+
console.error(`[lopata:vite] Reload failed for "${workerName}":`, err)
|
|
301
395
|
})
|
|
302
396
|
})
|
|
303
397
|
auxWatcher.start()
|
|
304
|
-
console.log(`[lopata:vite] Watching ${auxSrcDir} for changes (${
|
|
398
|
+
console.log(`[lopata:vite] Watching ${auxSrcDir} for changes (${workerName})`)
|
|
305
399
|
}
|
|
306
400
|
|
|
307
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)
|
|
308
424
|
}
|
|
309
425
|
|
|
310
426
|
// 5. Set up WebSocket trace streaming on httpServer
|
|
@@ -355,68 +471,52 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
355
471
|
return
|
|
356
472
|
}
|
|
357
473
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
return
|
|
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
|
|
371
487
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const routeError = (globalThis as any).__lopata_routeError
|
|
389
|
-
delete (globalThis as any).__lopata_routeError
|
|
390
|
-
if (routeError) {
|
|
391
|
-
if (routeError instanceof Error) {
|
|
392
|
-
stitchAsyncStack(routeError, callerStack)
|
|
393
|
-
}
|
|
394
|
-
console.error('[lopata:vite] Route error:\n' + (routeError instanceof Error ? routeError.stack : String(routeError)))
|
|
395
|
-
return (renderErrorPage as Function)(routeError, request, env, config)
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
ctx._awaitAll().catch(() => {})
|
|
399
|
-
return resp
|
|
400
|
-
} catch (err) {
|
|
401
|
-
if (err instanceof Error) {
|
|
402
|
-
stitchAsyncStack(err, callerStack)
|
|
403
|
-
}
|
|
404
|
-
console.error('[lopata:vite] Request error:\n' + (err instanceof Error ? err.stack : String(err)))
|
|
405
|
-
return (renderErrorPage as Function)(err, request, env, config)
|
|
406
|
-
}
|
|
407
|
-
})) as Response
|
|
408
|
-
|
|
409
|
-
writeResponse(response, res)
|
|
410
|
-
} finally {
|
|
411
|
-
const count = genActiveRequests.get(genId) ?? 1
|
|
412
|
-
if (count <= 1) genActiveRequests.delete(genId)
|
|
413
|
-
else genActiveRequests.set(genId, count - 1)
|
|
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
|
|
414
504
|
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
await handleWorkerFetch(req, res, next)
|
|
415
509
|
} catch (err) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
510
|
+
if (!isHmrRaceError(err)) {
|
|
511
|
+
writeRequestError(res, err)
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
// Retry once after a short delay — module graph may be mid-evaluation during HMR
|
|
515
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
516
|
+
try {
|
|
517
|
+
await handleWorkerFetch(req, res, next)
|
|
518
|
+
} catch (retryErr) {
|
|
519
|
+
writeRequestError(res, retryErr)
|
|
420
520
|
}
|
|
421
521
|
}
|
|
422
522
|
})
|
|
@@ -552,6 +652,42 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
552
652
|
try {
|
|
553
653
|
const { CFWebSocket } = await import('../bindings/websocket-pair.ts')
|
|
554
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
|
+
|
|
555
691
|
const activeModule = await ensureWorkerModule()
|
|
556
692
|
const handler = activeModule.default as Record<string, unknown>
|
|
557
693
|
if (!handler || typeof handler.fetch !== 'function') {
|
|
@@ -559,7 +695,6 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
559
695
|
return
|
|
560
696
|
}
|
|
561
697
|
|
|
562
|
-
const request = nodeReqToRequest(req)
|
|
563
698
|
const ctx = new ExecutionContext()
|
|
564
699
|
const response = await runWithExecutionContext(ctx, async () => {
|
|
565
700
|
return (handler.fetch as Function).call(handler, request, env, ctx) as Response
|
|
@@ -573,47 +708,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
573
708
|
|
|
574
709
|
// Complete the upgrade and bridge
|
|
575
710
|
wss.handleUpgrade(req, socket, head, (ws: any) => {
|
|
576
|
-
|
|
577
|
-
cfSocket.addEventListener('message', (ev: Event) => {
|
|
578
|
-
const msgData = (ev as MessageEvent).data
|
|
579
|
-
try {
|
|
580
|
-
ws.send(msgData)
|
|
581
|
-
} catch {}
|
|
582
|
-
})
|
|
583
|
-
cfSocket.addEventListener('close', (ev: Event) => {
|
|
584
|
-
const ce = ev as CloseEvent
|
|
585
|
-
try {
|
|
586
|
-
ws.close(ce.code, ce.reason)
|
|
587
|
-
} catch {}
|
|
588
|
-
})
|
|
589
|
-
// Accept the client side so events from server.send() are dispatched
|
|
590
|
-
cfSocket.accept()
|
|
591
|
-
|
|
592
|
-
// Real WS → CF
|
|
593
|
-
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
|
594
|
-
const msgData = isBinary
|
|
595
|
-
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
|
|
596
|
-
: data.toString('utf-8')
|
|
597
|
-
const evt = { type: 'message' as const, data: msgData }
|
|
598
|
-
if (cfSocket._peer?._accepted) {
|
|
599
|
-
cfSocket._peer._dispatchWSEvent(evt)
|
|
600
|
-
} else if (cfSocket._peer) {
|
|
601
|
-
cfSocket._peer._eventQueue.push(evt)
|
|
602
|
-
}
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
ws.on('close', (code: number, reason: Buffer) => {
|
|
606
|
-
if (cfSocket._peer && cfSocket._peer.readyState !== 3) {
|
|
607
|
-
const evt = { type: 'close' as const, code: code ?? 1000, reason: reason?.toString('utf-8') ?? '', wasClean: true }
|
|
608
|
-
if (cfSocket._peer._accepted) {
|
|
609
|
-
cfSocket._peer._dispatchWSEvent(evt)
|
|
610
|
-
} else {
|
|
611
|
-
cfSocket._peer._eventQueue.push(evt)
|
|
612
|
-
}
|
|
613
|
-
cfSocket._peer.readyState = 3
|
|
614
|
-
}
|
|
615
|
-
cfSocket.readyState = 3
|
|
616
|
-
})
|
|
711
|
+
bridgeCfWebSocket(cfSocket, ws)
|
|
617
712
|
})
|
|
618
713
|
} catch (err) {
|
|
619
714
|
console.error('[lopata:vite] Worker WebSocket upgrade failed:', err)
|
|
@@ -622,6 +717,19 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
622
717
|
}
|
|
623
718
|
}
|
|
624
719
|
|
|
720
|
+
/** Detect transient TypeError from Vite module graph being mid-evaluation during HMR */
|
|
721
|
+
function isHmrRaceError(err: unknown): boolean {
|
|
722
|
+
return err instanceof TypeError && err.message.includes('not be null or undefined')
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function writeRequestError(res: ServerResponse, err: unknown): void {
|
|
726
|
+
console.error('[lopata:vite] Request error:', err)
|
|
727
|
+
if (!res.headersSent) {
|
|
728
|
+
res.writeHead(500, { 'content-type': 'text/plain' })
|
|
729
|
+
res.end(err instanceof Error ? err.stack ?? err.message : String(err))
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
625
733
|
function stitchAsyncStack(err: Error, callerError: Error | null): void {
|
|
626
734
|
if (!callerError) return
|
|
627
735
|
if (!err.stack || !callerError.stack) return
|
|
@@ -638,6 +746,51 @@ function stitchAsyncStack(err: Error, callerError: Error | null): void {
|
|
|
638
746
|
err.stack += '\n --- async ---\n' + filtered.join('\n')
|
|
639
747
|
}
|
|
640
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
|
+
|
|
641
794
|
function matchGlob(text: string, pattern: string): boolean {
|
|
642
795
|
const regex = pattern
|
|
643
796
|
.replace(/\*\*/g, '\0')
|
package/src/vite-plugin/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface LopataPluginConfig {
|
|
|
11
11
|
/** Vite environment name for SSR. Default: "ssr" */
|
|
12
12
|
viteEnvironment?: { name?: string }
|
|
13
13
|
/** Auxiliary workers loaded via native Bun import (not through Vite). */
|
|
14
|
-
auxiliaryWorkers?: { configPath: string }[]
|
|
14
|
+
auxiliaryWorkers?: { configPath: string; name?: string }[]
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|