lopata 0.3.1 → 0.4.1
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/error-page.html +63 -2
- package/package.json +1 -1
- package/src/bindings/crypto-extras.ts +89 -0
- package/src/bindings/durable-object.ts +7 -2
- package/src/error-page-render.ts +38 -13
- package/src/generation-manager.ts +13 -3
- package/src/generation.ts +10 -1
- package/src/tracing/frames.ts +6 -2
- package/src/vite-plugin/dev-server-plugin.ts +42 -42
- package/src/vite-plugin/react-router-plugin.ts +2 -2
package/dist/error-page.html
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
--color-red-50: oklch(97.1% .013 17.38);
|
|
21
21
|
--color-red-300: oklch(80.8% .114 19.571);
|
|
22
22
|
--color-red-400: oklch(70.4% .191 22.216);
|
|
23
|
+
--color-amber-400: oklch(82.8% .189 84.429);
|
|
24
|
+
--color-amber-600: oklch(66.6% .179 58.318);
|
|
23
25
|
--color-emerald-400: oklch(76.5% .177 163.223);
|
|
24
26
|
--color-gray-50: oklch(98.5% .002 247.839);
|
|
25
27
|
--color-gray-100: oklch(96.7% .003 264.542);
|
|
@@ -412,6 +414,10 @@
|
|
|
412
414
|
margin-right: calc(var(--spacing) * 2);
|
|
413
415
|
}
|
|
414
416
|
|
|
417
|
+
.mb-1 {
|
|
418
|
+
margin-bottom: calc(var(--spacing) * 1);
|
|
419
|
+
}
|
|
420
|
+
|
|
415
421
|
.mb-1\.5 {
|
|
416
422
|
margin-bottom: calc(var(--spacing) * 1.5);
|
|
417
423
|
}
|
|
@@ -592,6 +598,10 @@
|
|
|
592
598
|
border-color: #0000;
|
|
593
599
|
}
|
|
594
600
|
|
|
601
|
+
.border-l-amber-400 {
|
|
602
|
+
border-left-color: var(--color-amber-400);
|
|
603
|
+
}
|
|
604
|
+
|
|
595
605
|
.border-l-error-red {
|
|
596
606
|
border-left-color: var(--color-error-red);
|
|
597
607
|
}
|
|
@@ -761,6 +771,10 @@
|
|
|
761
771
|
white-space: pre-wrap;
|
|
762
772
|
}
|
|
763
773
|
|
|
774
|
+
.text-amber-600 {
|
|
775
|
+
color: var(--color-amber-600);
|
|
776
|
+
}
|
|
777
|
+
|
|
764
778
|
.text-error-red {
|
|
765
779
|
color: var(--color-error-red);
|
|
766
780
|
}
|
|
@@ -1774,6 +1788,39 @@ function App() {
|
|
|
1774
1788
|
frames: error.frames
|
|
1775
1789
|
}, undefined, false, undefined, this)
|
|
1776
1790
|
}, undefined, false, undefined, this),
|
|
1791
|
+
error.causes?.map((cause, i3) => /* @__PURE__ */ u3("div", {
|
|
1792
|
+
class: "flex flex-col gap-4",
|
|
1793
|
+
children: [
|
|
1794
|
+
/* @__PURE__ */ u3("div", {
|
|
1795
|
+
class: "bg-white rounded-lg border border-gray-200 overflow-hidden border-l-4 border-l-amber-400",
|
|
1796
|
+
children: /* @__PURE__ */ u3("div", {
|
|
1797
|
+
class: "px-5 py-3",
|
|
1798
|
+
children: [
|
|
1799
|
+
/* @__PURE__ */ u3("div", {
|
|
1800
|
+
class: "flex items-center gap-2.5 mb-1",
|
|
1801
|
+
children: /* @__PURE__ */ u3("span", {
|
|
1802
|
+
class: "text-xs font-semibold uppercase tracking-wider text-amber-600",
|
|
1803
|
+
children: [
|
|
1804
|
+
"Caused by: ",
|
|
1805
|
+
cause.name
|
|
1806
|
+
]
|
|
1807
|
+
}, undefined, true, undefined, this)
|
|
1808
|
+
}, undefined, false, undefined, this),
|
|
1809
|
+
/* @__PURE__ */ u3(ErrorMessage, {
|
|
1810
|
+
message: cause.message
|
|
1811
|
+
}, undefined, false, undefined, this)
|
|
1812
|
+
]
|
|
1813
|
+
}, undefined, true, undefined, this)
|
|
1814
|
+
}, undefined, false, undefined, this),
|
|
1815
|
+
cause.frames.length > 0 && /* @__PURE__ */ u3(Section, {
|
|
1816
|
+
title: `Source Code (cause ${i3 + 1})`,
|
|
1817
|
+
open: true,
|
|
1818
|
+
children: /* @__PURE__ */ u3(FrameList, {
|
|
1819
|
+
frames: cause.frames
|
|
1820
|
+
}, undefined, false, undefined, this)
|
|
1821
|
+
}, undefined, false, undefined, this)
|
|
1822
|
+
]
|
|
1823
|
+
}, i3, true, undefined, this)),
|
|
1777
1824
|
/* @__PURE__ */ u3(Section, {
|
|
1778
1825
|
title: "Stack Trace",
|
|
1779
1826
|
children: /* @__PURE__ */ u3("div", {
|
|
@@ -1781,8 +1828,22 @@ function App() {
|
|
|
1781
1828
|
children: /* @__PURE__ */ u3("pre", {
|
|
1782
1829
|
class: "text-xs text-ink-muted leading-5 m-0 whitespace-pre-wrap break-words",
|
|
1783
1830
|
style: "font-family: 'JetBrains Mono', monospace;",
|
|
1784
|
-
children:
|
|
1785
|
-
|
|
1831
|
+
children: [
|
|
1832
|
+
error.stack,
|
|
1833
|
+
error.causes?.map((cause, i3) => /* @__PURE__ */ u3("span", {
|
|
1834
|
+
children: [
|
|
1835
|
+
`
|
|
1836
|
+
|
|
1837
|
+
`,
|
|
1838
|
+
/* @__PURE__ */ u3("span", {
|
|
1839
|
+
class: "text-amber-600 font-semibold",
|
|
1840
|
+
children: "[cause]: "
|
|
1841
|
+
}, undefined, false, undefined, this),
|
|
1842
|
+
cause.stack
|
|
1843
|
+
]
|
|
1844
|
+
}, i3, true, undefined, this))
|
|
1845
|
+
]
|
|
1846
|
+
}, undefined, true, undefined, this)
|
|
1786
1847
|
}, undefined, false, undefined, this)
|
|
1787
1848
|
}, undefined, false, undefined, this),
|
|
1788
1849
|
data.trace && data.trace.spans.length > 0 && /* @__PURE__ */ u3(Section, {
|
package/package.json
CHANGED
|
@@ -68,6 +68,82 @@ export class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView>
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Detect if DER-encoded key data is PKCS#1 RSAPrivateKey format.
|
|
73
|
+
* PKCS#1: SEQUENCE { INTEGER(version=0), INTEGER(modulus), ... }
|
|
74
|
+
* PKCS#8: SEQUENCE { INTEGER(version=0), SEQUENCE(algorithmId), ... }
|
|
75
|
+
*/
|
|
76
|
+
function isPkcs1RsaKey(data: Uint8Array): boolean {
|
|
77
|
+
if (data.length < 10 || data[0] !== 0x30) return false
|
|
78
|
+
let offset = 1
|
|
79
|
+
// Skip outer SEQUENCE length
|
|
80
|
+
if (data[offset]! & 0x80) {
|
|
81
|
+
offset += 1 + (data[offset]! & 0x7f)
|
|
82
|
+
} else {
|
|
83
|
+
offset += 1
|
|
84
|
+
}
|
|
85
|
+
// Expect version: INTEGER 0 (02 01 00)
|
|
86
|
+
if (data[offset] !== 0x02 || data[offset + 1] !== 0x01 || data[offset + 2] !== 0x00) return false
|
|
87
|
+
offset += 3
|
|
88
|
+
// PKCS#1 next element is INTEGER (0x02 = modulus)
|
|
89
|
+
// PKCS#8 next element is SEQUENCE (0x30 = algorithmIdentifier)
|
|
90
|
+
return data[offset] === 0x02
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function derEncodeLength(length: number): Uint8Array {
|
|
94
|
+
if (length < 0x80) return new Uint8Array([length])
|
|
95
|
+
if (length < 0x100) return new Uint8Array([0x81, length])
|
|
96
|
+
return new Uint8Array([0x82, (length >> 8) & 0xff, length & 0xff])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function derWrap(tag: number, content: Uint8Array): Uint8Array {
|
|
100
|
+
const len = derEncodeLength(content.length)
|
|
101
|
+
const result = new Uint8Array(1 + len.length + content.length)
|
|
102
|
+
result[0] = tag
|
|
103
|
+
result.set(len, 1)
|
|
104
|
+
result.set(content, 1 + len.length)
|
|
105
|
+
return result
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wrap a PKCS#1 RSAPrivateKey in a PKCS#8 PrivateKeyInfo envelope.
|
|
110
|
+
*/
|
|
111
|
+
function wrapPkcs1InPkcs8(pkcs1Key: Uint8Array): Uint8Array {
|
|
112
|
+
// AlgorithmIdentifier: SEQUENCE { OID 1.2.840.113549.1.1.1 (rsaEncryption), NULL }
|
|
113
|
+
const rsaAlgorithmId = new Uint8Array([
|
|
114
|
+
0x30,
|
|
115
|
+
0x0d,
|
|
116
|
+
0x06,
|
|
117
|
+
0x09,
|
|
118
|
+
0x2a,
|
|
119
|
+
0x86,
|
|
120
|
+
0x48,
|
|
121
|
+
0x86,
|
|
122
|
+
0xf7,
|
|
123
|
+
0x0d,
|
|
124
|
+
0x01,
|
|
125
|
+
0x01,
|
|
126
|
+
0x01,
|
|
127
|
+
0x05,
|
|
128
|
+
0x00,
|
|
129
|
+
])
|
|
130
|
+
const version = new Uint8Array([0x02, 0x01, 0x00])
|
|
131
|
+
const privateKeyOctetString = derWrap(0x04, pkcs1Key)
|
|
132
|
+
|
|
133
|
+
const inner = new Uint8Array(version.length + rsaAlgorithmId.length + privateKeyOctetString.length)
|
|
134
|
+
inner.set(version, 0)
|
|
135
|
+
inner.set(rsaAlgorithmId, version.length)
|
|
136
|
+
inner.set(privateKeyOctetString, version.length + rsaAlgorithmId.length)
|
|
137
|
+
|
|
138
|
+
return derWrap(0x30, inner)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toUint8Array(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
142
|
+
if (data instanceof Uint8Array) return data
|
|
143
|
+
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
144
|
+
return new Uint8Array(data)
|
|
145
|
+
}
|
|
146
|
+
|
|
71
147
|
/**
|
|
72
148
|
* Patches the global `crypto` object with CF-specific extensions.
|
|
73
149
|
*/
|
|
@@ -86,4 +162,17 @@ export function patchGlobalCrypto(): void {
|
|
|
86
162
|
writable: false,
|
|
87
163
|
configurable: true,
|
|
88
164
|
})
|
|
165
|
+
|
|
166
|
+
// Patch importKey to accept PKCS#1 RSA keys with "pkcs8" format (matching workerd behavior).
|
|
167
|
+
// Workerd is lenient and auto-wraps PKCS#1 in PKCS#8; Bun/Node native crypto rejects it.
|
|
168
|
+
const origImportKey = subtle.importKey.bind(subtle)
|
|
169
|
+
subtle.importKey = ((format: string, keyData: unknown, algorithm: unknown, extractable: boolean, keyUsages: readonly string[]) => {
|
|
170
|
+
if (format === 'pkcs8' && typeof keyData === 'object' && keyData !== null && !('kty' in keyData)) {
|
|
171
|
+
const bytes = toUint8Array(keyData as ArrayBuffer | ArrayBufferView)
|
|
172
|
+
if (isPkcs1RsaKey(bytes)) {
|
|
173
|
+
return (origImportKey as any)('pkcs8', wrapPkcs1InPkcs8(bytes), algorithm, extractable, [...keyUsages])
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return (origImportKey as any)(format, keyData, algorithm, extractable, [...keyUsages])
|
|
177
|
+
}) as typeof subtle.importKey
|
|
89
178
|
}
|
|
@@ -899,14 +899,19 @@ export class DurableObjectNamespaceImpl {
|
|
|
899
899
|
}
|
|
900
900
|
}
|
|
901
901
|
|
|
902
|
+
/** @internal Clear all in-memory alarm timers (alarms persist in DB for new generation to restore) */
|
|
903
|
+
clearAlarmTimers(): void {
|
|
904
|
+
for (const timer of this.alarmTimers.values()) clearTimeout(timer)
|
|
905
|
+
this.alarmTimers.clear()
|
|
906
|
+
}
|
|
907
|
+
|
|
902
908
|
/** @internal Destroy this namespace: clear timers, evict executors without active WebSockets */
|
|
903
909
|
destroy(): void {
|
|
904
910
|
if (this._evictionTimer) {
|
|
905
911
|
clearInterval(this._evictionTimer)
|
|
906
912
|
this._evictionTimer = null
|
|
907
913
|
}
|
|
908
|
-
|
|
909
|
-
this.alarmTimers.clear()
|
|
914
|
+
this.clearAlarmTimers()
|
|
910
915
|
// Dispose executors without active WebSockets; keep the rest alive
|
|
911
916
|
for (const [idStr, executor] of this._executors) {
|
|
912
917
|
if (executor.activeWebSocketCount() === 0) {
|
package/src/error-page-render.ts
CHANGED
|
@@ -5,12 +5,20 @@ import { getActiveContext } from './tracing/context'
|
|
|
5
5
|
import { enrichFrameWithSourceAsync, parseStackFrames, type StackFrame } from './tracing/frames'
|
|
6
6
|
import { getTraceStore } from './tracing/store'
|
|
7
7
|
|
|
8
|
+
interface ErrorCause {
|
|
9
|
+
name: string
|
|
10
|
+
message: string
|
|
11
|
+
stack: string
|
|
12
|
+
frames: StackFrame[]
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
interface ErrorPageData {
|
|
9
16
|
error: {
|
|
10
17
|
name: string
|
|
11
18
|
message: string
|
|
12
19
|
stack: string
|
|
13
20
|
frames: StackFrame[]
|
|
21
|
+
causes: ErrorCause[]
|
|
14
22
|
}
|
|
15
23
|
request: {
|
|
16
24
|
method: string
|
|
@@ -154,20 +162,36 @@ export async function renderErrorPage(
|
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
const err = error instanceof Error ? error : new Error(String(error))
|
|
157
|
-
const
|
|
158
|
-
// Drop native/node internal frames — they have no readable source and waste enrichment slots
|
|
159
|
-
.filter(f => !f.file.startsWith('native:') && !f.file.startsWith('node:'))
|
|
165
|
+
const cwdPrefix = process.cwd() + '/'
|
|
160
166
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
async function processError(e: Error): Promise<{ frames: StackFrame[]; stack: string }> {
|
|
168
|
+
const frames = parseStackFrames(e.stack ?? '')
|
|
169
|
+
.filter(f => !f.file.startsWith('native:') && !f.file.startsWith('node:'))
|
|
170
|
+
const framesToEnrich = frames.slice(0, 20)
|
|
171
|
+
await Promise.all(framesToEnrich.map(enrichFrameWithSourceAsync))
|
|
172
|
+
const displayFrames = framesToEnrich.filter(f => f.source).map(f => ({
|
|
173
|
+
...f,
|
|
174
|
+
file: f.file.startsWith(cwdPrefix) ? f.file.slice(cwdPrefix.length) : f.file,
|
|
175
|
+
}))
|
|
176
|
+
return { frames: displayFrames, stack: e.stack ?? String(e) }
|
|
177
|
+
}
|
|
164
178
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
const { frames: displayFrames, stack: mainStack } = await processError(err)
|
|
180
|
+
|
|
181
|
+
// Walk the cause chain
|
|
182
|
+
const causes: ErrorCause[] = []
|
|
183
|
+
let current: unknown = err.cause
|
|
184
|
+
for (let i = 0; i < 5 && current; i++) {
|
|
185
|
+
const causeErr = current instanceof Error ? current : new Error(String(current))
|
|
186
|
+
const { frames: causeFrames, stack: causeStack } = await processError(causeErr)
|
|
187
|
+
causes.push({
|
|
188
|
+
name: causeErr.name,
|
|
189
|
+
message: causeErr.message,
|
|
190
|
+
stack: causeStack,
|
|
191
|
+
frames: causeFrames,
|
|
192
|
+
})
|
|
193
|
+
current = causeErr.cause
|
|
194
|
+
}
|
|
171
195
|
|
|
172
196
|
const headers: Record<string, string> = {}
|
|
173
197
|
request.headers.forEach((value, key) => {
|
|
@@ -178,8 +202,9 @@ export async function renderErrorPage(
|
|
|
178
202
|
error: {
|
|
179
203
|
name: err.name,
|
|
180
204
|
message: err.message,
|
|
181
|
-
stack:
|
|
205
|
+
stack: mainStack,
|
|
182
206
|
frames: displayFrames,
|
|
207
|
+
causes,
|
|
183
208
|
},
|
|
184
209
|
request: {
|
|
185
210
|
method: request.method,
|
|
@@ -195,10 +195,20 @@ export class GenerationManager {
|
|
|
195
195
|
const oldGen = this.generations.get(oldGenId)
|
|
196
196
|
if (oldGen && oldGen.state === 'active') {
|
|
197
197
|
oldGen.drain()
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
if (oldGen.isIdle()) {
|
|
199
|
+
// No in-flight work — stop immediately
|
|
200
200
|
this._stopGeneration(oldGenId)
|
|
201
|
-
}
|
|
201
|
+
} else {
|
|
202
|
+
// Poll for idle state, with grace period as hard maximum
|
|
203
|
+
oldGen.drainPollTimer = setInterval(() => {
|
|
204
|
+
if (oldGen.isIdle()) {
|
|
205
|
+
this._stopGeneration(oldGenId)
|
|
206
|
+
}
|
|
207
|
+
}, 200)
|
|
208
|
+
oldGen.drainTimer = setTimeout(() => {
|
|
209
|
+
this._stopGeneration(oldGenId)
|
|
210
|
+
}, this.gracePeriodMs)
|
|
211
|
+
}
|
|
202
212
|
}
|
|
203
213
|
}
|
|
204
214
|
|
package/src/generation.ts
CHANGED
|
@@ -55,6 +55,7 @@ export class Generation {
|
|
|
55
55
|
private queueConsumers: QueueConsumer[] = []
|
|
56
56
|
private cronTimer: NodeJS.Timer | ReturnType<typeof setInterval> | null = null
|
|
57
57
|
drainTimer: ReturnType<typeof setTimeout> | null = null
|
|
58
|
+
drainPollTimer: ReturnType<typeof setInterval> | null = null
|
|
58
59
|
|
|
59
60
|
constructor(
|
|
60
61
|
id: number,
|
|
@@ -350,11 +351,15 @@ export class Generation {
|
|
|
350
351
|
}
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
/** Transition to draining — stops consumers, keeps in-flight requests alive */
|
|
354
|
+
/** Transition to draining — stops consumers, clears alarm timers, keeps in-flight requests alive */
|
|
354
355
|
drain(): void {
|
|
355
356
|
if (this.state === 'stopped') return
|
|
356
357
|
this.state = 'draining'
|
|
357
358
|
this.stopConsumers()
|
|
359
|
+
// Clear alarm timers — new generation restores them from DB via _restoreAlarms()
|
|
360
|
+
for (const entry of this.registry.durableObjects) {
|
|
361
|
+
entry.namespace.clearAlarmTimers()
|
|
362
|
+
}
|
|
358
363
|
}
|
|
359
364
|
|
|
360
365
|
/** Force-stop: drain + destroy all DO namespaces + abort workflows */
|
|
@@ -366,6 +371,10 @@ export class Generation {
|
|
|
366
371
|
clearTimeout(this.drainTimer)
|
|
367
372
|
this.drainTimer = null
|
|
368
373
|
}
|
|
374
|
+
if (this.drainPollTimer) {
|
|
375
|
+
clearInterval(this.drainPollTimer)
|
|
376
|
+
this.drainPollTimer = null
|
|
377
|
+
}
|
|
369
378
|
for (const entry of this.registry.durableObjects) {
|
|
370
379
|
entry.namespace.destroy()
|
|
371
380
|
}
|
package/src/tracing/frames.ts
CHANGED
|
@@ -9,12 +9,16 @@ export interface StackFrame {
|
|
|
9
9
|
sourceLine?: number
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// V8: `at functionName (file:line:col)` or `at file:line:col`
|
|
13
|
+
const V8_STACK_RE = /at\s+(?:(.+?)\s+\()?(.+):(\d+):(\d+)\)?/
|
|
14
|
+
// JSC/Bun: `functionName@file:line:col`
|
|
15
|
+
const JSC_STACK_RE = /^(.+?)@(.+):(\d+):(\d+)$/
|
|
13
16
|
|
|
14
17
|
export function parseStackFrames(stack: string): StackFrame[] {
|
|
15
18
|
const frames: StackFrame[] = []
|
|
16
19
|
for (const line of stack.split('\n')) {
|
|
17
|
-
const
|
|
20
|
+
const trimmed = line.trim()
|
|
21
|
+
const match = trimmed.match(V8_STACK_RE) ?? trimmed.match(JSC_STACK_RE)
|
|
18
22
|
if (!match) continue
|
|
19
23
|
frames.push({
|
|
20
24
|
file: match[2]!,
|
|
@@ -50,6 +50,8 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
50
50
|
|
|
51
51
|
// Track current module to detect when Vite HMR invalidates it
|
|
52
52
|
let currentModule: Record<string, unknown> | null = null
|
|
53
|
+
// Serializes module reload — prevents concurrent wireClassRefs calls
|
|
54
|
+
let reloadLock: Promise<void> | null = null
|
|
53
55
|
|
|
54
56
|
return {
|
|
55
57
|
name: 'lopata:dev-server',
|
|
@@ -219,27 +221,43 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
219
221
|
}
|
|
220
222
|
|
|
221
223
|
const entrypoint = resolve(server.config.root, config.main)
|
|
224
|
+
|
|
225
|
+
// Wait for any in-progress reload before importing
|
|
226
|
+
if (reloadLock) await reloadLock
|
|
227
|
+
|
|
222
228
|
const workerModule = await (ssrEnv as any).runner.import(entrypoint) as Record<string, unknown>
|
|
223
229
|
|
|
224
230
|
// Re-wire class refs when module changes (HMR invalidation)
|
|
225
231
|
if (workerModule !== currentModule) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
232
|
+
if (reloadLock) {
|
|
233
|
+
// Another request started reloading while we were importing — wait for it
|
|
234
|
+
await reloadLock
|
|
235
|
+
} else {
|
|
236
|
+
let resolveReload!: () => void
|
|
237
|
+
reloadLock = new Promise(r => {
|
|
238
|
+
resolveReload = r
|
|
239
|
+
})
|
|
240
|
+
try {
|
|
241
|
+
currentModule = workerModule
|
|
242
|
+
wireClassRefs(registry, workerModule, env, workerRegistry)
|
|
243
|
+
setGlobalEnv(env)
|
|
244
|
+
console.log('[lopata:vite] Worker module (re)loaded, classes wired')
|
|
245
|
+
} catch (err) {
|
|
246
|
+
// Reset so next request retries
|
|
247
|
+
currentModule = null
|
|
248
|
+
throw err
|
|
249
|
+
} finally {
|
|
250
|
+
reloadLock = null
|
|
251
|
+
resolveReload()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
237
254
|
}
|
|
238
255
|
|
|
239
256
|
const request = nodeReqToRequest(req)
|
|
240
257
|
const parsedUrl = new URL(request.url)
|
|
241
258
|
|
|
242
|
-
const
|
|
259
|
+
const activeModule = currentModule ?? workerModule
|
|
260
|
+
const handler = activeModule.default as Record<string, unknown>
|
|
243
261
|
if (!handler || typeof handler.fetch !== 'function') {
|
|
244
262
|
console.error('[lopata:vite] Worker module default export has no fetch() method')
|
|
245
263
|
return next()
|
|
@@ -258,6 +276,18 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
258
276
|
try {
|
|
259
277
|
const resp = await (handler.fetch as Function).call(handler, request, env, ctx) as Response
|
|
260
278
|
;(setSpanAttribute as Function)('http.status_code', resp.status)
|
|
279
|
+
|
|
280
|
+
// Intercept React Router error boundary responses with lopata error page
|
|
281
|
+
const routeError = (globalThis as any).__lopata_routeError
|
|
282
|
+
delete (globalThis as any).__lopata_routeError
|
|
283
|
+
if (routeError) {
|
|
284
|
+
if (routeError instanceof Error) {
|
|
285
|
+
stitchAsyncStack(routeError, callerStack)
|
|
286
|
+
}
|
|
287
|
+
console.error('[lopata:vite] Route error:\n' + (routeError instanceof Error ? routeError.stack : String(routeError)))
|
|
288
|
+
return (renderErrorPage as Function)(routeError, request, env, config)
|
|
289
|
+
}
|
|
290
|
+
|
|
261
291
|
ctx._awaitAll().catch(() => {})
|
|
262
292
|
return resp
|
|
263
293
|
} catch (err) {
|
|
@@ -390,36 +420,6 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
390
420
|
}
|
|
391
421
|
}
|
|
392
422
|
|
|
393
|
-
function invalidateVirtualModules(ssrEnv: any): void {
|
|
394
|
-
// Only invalidate Lopata's own virtual modules, NOT framework modules
|
|
395
|
-
// (e.g. React Router's virtual:react-router/server-manifest uses
|
|
396
|
-
// Math.random() for dev version — invalidating it generates a new version
|
|
397
|
-
// that mismatches the client's cached manifest, causing
|
|
398
|
-
// "manifest version mismatch during eager route discovery" errors).
|
|
399
|
-
const isLopataModule = (id: string) => id.includes('\0cloudflare:') || id.includes('\0@cloudflare/')
|
|
400
|
-
|
|
401
|
-
// Server-side: clear transformResult so fetchModule returns fresh code
|
|
402
|
-
const modGraph = ssrEnv.moduleGraph
|
|
403
|
-
if (modGraph?.idToModuleMap) {
|
|
404
|
-
for (const [id, mod] of modGraph.idToModuleMap) {
|
|
405
|
-
if (isLopataModule(id)) {
|
|
406
|
-
modGraph.invalidateModule(mod)
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Runner-side: clear evaluated state so next import re-evaluates
|
|
412
|
-
const runner = ssrEnv.runner
|
|
413
|
-
const evaluatedModules = runner?.evaluatedModules
|
|
414
|
-
if (evaluatedModules?.idToModuleMap) {
|
|
415
|
-
for (const [id, node] of evaluatedModules.idToModuleMap) {
|
|
416
|
-
if (isLopataModule(id)) {
|
|
417
|
-
evaluatedModules.invalidateModule(node)
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
423
|
function stitchAsyncStack(err: Error, callerError: Error | null): void {
|
|
424
424
|
if (!callerError) return
|
|
425
425
|
if (!err.stack || !callerError.stack) return
|
|
@@ -46,7 +46,7 @@ const __bf_instr = {
|
|
|
46
46
|
},
|
|
47
47
|
}, async () => {
|
|
48
48
|
const result = await callHandler();
|
|
49
|
-
if (result.status === "error") throw result.error;
|
|
49
|
+
if (result.status === "error") { globalThis.__lopata_routeError = result.error; throw result.error; }
|
|
50
50
|
});
|
|
51
51
|
},
|
|
52
52
|
action: async (callHandler, info) => {
|
|
@@ -62,7 +62,7 @@ const __bf_instr = {
|
|
|
62
62
|
},
|
|
63
63
|
}, async () => {
|
|
64
64
|
const result = await callHandler();
|
|
65
|
-
if (result.status === "error") throw result.error;
|
|
65
|
+
if (result.status === "error") { globalThis.__lopata_routeError = result.error; throw result.error; }
|
|
66
66
|
});
|
|
67
67
|
},
|
|
68
68
|
});
|