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.
- 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/dev-server-plugin.ts +176 -56
- 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
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
366
|
+
workerName,
|
|
351
367
|
workerRegistry,
|
|
352
368
|
isMain: false,
|
|
353
369
|
})
|
|
354
|
-
workerRegistry.register(
|
|
370
|
+
workerRegistry.register(workerName, auxManager)
|
|
355
371
|
|
|
356
372
|
try {
|
|
357
373
|
const gen = await auxManager.reload()
|
|
358
|
-
console.log(`[lopata:vite] Auxiliary worker "${
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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 (${
|
|
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
|
-
|
|
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')
|
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
|
/**
|