lopata 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli/dev.ts CHANGED
@@ -8,7 +8,7 @@ import { QueuePullConsumer } from '../bindings/queue'
8
8
  import type { AckRequest, PullRequest } from '../bindings/queue'
9
9
  import { CFWebSocket } from '../bindings/websocket-pair'
10
10
  import { autoLoadConfig, loadConfig } from '../config'
11
- import { handleDashboardRequest } from '../dashboard/api'
11
+ import { handleDashboardRequest } from '../dashboard-serve'
12
12
  import { getDatabase } from '../db'
13
13
  import { FileWatcher } from '../file-watcher'
14
14
  import { GenerationManager } from '../generation-manager'
@@ -0,0 +1,90 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ let dashboardAssets: Map<string, { content: Uint8Array; contentType: string }> | null = null
5
+ let dashboardHtmlContent: string | null = null
6
+
7
+ const distDir = join(import.meta.dir, '../dist/dashboard')
8
+
9
+ if (existsSync(join(distDir, 'index.html'))) {
10
+ // Production: load pre-built assets from dist/
11
+ dashboardHtmlContent = await Bun.file(join(distDir, 'index.html')).text()
12
+ dashboardAssets = new Map()
13
+ for (const entry of readdirSync(distDir)) {
14
+ if (entry === 'index.html') continue
15
+ const content = new Uint8Array(await Bun.file(join(distDir, entry)).arrayBuffer())
16
+ const contentType = entry.endsWith('.css')
17
+ ? 'text/css'
18
+ : entry.endsWith('.js')
19
+ ? 'application/javascript'
20
+ : 'application/octet-stream'
21
+ dashboardAssets.set(entry, { content, contentType })
22
+ }
23
+ } else {
24
+ // Dev: build on-the-fly (requires source files + bun-plugin-tailwind)
25
+ const tailwindPlugin = (await import('bun-plugin-tailwind')).default
26
+ const htmlEntry = join(import.meta.dir, 'dashboard/index.html')
27
+
28
+ const result = await Bun.build({
29
+ entrypoints: [htmlEntry],
30
+ plugins: [tailwindPlugin],
31
+ })
32
+
33
+ if (!result.success) {
34
+ console.error('[lopata] Dashboard build failed:', result.logs)
35
+ throw new Error('Dashboard build failed')
36
+ }
37
+
38
+ const assets = new Map<string, { content: Uint8Array; contentType: string }>()
39
+ let html = ''
40
+
41
+ for (const output of result.outputs) {
42
+ const name = output.path.split('/').pop()!
43
+ const content = new Uint8Array(await output.arrayBuffer())
44
+
45
+ if (output.kind === 'entry-point' && name.endsWith('.html')) {
46
+ html = new TextDecoder().decode(content)
47
+ } else {
48
+ const contentType = name.endsWith('.css')
49
+ ? 'text/css'
50
+ : name.endsWith('.js')
51
+ ? 'application/javascript'
52
+ : 'application/octet-stream'
53
+ assets.set(name, { content, contentType })
54
+ }
55
+ }
56
+
57
+ for (const name of assets.keys()) {
58
+ html = html.replaceAll(`./${name}`, `/__dashboard/assets/${name}`)
59
+ }
60
+
61
+ dashboardHtmlContent = html
62
+ dashboardAssets = assets
63
+ }
64
+
65
+ export function handleDashboardRequest(request: Request): Response {
66
+ const url = new URL(request.url)
67
+
68
+ // Serve dashboard HTML
69
+ if (url.pathname === '/__dashboard') {
70
+ return new Response(dashboardHtmlContent, {
71
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
72
+ })
73
+ }
74
+
75
+ // Serve dashboard assets (JS, CSS)
76
+ const assetMatch = url.pathname.match(/^\/__dashboard\/assets\/(.+)$/)
77
+ if (assetMatch && dashboardAssets) {
78
+ const asset = dashboardAssets.get(assetMatch[1]!)
79
+ if (asset) {
80
+ return new Response(asset.content as unknown as BodyInit, {
81
+ headers: {
82
+ 'Content-Type': asset.contentType,
83
+ 'Cache-Control': 'public, max-age=31536000, immutable',
84
+ },
85
+ })
86
+ }
87
+ }
88
+
89
+ return new Response('Not found', { status: 404 })
90
+ }
@@ -0,0 +1,272 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { WranglerConfig } from './config'
4
+ import { getActiveContext } from './tracing/context'
5
+ import { enrichFrameWithSourceAsync, parseStackFrames, type StackFrame } from './tracing/frames'
6
+ import { getTraceStore } from './tracing/store'
7
+
8
+ interface ErrorPageData {
9
+ error: {
10
+ name: string
11
+ message: string
12
+ stack: string
13
+ frames: StackFrame[]
14
+ }
15
+ request: {
16
+ method: string
17
+ url: string
18
+ headers: Record<string, string>
19
+ }
20
+ env: Record<string, string>
21
+ bindings: { name: string; type: string }[]
22
+ runtime: {
23
+ bunVersion: string
24
+ platform: string
25
+ arch: string
26
+ workerName?: string
27
+ configName?: string
28
+ }
29
+ trace?: {
30
+ traceId: string
31
+ spanId: string | null
32
+ spans: Array<{
33
+ spanId: string
34
+ traceId: string
35
+ parentSpanId: string | null
36
+ name: string
37
+ status: string
38
+ startTime: number
39
+ endTime: number | null
40
+ durationMs: number | null
41
+ }>
42
+ }
43
+ }
44
+
45
+ // ─── Pre-built error page HTML ────────────────────────────────────────────
46
+
47
+ let errorPageHtml: string | null = null
48
+
49
+ const distFile = join(import.meta.dir, '../dist/error-page.html')
50
+
51
+ if (existsSync(distFile)) {
52
+ // Production: load pre-built self-contained HTML
53
+ errorPageHtml = await Bun.file(distFile).text()
54
+ } else {
55
+ // Dev: build on-the-fly (requires source files + bun-plugin-tailwind)
56
+ const tailwindPlugin = (await import('bun-plugin-tailwind')).default
57
+ const htmlEntry = join(import.meta.dir, 'error-page/index.html')
58
+
59
+ const result = await Bun.build({
60
+ entrypoints: [htmlEntry],
61
+ plugins: [tailwindPlugin],
62
+ })
63
+
64
+ if (!result.success) {
65
+ console.error('[lopata] Error page build failed:', result.logs)
66
+ throw new Error('Error page build failed')
67
+ }
68
+
69
+ const assets = new Map<string, { content: Uint8Array; contentType: string }>()
70
+ let html = ''
71
+
72
+ for (const output of result.outputs) {
73
+ const name = output.path.split('/').pop()!
74
+ const content = new Uint8Array(await output.arrayBuffer())
75
+
76
+ if (output.kind === 'entry-point' && name.endsWith('.html')) {
77
+ html = new TextDecoder().decode(content)
78
+ } else {
79
+ const contentType = name.endsWith('.css')
80
+ ? 'text/css'
81
+ : name.endsWith('.js')
82
+ ? 'application/javascript'
83
+ : 'application/octet-stream'
84
+ assets.set(name, { content, contentType })
85
+ }
86
+ }
87
+
88
+ // Inline assets directly into the HTML to make it self-contained
89
+ for (const [name, asset] of assets) {
90
+ const assetText = new TextDecoder().decode(asset.content)
91
+ if (name.endsWith('.css')) {
92
+ html = html.replace(
93
+ new RegExp(`<link[^>]*href="\\./${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*/?>`),
94
+ `<style>${assetText}</style>`,
95
+ )
96
+ } else if (name.endsWith('.js')) {
97
+ html = html.replace(
98
+ new RegExp(`<script[^>]*src="\\./${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*>[^<]*</script>`),
99
+ `<script type="module">${assetText}</script>`,
100
+ )
101
+ }
102
+ }
103
+
104
+ errorPageHtml = html
105
+ }
106
+
107
+ // ─── Env masking ──────────────────────────────────────────────────────────
108
+
109
+ const SENSITIVE_KEYS = /SECRET|KEY|TOKEN|PASSWORD|API|PRIVATE/i
110
+
111
+ function maskValue(value: string): string {
112
+ const show = 3
113
+ if (value.length <= show * 2 + 3) return '***'
114
+ return value.slice(0, show) + '***' + value.slice(-show)
115
+ }
116
+
117
+ function maskEnv(env: Record<string, unknown>): Record<string, string> {
118
+ const masked: Record<string, string> = {}
119
+ for (const [key, value] of Object.entries(env)) {
120
+ if (typeof value === 'object') continue // skip bindings
121
+ const strVal = String(value)
122
+ masked[key] = SENSITIVE_KEYS.test(key) ? maskValue(strVal) : strVal
123
+ }
124
+ return masked
125
+ }
126
+
127
+ // ─── Binding extraction ──────────────────────────────────────────────────
128
+
129
+ function extractBindings(config: WranglerConfig): { name: string; type: string }[] {
130
+ const bindings: { name: string; type: string }[] = []
131
+ for (const kv of config.kv_namespaces ?? []) bindings.push({ name: kv.binding, type: 'KV' })
132
+ for (const r2 of config.r2_buckets ?? []) bindings.push({ name: r2.binding, type: 'R2' })
133
+ for (const d of config.durable_objects?.bindings ?? []) bindings.push({ name: d.name, type: 'Durable Object' })
134
+ for (const wf of config.workflows ?? []) bindings.push({ name: wf.binding, type: 'Workflow' })
135
+ for (const d1 of config.d1_databases ?? []) bindings.push({ name: d1.binding, type: 'D1' })
136
+ for (const p of config.queues?.producers ?? []) bindings.push({ name: p.binding, type: 'Queue' })
137
+ for (const svc of config.services ?? []) bindings.push({ name: svc.binding, type: 'Service' })
138
+ if (config.images) bindings.push({ name: config.images.binding, type: 'Images' })
139
+ if (config.assets?.binding) bindings.push({ name: config.assets.binding, type: 'Assets' })
140
+ return bindings
141
+ }
142
+
143
+ // ─── Public API ──────────────────────────────────────────────────────────
144
+
145
+ export async function renderErrorPage(
146
+ error: unknown,
147
+ request: Request,
148
+ env: Record<string, unknown>,
149
+ config: WranglerConfig,
150
+ workerName?: string,
151
+ ): Promise<Response> {
152
+ if (!errorPageHtml) {
153
+ return new Response('Internal Server Error', { status: 500 })
154
+ }
155
+
156
+ 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:'))
160
+
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))
164
+
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
+ }))
171
+
172
+ const headers: Record<string, string> = {}
173
+ request.headers.forEach((value, key) => {
174
+ headers[key] = value
175
+ })
176
+
177
+ const data: ErrorPageData = {
178
+ error: {
179
+ name: err.name,
180
+ message: err.message,
181
+ stack: err.stack ?? String(error),
182
+ frames: displayFrames,
183
+ },
184
+ request: {
185
+ method: request.method,
186
+ url: request.url,
187
+ headers,
188
+ },
189
+ env: maskEnv(env),
190
+ bindings: extractBindings(config),
191
+ runtime: {
192
+ bunVersion: Bun.version,
193
+ platform: process.platform,
194
+ arch: process.arch,
195
+ workerName,
196
+ configName: config.name,
197
+ },
198
+ }
199
+
200
+ // Attach trace data if available
201
+ try {
202
+ const ctx = getActiveContext()
203
+ if (ctx?.traceId) {
204
+ const traceDetail = getTraceStore().getTrace(ctx.traceId)
205
+ if (traceDetail.spans.length > 0) {
206
+ data.trace = {
207
+ traceId: ctx.traceId,
208
+ spanId: ctx.spanId ?? null,
209
+ spans: traceDetail.spans.map(s => ({
210
+ spanId: s.spanId,
211
+ traceId: s.traceId,
212
+ parentSpanId: s.parentSpanId,
213
+ name: s.name,
214
+ status: s.status,
215
+ startTime: s.startTime,
216
+ endTime: s.endTime,
217
+ durationMs: s.durationMs,
218
+ })),
219
+ }
220
+ }
221
+ }
222
+ } catch {
223
+ // Don't break error page if trace fetch fails
224
+ }
225
+
226
+ // Persist error to tracing store
227
+ try {
228
+ const ctx = getActiveContext()
229
+ getTraceStore().insertError({
230
+ id: crypto.randomUUID(),
231
+ timestamp: Date.now(),
232
+ errorName: data.error.name,
233
+ errorMessage: data.error.message,
234
+ requestMethod: data.request.method,
235
+ requestUrl: data.request.url,
236
+ workerName: data.runtime.workerName ?? null,
237
+ traceId: ctx?.traceId ?? null,
238
+ spanId: ctx?.spanId ?? null,
239
+ source: 'fetch',
240
+ data: JSON.stringify(data),
241
+ })
242
+ } catch {
243
+ // Don't let persistence failure break the error response
244
+ }
245
+
246
+ const wantsHtml = (request.headers.get('Accept') ?? '').includes('text/html')
247
+
248
+ if (wantsHtml && errorPageHtml) {
249
+ const script = `<script>window.__LOPATA_ERROR__ = ${JSON.stringify(data).replace(/</g, '\\u003c')};</script>`
250
+ const html = errorPageHtml.replace('</head>', `${script}\n</head>`)
251
+
252
+ return new Response(html, {
253
+ status: 500,
254
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
255
+ })
256
+ }
257
+
258
+ // Text-only error response for non-HTML clients (curl, fetch, APIs, etc.)
259
+ let text = `${data.error.name}: ${data.error.message}\n`
260
+ if (displayFrames.length > 0) {
261
+ text += '\nStack:\n'
262
+ for (const f of displayFrames) {
263
+ text += ` at ${f.function} (${f.file}:${f.line}:${f.column})\n`
264
+ }
265
+ }
266
+ text += `\n${data.request.method} ${data.request.url}\n`
267
+
268
+ return new Response(text, {
269
+ status: 500,
270
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
271
+ })
272
+ }
package/src/generation.ts CHANGED
@@ -7,7 +7,7 @@ import { CFWebSocket } from './bindings/websocket-pair'
7
7
  import type { SqliteWorkflowBinding } from './bindings/workflow'
8
8
  import type { WranglerConfig } from './config'
9
9
  import { getDatabase } from './db'
10
- import { renderErrorPage } from './error-page/build'
10
+ import { renderErrorPage } from './error-page-render'
11
11
  import { ExecutionContext } from './execution-context'
12
12
  import { getActiveContext } from './tracing/context'
13
13
  import { persistError, setSpanAttribute, startSpan } from './tracing/span'
@@ -66,8 +66,8 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
66
66
  const ecMod = await import('../execution-context.ts')
67
67
  const spanMod = await import('../tracing/span.ts')
68
68
  const ctxMod = await import('../tracing/context.ts')
69
- const errorPageMod = await import('../error-page/build.ts')
70
- const dashboardMod = await import('../dashboard/api.ts')
69
+ const errorPageMod = await import('../error-page-render.ts')
70
+ const dashboardMod = await import('../dashboard-serve.ts')
71
71
  const apiMod = await import('../api/index.ts')
72
72
  const traceMod = await import('../tracing/store.ts')
73
73