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.
@@ -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: error.stack
1785
- }, undefined, false, undefined, this)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- for (const timer of this.alarmTimers.values()) clearTimeout(timer)
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) {
@@ -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 frames = parseStackFrames(err.stack ?? '')
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
- // Enrich frames with source code (limit to 20 for performance)
162
- const framesToEnrich = frames.slice(0, 20)
163
- await Promise.all(framesToEnrich.map(enrichFrameWithSourceAsync))
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
- // Strip cwd prefix from paths for display
166
- const cwdPrefix = process.cwd() + '/'
167
- const displayFrames = framesToEnrich.filter(f => f.source).map(f => ({
168
- ...f,
169
- file: f.file.startsWith(cwdPrefix) ? f.file.slice(cwdPrefix.length) : f.file,
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: err.stack ?? String(error),
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
- // Schedule force-stop after grace period
199
- oldGen.drainTimer = setTimeout(() => {
198
+ if (oldGen.isIdle()) {
199
+ // No in-flight work — stop immediately
200
200
  this._stopGeneration(oldGenId)
201
- }, this.gracePeriodMs)
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
  }
@@ -9,12 +9,16 @@ export interface StackFrame {
9
9
  sourceLine?: number
10
10
  }
11
11
 
12
- const STACK_LINE_RE = /at\s+(?:(.+?)\s+\()?(.+):(\d+):(\d+)\)?/
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 match = line.match(STACK_LINE_RE)
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
- currentModule = workerModule
227
-
228
- // Invalidate virtual modules in SSR environment.
229
- // Framework plugins (e.g. React Router) may only invalidate
230
- // virtual modules in the client module graph during HMR,
231
- // leaving stale versions in the SSR environment.
232
- invalidateVirtualModules(ssrEnv)
233
-
234
- wireClassRefs(registry, workerModule, env, workerRegistry)
235
- setGlobalEnv(env)
236
- console.log('[lopata:vite] Worker module (re)loaded, classes wired')
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 handler = workerModule.default as Record<string, unknown>
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
  });