lopata 0.14.2 → 0.15.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/src/cli/dev.ts CHANGED
@@ -19,6 +19,7 @@ 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'
22
23
  import { CFWebSocket } from '../bindings/websocket-pair'
23
24
  import { autoLoadConfig, loadConfig } from '../config'
24
25
  import { handleDashboardRequest } from '../dashboard-serve'
@@ -28,6 +29,7 @@ import { GenerationManager } from '../generation-manager'
28
29
  import { loadLopataConfig } from '../lopata-config'
29
30
  import { addCfProperty } from '../request-cf'
30
31
  import { extractHostname, RouteDispatcher } from '../route-matcher'
32
+ import { handleS3Request, matchS3Path } from '../s3/proxy'
31
33
  import { getTraceStore } from '../tracing/store'
32
34
  import type { TraceEvent } from '../tracing/types'
33
35
  import { WorkerRegistry } from '../worker-registry'
@@ -260,6 +262,41 @@ export async function run(ctx: CliContext, args: string[]) {
260
262
  return handleDashboardRequest(request)
261
263
  }
262
264
 
265
+ // S3-compatible proxy: /__s3/{bucket}/{key...} → R2 binding on active worker
266
+ const s3Match = matchS3Path(url.pathname)
267
+ 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)
298
+ }
299
+
263
300
  // Queue pull consumer endpoints: POST /cdn-cgi/handler/queues/<name>/messages/pull and /ack
264
301
  const queuePullMatch = url.pathname.match(/^\/cdn-cgi\/handler\/queues\/([^/]+)\/messages\/(pull|ack)$/)
265
302
  if (queuePullMatch && request.method === 'POST') {
@@ -471,6 +508,13 @@ export async function run(ctx: CliContext, args: string[]) {
471
508
  console.log(`[lopata] Server running at http://${hostname}:${port}`)
472
509
  console.log(`[lopata] Dashboard: http://${hostname}:${port}/__dashboard`)
473
510
 
511
+ if (hostname === '0.0.0.0') {
512
+ console.warn(
513
+ `[lopata] WARNING: S3 endpoint at /__s3/<bucket> is UNAUTHENTICATED and reachable from the network.`,
514
+ )
515
+ console.warn('[lopata] Anyone on your LAN can read and write local R2 buckets.')
516
+ }
517
+
474
518
  // Graceful shutdown
475
519
  const shutdown = () => {
476
520
  console.log('\n[lopata] Shutting down…')
@@ -0,0 +1,129 @@
1
+ /**
2
+ * AWS sigv4 streaming payload decoder.
3
+ *
4
+ * When an S3 client signs with STREAMING-AWS4-HMAC-SHA256-PAYLOAD (the default
5
+ * in aws-sdk-js v3), the request body is wrapped in chunk framing:
6
+ *
7
+ * <hex-size>;chunk-signature=<hex-sig>\r\n
8
+ * <data bytes, length = hex-size>\r\n
9
+ * ...
10
+ * 0;chunk-signature=<hex-sig>\r\n
11
+ * \r\n
12
+ *
13
+ * This decoder strips the framing and emits only the data bytes. It does NOT
14
+ * verify signatures (lopata's S3 proxy is unauthenticated dev-only).
15
+ */
16
+
17
+ export function isAwsChunked(headers: Headers): boolean {
18
+ const h = headers.get('x-amz-content-sha256')
19
+ if (!h) return false
20
+ return h === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD'
21
+ || h === 'STREAMING-UNSIGNED-PAYLOAD-TRAILER'
22
+ || h === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
23
+ }
24
+
25
+ /**
26
+ * Decode an aws-chunked ReadableStream into a stream of just the data bytes.
27
+ * Buffers incoming bytes, consumes framing lines, and emits data chunks.
28
+ */
29
+ export function decodeAwsChunked(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
30
+ const reader = stream.getReader()
31
+ let buffer = new Uint8Array(0)
32
+ // expecting 'header' (chunk-size;chunk-signature=...) or 'data'
33
+ type State =
34
+ | { kind: 'header' }
35
+ | { kind: 'data'; remaining: number }
36
+ | { kind: 'crlf-after-data' }
37
+ | { kind: 'done' }
38
+ let state: State = { kind: 'header' }
39
+
40
+ function append(chunk: Uint8Array) {
41
+ const merged = new Uint8Array(buffer.length + chunk.length)
42
+ merged.set(buffer, 0)
43
+ merged.set(chunk, buffer.length)
44
+ buffer = merged
45
+ }
46
+
47
+ function indexOfCRLF(buf: Uint8Array): number {
48
+ for (let i = 0; i < buf.length - 1; i++) {
49
+ if (buf[i] === 0x0d && buf[i + 1] === 0x0a) return i
50
+ }
51
+ return -1
52
+ }
53
+
54
+ return new ReadableStream({
55
+ async pull(controller) {
56
+ while (true) {
57
+ if (state.kind === 'done') {
58
+ controller.close()
59
+ return
60
+ }
61
+
62
+ if (state.kind === 'header') {
63
+ const crlf = indexOfCRLF(buffer)
64
+ if (crlf === -1) {
65
+ const { done, value } = await reader.read()
66
+ if (done) {
67
+ // Stream ended without proper termination — that's ok if we're empty.
68
+ controller.close()
69
+ return
70
+ }
71
+ append(value)
72
+ continue
73
+ }
74
+ const line = new TextDecoder().decode(buffer.subarray(0, crlf))
75
+ buffer = buffer.subarray(crlf + 2)
76
+ const sizeHex = line.split(';')[0]!.trim()
77
+ const size = parseInt(sizeHex, 16)
78
+ if (Number.isNaN(size)) {
79
+ controller.error(new Error(`Invalid chunk size: ${line}`))
80
+ return
81
+ }
82
+ if (size === 0) {
83
+ state = { kind: 'done' }
84
+ controller.close()
85
+ return
86
+ }
87
+ state = { kind: 'data', remaining: size }
88
+ continue
89
+ }
90
+
91
+ if (state.kind === 'data') {
92
+ if (buffer.length === 0) {
93
+ const { done, value } = await reader.read()
94
+ if (done) {
95
+ controller.error(new Error('Unexpected end of aws-chunked stream inside data'))
96
+ return
97
+ }
98
+ append(value)
99
+ continue
100
+ }
101
+ const take = Math.min(state.remaining, buffer.length)
102
+ controller.enqueue(buffer.subarray(0, take))
103
+ buffer = buffer.subarray(take)
104
+ state = { kind: 'data', remaining: state.remaining - take }
105
+ if (state.remaining === 0) {
106
+ state = { kind: 'crlf-after-data' }
107
+ }
108
+ return
109
+ }
110
+
111
+ if (state.kind === 'crlf-after-data') {
112
+ while (buffer.length < 2) {
113
+ const { done, value } = await reader.read()
114
+ if (done) {
115
+ controller.error(new Error('Unexpected end of aws-chunked stream before CRLF'))
116
+ return
117
+ }
118
+ append(value)
119
+ }
120
+ buffer = buffer.subarray(2)
121
+ state = { kind: 'header' }
122
+ }
123
+ }
124
+ },
125
+ cancel(reason) {
126
+ return reader.cancel(reason)
127
+ },
128
+ })
129
+ }
@@ -0,0 +1,131 @@
1
+ import { HTTP_METADATA_FIELDS, type R2HTTPMetadata, type R2Object, type R2Range } from '../bindings/r2'
2
+
3
+ export function extractPutOptions(req: Request): {
4
+ httpMetadata: R2HTTPMetadata
5
+ customMetadata: Record<string, string>
6
+ } {
7
+ const h = req.headers
8
+ const httpMetadata: R2HTTPMetadata = {}
9
+ for (const [header, field] of HTTP_METADATA_FIELDS) {
10
+ const v = h.get(header)
11
+ if (!v) continue
12
+ if (field === 'cacheExpiry') {
13
+ const d = new Date(v)
14
+ if (!Number.isNaN(d.getTime())) httpMetadata.cacheExpiry = d
15
+ } else {
16
+ ;(httpMetadata[field] as string) = v
17
+ }
18
+ }
19
+
20
+ const customMetadata: Record<string, string> = {}
21
+ for (const [k, v] of h) {
22
+ if (k.toLowerCase().startsWith('x-amz-meta-')) {
23
+ customMetadata[k.slice('x-amz-meta-'.length)] = v
24
+ }
25
+ }
26
+ return { httpMetadata, customMetadata }
27
+ }
28
+
29
+ export function applyObjectHeaders(obj: R2Object, headers = new Headers()): Headers {
30
+ headers.set('ETag', `"${obj.etag}"`)
31
+ headers.set('Last-Modified', obj.uploaded.toUTCString())
32
+ headers.set('Content-Length', String(obj.size))
33
+ obj.writeHttpMetadata(headers)
34
+ for (const [k, v] of Object.entries(obj.customMetadata)) {
35
+ headers.set(`x-amz-meta-${k}`, v)
36
+ }
37
+ return headers
38
+ }
39
+
40
+ /**
41
+ * Parse a Range header: bytes=<start>-<end> | bytes=<start>- | bytes=-<suffix>.
42
+ * Returns null if the header is absent or malformed.
43
+ */
44
+ export function parseRange(header: string | null): R2Range | null {
45
+ if (!header) return null
46
+ const m = header.match(/^bytes=(\d*)-(\d*)$/)
47
+ if (!m) return null
48
+ const startStr = m[1]!
49
+ const endStr = m[2]!
50
+ if (startStr === '' && endStr === '') return null
51
+ if (startStr === '') {
52
+ return { suffix: Number(endStr) }
53
+ }
54
+ const offset = Number(startStr)
55
+ if (endStr === '') return { offset }
56
+ return { offset, length: Number(endStr) - offset + 1 }
57
+ }
58
+
59
+ export interface Conditional {
60
+ ifMatch?: string[]
61
+ ifNoneMatch?: string[]
62
+ ifModifiedSince?: Date
63
+ ifUnmodifiedSince?: Date
64
+ }
65
+
66
+ export function parseConditional(headers: Headers): Conditional {
67
+ const c: Conditional = {}
68
+ const ifMatch = headers.get('if-match')
69
+ if (ifMatch) c.ifMatch = ifMatch.split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
70
+ const ifNoneMatch = headers.get('if-none-match')
71
+ if (ifNoneMatch) c.ifNoneMatch = ifNoneMatch.split(',').map((s) => s.trim().replace(/^"|"$/g, ''))
72
+ const ims = headers.get('if-modified-since')
73
+ if (ims) {
74
+ const d = new Date(ims)
75
+ if (!Number.isNaN(d.getTime())) c.ifModifiedSince = d
76
+ }
77
+ const iums = headers.get('if-unmodified-since')
78
+ if (iums) {
79
+ const d = new Date(iums)
80
+ if (!Number.isNaN(d.getTime())) c.ifUnmodifiedSince = d
81
+ }
82
+ return c
83
+ }
84
+
85
+ /**
86
+ * Evaluate a conditional against an existing object.
87
+ * Returns 'match' (preconditions met, proceed), 'not-modified' (GET/HEAD 304),
88
+ * or 'precondition-failed' (412).
89
+ *
90
+ * Per RFC 7232 & S3 semantics:
91
+ * If-Match fails → 412
92
+ * If-Unmodified-Since fails → 412
93
+ * If-None-Match matches → 304 for GET/HEAD, 412 for others
94
+ * If-Modified-Since not modified → 304 for GET/HEAD, ignored otherwise
95
+ */
96
+ export type ConditionalResult = 'match' | 'not-modified' | 'precondition-failed'
97
+
98
+ export function evaluateConditional(
99
+ cond: Conditional,
100
+ obj: { etag: string; uploaded: Date },
101
+ method: 'read' | 'write',
102
+ ): ConditionalResult {
103
+ const etag = obj.etag
104
+ // Normalise "uploaded" to second precision — HTTP dates have 1-second resolution.
105
+ const uploadedSec = Math.floor(obj.uploaded.getTime() / 1000) * 1000
106
+
107
+ if (cond.ifMatch) {
108
+ const matches = cond.ifMatch.some((t) => t === '*' || t === etag)
109
+ if (!matches) return 'precondition-failed'
110
+ }
111
+ if (cond.ifUnmodifiedSince) {
112
+ if (uploadedSec > cond.ifUnmodifiedSince.getTime()) return 'precondition-failed'
113
+ }
114
+ if (cond.ifNoneMatch) {
115
+ const matches = cond.ifNoneMatch.some((t) => t === '*' || t === etag)
116
+ if (matches) return method === 'read' ? 'not-modified' : 'precondition-failed'
117
+ }
118
+ if (cond.ifModifiedSince && method === 'read') {
119
+ if (uploadedSec <= cond.ifModifiedSince.getTime()) return 'not-modified'
120
+ }
121
+ return 'match'
122
+ }
123
+
124
+ export function corsHeaders(origin: string | null): Headers {
125
+ const h = new Headers()
126
+ h.set('Access-Control-Allow-Origin', origin ?? '*')
127
+ h.set('Access-Control-Allow-Methods', 'GET, PUT, POST, HEAD, DELETE, OPTIONS')
128
+ h.set('Access-Control-Allow-Headers', '*')
129
+ h.set('Access-Control-Expose-Headers', '*')
130
+ return h
131
+ }