lopata 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli/dev.ts CHANGED
@@ -19,7 +19,6 @@ import {
19
19
  } from '../api'
20
20
  import { QueuePullConsumer } from '../bindings/queue'
21
21
  import type { AckRequest, PullRequest } from '../bindings/queue'
22
- import type { FileR2Bucket } from '../bindings/r2'
23
22
  import { CFWebSocket } from '../bindings/websocket-pair'
24
23
  import { autoLoadConfig, loadConfig } from '../config'
25
24
  import { handleDashboardRequest } from '../dashboard-serve'
@@ -29,7 +28,7 @@ import { GenerationManager } from '../generation-manager'
29
28
  import { loadLopataConfig } from '../lopata-config'
30
29
  import { addCfProperty } from '../request-cf'
31
30
  import { extractHostname, RouteDispatcher } from '../route-matcher'
32
- import { handleS3Request, matchS3Path } from '../s3/proxy'
31
+ import { handleS3ProxyRequest, matchS3Path } from '../s3/proxy'
33
32
  import { getTraceStore } from '../tracing/store'
34
33
  import type { TraceEvent } from '../tracing/types'
35
34
  import { WorkerRegistry } from '../worker-registry'
@@ -265,36 +264,8 @@ export async function run(ctx: CliContext, args: string[]) {
265
264
  // S3-compatible proxy: /__s3/{bucket}/{key...} → R2 binding on active worker
266
265
  const s3Match = matchS3Path(url.pathname)
267
266
  if (s3Match) {
268
- const targetManager = resolveWorkerParam(url, registry, manager)
269
- const gen = targetManager.active
270
- const binding = gen?.env[s3Match.bucket] as FileR2Bucket | undefined
271
- const resolveBucket = (name: string) => gen?.env[name] as FileR2Bucket | undefined
272
- const listAllBuckets = () => {
273
- if (!gen) return []
274
- const out: Array<{ name: string; creationDate: Date }> = []
275
- for (const [name, value] of Object.entries(gen.env)) {
276
- // Duck-typed R2Bucket check — instrumentBinding wraps in a Proxy, so instanceof
277
- // isn't reliable. R2 bindings are distinguished by having these methods.
278
- if (
279
- value
280
- && typeof (value as { put?: unknown }).put === 'function'
281
- && typeof (value as { head?: unknown }).head === 'function'
282
- && typeof (value as { createMultipartUpload?: unknown }).createMultipartUpload === 'function'
283
- ) {
284
- out.push({ name, creationDate: new Date(0) })
285
- }
286
- }
287
- return out
288
- }
289
- const rewritten = new URL(request.url)
290
- rewritten.pathname = '/' + s3Match.keyPath
291
- const virtualReq = new Request(rewritten.toString(), {
292
- method: request.method,
293
- headers: request.headers,
294
- body: request.body,
295
- duplex: 'half',
296
- })
297
- return handleS3Request(virtualReq, s3Match.bucket, binding, resolveBucket, listAllBuckets)
267
+ const gen = resolveWorkerParam(url, registry, manager).active
268
+ return handleS3ProxyRequest(request, s3Match, gen?.env)
298
269
  }
299
270
 
300
271
  // Queue pull consumer endpoints: POST /cdn-cgi/handler/queues/<name>/messages/pull and /ack
package/src/s3/proxy.ts CHANGED
@@ -29,6 +29,19 @@ import {
29
29
  export type ResolveBucket = (name: string) => FileR2Bucket | undefined
30
30
  export type ListAllBuckets = () => Array<{ name: string; creationDate: Date }>
31
31
 
32
+ /**
33
+ * Duck-typed R2 binding check. `instrumentBinding` wraps R2 buckets in a
34
+ * Proxy, so `instanceof FileR2Bucket` isn't reliable — identify them by the
35
+ * presence of the methods we need from `handleS3Request`.
36
+ */
37
+ export function isR2Binding(v: unknown): v is FileR2Bucket {
38
+ return !!v
39
+ && typeof v === 'object'
40
+ && typeof (v as { put?: unknown }).put === 'function'
41
+ && typeof (v as { head?: unknown }).head === 'function'
42
+ && typeof (v as { createMultipartUpload?: unknown }).createMultipartUpload === 'function'
43
+ }
44
+
32
45
  /**
33
46
  * Per-bucket CORS configuration XML, set via PutBucketCors and returned by
34
47
  * GetBucketCors. Purely observational — lopata's dev server sends a fixed
@@ -697,3 +710,40 @@ export function matchS3Path(pathname: string): { bucket: string; keyPath: string
697
710
  if (slash === -1) return { bucket: rest, keyPath: '' }
698
711
  return { bucket: rest.slice(0, slash), keyPath: rest.slice(slash + 1) }
699
712
  }
713
+
714
+ /**
715
+ * Given a matched `/__s3/{bucket}/{key...}` request and a worker `env`,
716
+ * rewrite the URL to `/{key}`, duck-type R2 bindings from `env`, and dispatch
717
+ * to `handleS3Request`. Shared by the CLI dev server and the Vite plugin.
718
+ *
719
+ * `env` may be undefined when no worker generation is active yet — the S3
720
+ * handler then sees an undefined bucket binding and responds NoSuchBucket.
721
+ */
722
+ export function handleS3ProxyRequest(
723
+ req: Request,
724
+ match: { bucket: string; keyPath: string },
725
+ env: Record<string, unknown> | undefined,
726
+ ): Promise<Response> {
727
+ const resolveBucket: ResolveBucket = (name) => {
728
+ const v = env?.[name]
729
+ return isR2Binding(v) ? v : undefined
730
+ }
731
+ const binding = resolveBucket(match.bucket)
732
+ const listAllBuckets: ListAllBuckets = () => {
733
+ if (!env) return []
734
+ const out: Array<{ name: string; creationDate: Date }> = []
735
+ for (const [name, value] of Object.entries(env)) {
736
+ if (isR2Binding(value)) out.push({ name, creationDate: new Date(0) })
737
+ }
738
+ return out
739
+ }
740
+ const rewritten = new URL(req.url)
741
+ rewritten.pathname = '/' + match.keyPath
742
+ const virtualReq = new Request(rewritten.toString(), {
743
+ method: req.method,
744
+ headers: req.headers,
745
+ body: req.body,
746
+ duplex: 'half',
747
+ })
748
+ return handleS3Request(virtualReq, match.bucket, binding, resolveBucket, listAllBuckets)
749
+ }
@@ -51,6 +51,8 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
51
51
  let handleDashboardRequest: Function
52
52
  let handleApiRequest: Function
53
53
  let getTraceStore: Function
54
+ let handleS3ProxyRequest: typeof import('../s3/proxy.ts').handleS3ProxyRequest
55
+ let matchS3Path: typeof import('../s3/proxy.ts').matchS3Path
54
56
 
55
57
  // Route dispatcher for multi-worker route-based dispatching
56
58
  let routeDispatcher: RouteDispatcher | undefined
@@ -253,6 +255,7 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
253
255
  const dashboardMod = await import('../dashboard-serve.ts')
254
256
  const apiMod = await import('../api/index.ts')
255
257
  const traceMod = await import('../tracing/store.ts')
258
+ const s3Mod = await import('../s3/proxy.ts')
256
259
 
257
260
  wireClassRefs = envMod.wireClassRefs
258
261
  setGlobalEnv = envMod.setGlobalEnv
@@ -265,6 +268,8 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
265
268
  handleDashboardRequest = dashboardMod.handleDashboardRequest
266
269
  handleApiRequest = apiMod.handleApiRequest
267
270
  getTraceStore = traceMod.getTraceStore
271
+ handleS3ProxyRequest = s3Mod.handleS3ProxyRequest
272
+ matchS3Path = s3Mod.matchS3Path
268
273
 
269
274
  // 1. Load wrangler config
270
275
  if (options.configPath) {
@@ -524,6 +529,28 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
524
529
  return
525
530
  }
526
531
 
532
+ // S3-compatible proxy: /__s3/{bucket}/{key...} → R2 binding on the main worker env.
533
+ // Routes straight through to lopata's S3 handler without going through the
534
+ // worker's fetch(), so sandbox uploaders etc. can hit R2 bindings directly
535
+ // with the same URL shape that `bunx lopata dev` exposes.
536
+ {
537
+ const pathOnly = url.split('?')[0] ?? url
538
+ const s3Match = matchS3Path(pathOnly)
539
+ if (s3Match) {
540
+ try {
541
+ const response = await handleS3ProxyRequest(nodeReqToRequest(req), s3Match, env)
542
+ await writeResponse(response, res)
543
+ } catch (err) {
544
+ console.error('[lopata:vite] S3 proxy error:', err)
545
+ if (!res.headersSent) {
546
+ res.writeHead(500, { 'content-type': 'text/plain' })
547
+ res.end(String(err))
548
+ }
549
+ }
550
+ return
551
+ }
552
+ }
553
+
527
554
  // Aux worker dispatch: host-based first, then route-based
528
555
  {
529
556
  const resolved = resolveAuxWorker(req, url)