@echofiles/echo-pdf 0.4.1 → 0.4.3

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.
Files changed (72) hide show
  1. package/README.md +302 -11
  2. package/bin/echo-pdf.js +176 -8
  3. package/bin/lib/http.js +26 -1
  4. package/dist/agent-defaults.d.ts +3 -0
  5. package/dist/agent-defaults.js +18 -0
  6. package/dist/auth.d.ts +18 -0
  7. package/dist/auth.js +36 -0
  8. package/dist/core/index.d.ts +50 -0
  9. package/dist/core/index.js +7 -0
  10. package/dist/file-ops.d.ts +11 -0
  11. package/dist/file-ops.js +36 -0
  12. package/dist/file-store-do.d.ts +36 -0
  13. package/dist/file-store-do.js +298 -0
  14. package/dist/file-utils.d.ts +6 -0
  15. package/dist/file-utils.js +36 -0
  16. package/dist/http-error.d.ts +9 -0
  17. package/dist/http-error.js +14 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/local/index.d.ts +135 -0
  21. package/dist/local/index.js +555 -0
  22. package/dist/mcp-server.d.ts +3 -0
  23. package/dist/mcp-server.js +124 -0
  24. package/dist/node/pdfium-local.d.ts +8 -0
  25. package/dist/node/pdfium-local.js +147 -0
  26. package/dist/node/semantic-local.d.ts +16 -0
  27. package/dist/node/semantic-local.js +113 -0
  28. package/dist/pdf-agent.d.ts +18 -0
  29. package/dist/pdf-agent.js +217 -0
  30. package/dist/pdf-config.d.ts +4 -0
  31. package/dist/pdf-config.js +140 -0
  32. package/dist/pdf-storage.d.ts +8 -0
  33. package/dist/pdf-storage.js +86 -0
  34. package/dist/pdf-types.d.ts +83 -0
  35. package/dist/pdf-types.js +1 -0
  36. package/dist/pdfium-engine.d.ts +9 -0
  37. package/dist/pdfium-engine.js +180 -0
  38. package/dist/provider-client.d.ts +20 -0
  39. package/dist/provider-client.js +173 -0
  40. package/dist/provider-keys.d.ts +10 -0
  41. package/dist/provider-keys.js +27 -0
  42. package/dist/r2-file-store.d.ts +20 -0
  43. package/dist/r2-file-store.js +176 -0
  44. package/dist/response-schema.d.ts +15 -0
  45. package/dist/response-schema.js +159 -0
  46. package/dist/tool-registry.d.ts +16 -0
  47. package/dist/tool-registry.js +175 -0
  48. package/dist/types.d.ts +91 -0
  49. package/dist/types.js +1 -0
  50. package/dist/worker.d.ts +7 -0
  51. package/dist/worker.js +386 -0
  52. package/package.json +34 -5
  53. package/wrangler.toml +1 -1
  54. package/src/agent-defaults.ts +0 -25
  55. package/src/file-ops.ts +0 -50
  56. package/src/file-store-do.ts +0 -349
  57. package/src/file-utils.ts +0 -43
  58. package/src/http-error.ts +0 -21
  59. package/src/index.ts +0 -415
  60. package/src/mcp-server.ts +0 -171
  61. package/src/pdf-agent.ts +0 -252
  62. package/src/pdf-config.ts +0 -143
  63. package/src/pdf-storage.ts +0 -109
  64. package/src/pdf-types.ts +0 -85
  65. package/src/pdfium-engine.ts +0 -207
  66. package/src/provider-client.ts +0 -176
  67. package/src/provider-keys.ts +0 -44
  68. package/src/r2-file-store.ts +0 -195
  69. package/src/response-schema.ts +0 -182
  70. package/src/tool-registry.ts +0 -203
  71. package/src/types.ts +0 -40
  72. package/src/wasm.d.ts +0 -4
@@ -1,207 +0,0 @@
1
- import { init } from "@embedpdf/pdfium"
2
- import { encode as encodePng } from "@cf-wasm/png/workerd"
3
- import type { WrappedPdfiumModule } from "@embedpdf/pdfium"
4
- import type { EchoPdfConfig } from "./pdf-types"
5
- import { toDataUrl } from "./file-utils"
6
- import compiledPdfiumModule from "@embedpdf/pdfium/dist/pdfium.wasm"
7
-
8
- let moduleInstance: WrappedPdfiumModule | null = null
9
- let libraryInitialized = false
10
-
11
- const toUint8 = (value: ArrayBuffer): Uint8Array => new Uint8Array(value)
12
- const textDecoder = new TextDecoder()
13
-
14
- const ensureWasmFunctionShim = (): void => {
15
- const wasmApi = WebAssembly as unknown as {
16
- Function?: unknown
17
- }
18
- if (typeof wasmApi.Function === "function") return
19
- ;(wasmApi as { Function: (sig: unknown, fn: unknown) => unknown }).Function = (
20
- _sig: unknown,
21
- fn: unknown
22
- ) => fn
23
- }
24
-
25
- const ensurePdfium = async (config: EchoPdfConfig): Promise<WrappedPdfiumModule> => {
26
- ensureWasmFunctionShim()
27
- if (!moduleInstance) {
28
- const maybeModule = compiledPdfiumModule as unknown
29
- if (maybeModule instanceof WebAssembly.Module) {
30
- moduleInstance = await init({
31
- instantiateWasm: (
32
- imports: WebAssembly.Imports,
33
- successCallback: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void
34
- ): WebAssembly.Exports => {
35
- const instance = new WebAssembly.Instance(maybeModule, imports)
36
- successCallback(instance, maybeModule)
37
- return instance.exports
38
- },
39
- })
40
- } else {
41
- const wasmBinary = await fetch(config.pdfium.wasmUrl).then((res) => res.arrayBuffer())
42
- moduleInstance = await init({ wasmBinary })
43
- }
44
- }
45
- if (!libraryInitialized) {
46
- moduleInstance.FPDF_InitLibrary()
47
- libraryInitialized = true
48
- }
49
- return moduleInstance
50
- }
51
-
52
- const makeDoc = (pdfium: WrappedPdfiumModule, bytes: Uint8Array): {
53
- readonly doc: number
54
- readonly memPtr: number
55
- } => {
56
- const memPtr = pdfium.pdfium.wasmExports.malloc(bytes.length)
57
- ;(pdfium.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8.set(bytes, memPtr)
58
- const doc = pdfium.FPDF_LoadMemDocument(memPtr, bytes.length, "")
59
- if (!doc) {
60
- pdfium.pdfium.wasmExports.free(memPtr)
61
- throw new Error("Failed to load PDF document")
62
- }
63
- return { doc, memPtr }
64
- }
65
-
66
- const closeDoc = (pdfium: WrappedPdfiumModule, doc: number, memPtr: number): void => {
67
- pdfium.FPDF_CloseDocument(doc)
68
- pdfium.pdfium.wasmExports.free(memPtr)
69
- }
70
-
71
- const bgraToRgba = (bgra: Uint8Array): Uint8Array => {
72
- const rgba = new Uint8Array(bgra.length)
73
- for (let i = 0; i < bgra.length; i += 4) {
74
- rgba[i] = bgra[i + 2] ?? 0
75
- rgba[i + 1] = bgra[i + 1] ?? 0
76
- rgba[i + 2] = bgra[i] ?? 0
77
- rgba[i + 3] = bgra[i + 3] ?? 255
78
- }
79
- return rgba
80
- }
81
-
82
- const decodeUtf16Le = (buf: Uint8Array): string => {
83
- const view = new Uint16Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 2))
84
- const chars: number[] = []
85
- for (const code of view) {
86
- if (code === 0) break
87
- chars.push(code)
88
- }
89
- return String.fromCharCode(...chars)
90
- }
91
-
92
- export const getPdfPageCount = async (config: EchoPdfConfig, bytes: Uint8Array): Promise<number> => {
93
- const pdfium = await ensurePdfium(config)
94
- const { doc, memPtr } = makeDoc(pdfium, bytes)
95
- try {
96
- return pdfium.FPDF_GetPageCount(doc)
97
- } finally {
98
- closeDoc(pdfium, doc, memPtr)
99
- }
100
- }
101
-
102
- export const renderPdfPageToPng = async (
103
- config: EchoPdfConfig,
104
- bytes: Uint8Array,
105
- pageIndex: number,
106
- scale = config.service.defaultRenderScale
107
- ): Promise<{
108
- width: number
109
- height: number
110
- png: Uint8Array
111
- }> => {
112
- const pdfium = await ensurePdfium(config)
113
- const { doc, memPtr } = makeDoc(pdfium, bytes)
114
- let page = 0
115
- let bitmap = 0
116
- try {
117
- page = pdfium.FPDF_LoadPage(doc, pageIndex)
118
- if (!page) {
119
- throw new Error(`Failed to load page ${pageIndex}`)
120
- }
121
- const width = Math.max(1, Math.round(pdfium.FPDF_GetPageWidthF(page) * scale))
122
- const height = Math.max(1, Math.round(pdfium.FPDF_GetPageHeightF(page) * scale))
123
- bitmap = pdfium.FPDFBitmap_Create(width, height, 1)
124
- if (!bitmap) {
125
- throw new Error("Failed to create bitmap")
126
- }
127
- pdfium.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, 0xffffffff)
128
- pdfium.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, 0)
129
-
130
- const stride = pdfium.FPDFBitmap_GetStride(bitmap)
131
- const bufferPtr = pdfium.FPDFBitmap_GetBuffer(bitmap)
132
- const heap = (pdfium.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8
133
- const bgra = heap.slice(bufferPtr, bufferPtr + stride * height)
134
- const rgba = bgraToRgba(bgra)
135
- const png = encodePng(rgba, width, height)
136
- return { width, height, png }
137
- } finally {
138
- if (bitmap) pdfium.FPDFBitmap_Destroy(bitmap)
139
- if (page) pdfium.FPDF_ClosePage(page)
140
- closeDoc(pdfium, doc, memPtr)
141
- }
142
- }
143
-
144
- export const extractPdfPageText = async (
145
- config: EchoPdfConfig,
146
- bytes: Uint8Array,
147
- pageIndex: number
148
- ): Promise<string> => {
149
- const pdfium = await ensurePdfium(config)
150
- const { doc, memPtr } = makeDoc(pdfium, bytes)
151
- let page = 0
152
- let textPage = 0
153
- let outPtr = 0
154
- try {
155
- page = pdfium.FPDF_LoadPage(doc, pageIndex)
156
- if (!page) {
157
- throw new Error(`Failed to load page ${pageIndex}`)
158
- }
159
- textPage = pdfium.FPDFText_LoadPage(page)
160
- if (!textPage) return ""
161
- const chars = pdfium.FPDFText_CountChars(textPage)
162
- if (chars <= 0) return ""
163
- const bytesLen = (chars + 1) * 2
164
- outPtr = pdfium.pdfium.wasmExports.malloc(bytesLen)
165
- pdfium.FPDFText_GetText(textPage, 0, chars, outPtr)
166
- const heap = (pdfium.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8
167
- const raw = heap.slice(outPtr, outPtr + bytesLen)
168
- return decodeUtf16Le(raw).trim()
169
- } finally {
170
- if (outPtr) pdfium.pdfium.wasmExports.free(outPtr)
171
- if (textPage) pdfium.FPDFText_ClosePage(textPage)
172
- if (page) pdfium.FPDF_ClosePage(page)
173
- closeDoc(pdfium, doc, memPtr)
174
- }
175
- }
176
-
177
- export const toBytes = async (value: string): Promise<Uint8Array> => {
178
- const response = await fetch(value)
179
- if (!response.ok) {
180
- throw new Error(`Failed to fetch source: HTTP ${response.status}`)
181
- }
182
- const contentType = (response.headers.get("content-type") ?? "").toLowerCase()
183
- const bytes = toUint8(await response.arrayBuffer())
184
- const signature = textDecoder.decode(bytes.subarray(0, Math.min(8, bytes.length)))
185
-
186
- if (contentType.includes("application/pdf") || signature.startsWith("%PDF-")) {
187
- return bytes
188
- }
189
-
190
- const html = textDecoder.decode(bytes)
191
- const pdfMatch = html.match(/https?:\/\/[^"' )]+\.pdf[^"' )]*/i)
192
- if (!pdfMatch || pdfMatch.length === 0) {
193
- throw new Error("URL does not point to a PDF and no PDF link was found in the page")
194
- }
195
-
196
- const resolvedUrl = pdfMatch[0].replace(/&amp;/g, "&")
197
- const pdfResponse = await fetch(resolvedUrl)
198
- if (!pdfResponse.ok) {
199
- throw new Error(`Failed to fetch resolved PDF url: HTTP ${pdfResponse.status}`)
200
- }
201
- const pdfBytes = toUint8(await pdfResponse.arrayBuffer())
202
- const pdfSignature = textDecoder.decode(pdfBytes.subarray(0, Math.min(8, pdfBytes.length)))
203
- if (!pdfSignature.startsWith("%PDF-")) {
204
- throw new Error("Resolved file is not a valid PDF")
205
- }
206
- return pdfBytes
207
- }
@@ -1,176 +0,0 @@
1
- import type { Env } from "./types"
2
- import type { EchoPdfConfig, EchoPdfProviderConfig } from "./pdf-types"
3
- import { resolveProviderApiKey } from "./provider-keys"
4
-
5
- const defaultBaseUrl = (provider: EchoPdfProviderConfig): string => {
6
- if (provider.baseUrl) return provider.baseUrl
7
- switch (provider.type) {
8
- case "openrouter":
9
- return "https://openrouter.ai/api/v1"
10
- case "vercel-ai-gateway":
11
- return "https://ai-gateway.vercel.sh/v1"
12
- case "openai":
13
- default:
14
- return "https://api.openai.com/v1"
15
- }
16
- }
17
-
18
- const noTrailingSlash = (url: string): string => url.replace(/\/+$/, "")
19
-
20
- const resolveEndpoint = (
21
- provider: EchoPdfProviderConfig,
22
- kind: "chatCompletionsPath" | "modelsPath"
23
- ): string => {
24
- const configured = provider.endpoints?.[kind]
25
- if (configured?.startsWith("http://") || configured?.startsWith("https://")) {
26
- return configured
27
- }
28
- const fallback = kind === "chatCompletionsPath" ? "/chat/completions" : "/models"
29
- const path = configured && configured.length > 0 ? configured : fallback
30
- return `${noTrailingSlash(defaultBaseUrl(provider))}${path.startsWith("/") ? path : `/${path}`}`
31
- }
32
-
33
- const toAuthHeader = (
34
- config: EchoPdfConfig,
35
- providerAlias: string,
36
- provider: EchoPdfProviderConfig,
37
- env: Env,
38
- runtimeApiKeys?: Record<string, string>
39
- ): Record<string, string> => {
40
- const token = resolveProviderApiKey({
41
- config,
42
- env,
43
- providerAlias,
44
- provider,
45
- runtimeApiKeys,
46
- })
47
- return { Authorization: `Bearer ${token}` }
48
- }
49
-
50
- const withTimeout = async (
51
- url: string,
52
- init: RequestInit,
53
- timeoutMs: number
54
- ): Promise<Response> => {
55
- const ctrl = new AbortController()
56
- const timer = setTimeout(() => ctrl.abort("timeout"), timeoutMs)
57
- try {
58
- return await fetch(url, { ...init, signal: ctrl.signal })
59
- } catch (error) {
60
- if (error instanceof Error && error.name === "AbortError") {
61
- throw new Error(`Request timeout after ${timeoutMs}ms for ${url}`)
62
- }
63
- throw error
64
- } finally {
65
- clearTimeout(timer)
66
- }
67
- }
68
-
69
- const responseDetail = async (response: Response): Promise<string> => {
70
- const contentType = response.headers.get("content-type") ?? ""
71
- try {
72
- if (contentType.includes("application/json")) {
73
- return JSON.stringify(await response.json()).slice(0, 800)
74
- }
75
- return (await response.text()).slice(0, 800)
76
- } catch {
77
- return "<unable to parse response payload>"
78
- }
79
- }
80
-
81
- const getProvider = (config: EchoPdfConfig, alias: string): EchoPdfProviderConfig => {
82
- const provider = config.providers[alias]
83
- if (!provider) {
84
- throw new Error(`Provider "${alias}" not configured`)
85
- }
86
- return provider
87
- }
88
-
89
- export const listProviderModels = async (
90
- config: EchoPdfConfig,
91
- env: Env,
92
- alias: string,
93
- runtimeApiKeys?: Record<string, string>
94
- ): Promise<ReadonlyArray<string>> => {
95
- const provider = getProvider(config, alias)
96
- const url = resolveEndpoint(provider, "modelsPath")
97
- const response = await withTimeout(
98
- url,
99
- {
100
- method: "GET",
101
- headers: {
102
- Accept: "application/json",
103
- ...toAuthHeader(config, alias, provider, env, runtimeApiKeys),
104
- ...(provider.headers ?? {}),
105
- },
106
- },
107
- provider.timeoutMs ?? 30000
108
- )
109
-
110
- if (!response.ok) {
111
- throw new Error(`Model list request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`)
112
- }
113
-
114
- const payload = await response.json()
115
- const data = (payload as { data?: unknown }).data
116
- if (!Array.isArray(data)) return []
117
- return data
118
- .map((item) => item as { id?: unknown })
119
- .map((item) => (typeof item.id === "string" ? item.id : ""))
120
- .filter((id) => id.length > 0)
121
- }
122
-
123
- export const visionRecognize = async (input: {
124
- config: EchoPdfConfig
125
- env: Env
126
- providerAlias: string
127
- model: string
128
- prompt: string
129
- imageDataUrl: string
130
- runtimeApiKeys?: Record<string, string>
131
- }): Promise<string> => {
132
- const provider = getProvider(input.config, input.providerAlias)
133
- const url = resolveEndpoint(provider, "chatCompletionsPath")
134
- const response = await withTimeout(
135
- url,
136
- {
137
- method: "POST",
138
- headers: {
139
- "Content-Type": "application/json",
140
- ...toAuthHeader(input.config, input.providerAlias, provider, input.env, input.runtimeApiKeys),
141
- ...(provider.headers ?? {}),
142
- },
143
- body: JSON.stringify({
144
- model: input.model,
145
- messages: [
146
- {
147
- role: "user",
148
- content: [
149
- { type: "text", text: input.prompt },
150
- { type: "image_url", image_url: { url: input.imageDataUrl } },
151
- ],
152
- },
153
- ],
154
- }),
155
- },
156
- provider.timeoutMs ?? 30000
157
- )
158
-
159
- if (!response.ok) {
160
- throw new Error(`Vision request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`)
161
- }
162
-
163
- const payload = await response.json()
164
- const message = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message
165
- if (!message) return ""
166
- const content = message.content
167
- if (typeof content === "string") return content
168
- if (Array.isArray(content)) {
169
- return content
170
- .map((part) => part as { type?: string; text?: string })
171
- .filter((part) => part.type === "text" && typeof part.text === "string")
172
- .map((part) => part.text ?? "")
173
- .join("")
174
- }
175
- return ""
176
- }
@@ -1,44 +0,0 @@
1
- import { readRequiredEnv } from "./pdf-config"
2
- import type { EchoPdfConfig, EchoPdfProviderConfig } from "./pdf-types"
3
- import type { Env } from "./types"
4
-
5
- const normalizeKey = (value: string): string => value.trim()
6
-
7
- const keyVariants = (value: string): string[] => {
8
- const raw = normalizeKey(value)
9
- if (raw.length === 0) return []
10
- return Array.from(
11
- new Set([
12
- raw,
13
- raw.replace(/-/g, "_"),
14
- raw.replace(/_/g, "-"),
15
- ])
16
- )
17
- }
18
-
19
- export const runtimeProviderKeyCandidates = (
20
- _config: EchoPdfConfig,
21
- providerAlias: string,
22
- provider: EchoPdfProviderConfig
23
- ): string[] => {
24
- const aliases = keyVariants(providerAlias)
25
- const types = keyVariants(provider.type)
26
- return Array.from(new Set([...aliases, ...types]))
27
- }
28
-
29
- export const resolveProviderApiKey = (input: {
30
- config: EchoPdfConfig
31
- env: Env
32
- providerAlias: string
33
- provider: EchoPdfProviderConfig
34
- runtimeApiKeys?: Record<string, string>
35
- }): string => {
36
- const candidates = runtimeProviderKeyCandidates(input.config, input.providerAlias, input.provider)
37
- for (const candidate of candidates) {
38
- const value = input.runtimeApiKeys?.[candidate]
39
- if (typeof value === "string" && value.trim().length > 0) {
40
- return value.trim()
41
- }
42
- }
43
- return readRequiredEnv(input.env, input.provider.apiKeyEnv)
44
- }
@@ -1,195 +0,0 @@
1
- import type { StoragePolicy } from "./pdf-types"
2
- import type { FileStore, StoredFileMeta, StoredFileRecord } from "./types"
3
-
4
- const PREFIX = "file/"
5
-
6
- type MetaFields = {
7
- filename?: string
8
- mimeType?: string
9
- createdAt?: string
10
- }
11
-
12
- const toId = (key: string): string => key.startsWith(PREFIX) ? key.slice(PREFIX.length) : key
13
- const toKey = (id: string): string => `${PREFIX}${id}`
14
-
15
- const parseCreatedAt = (value: string | undefined, fallback: Date): string => {
16
- if (typeof value === "string" && value.trim().length > 0) {
17
- const ms = Date.parse(value)
18
- if (Number.isFinite(ms)) return new Date(ms).toISOString()
19
- }
20
- return fallback.toISOString()
21
- }
22
-
23
- const isExpired = (createdAtIso: string, ttlHours: number): boolean => {
24
- const ms = Date.parse(createdAtIso)
25
- if (!Number.isFinite(ms)) return false
26
- return Date.now() - ms > ttlHours * 60 * 60 * 1000
27
- }
28
-
29
- export class R2FileStore implements FileStore {
30
- constructor(
31
- private readonly bucket: R2Bucket,
32
- private readonly policy: StoragePolicy
33
- ) {}
34
-
35
- async put(input: { readonly filename: string; readonly mimeType: string; readonly bytes: Uint8Array }): Promise<StoredFileMeta> {
36
- const sizeBytes = input.bytes.byteLength
37
- if (sizeBytes > this.policy.maxFileBytes) {
38
- const err = new Error(`file too large: ${sizeBytes} bytes exceeds maxFileBytes ${this.policy.maxFileBytes}`)
39
- ;(err as { status?: number; code?: string; details?: unknown }).status = 413
40
- ;(err as { status?: number; code?: string; details?: unknown }).code = "FILE_TOO_LARGE"
41
- ;(err as { status?: number; code?: string; details?: unknown }).details = { policy: this.policy, sizeBytes }
42
- throw err
43
- }
44
-
45
- await this.cleanupInternal(sizeBytes)
46
-
47
- const id = crypto.randomUUID()
48
- const createdAt = new Date().toISOString()
49
- await this.bucket.put(toKey(id), input.bytes, {
50
- httpMetadata: {
51
- contentType: input.mimeType,
52
- },
53
- customMetadata: {
54
- filename: input.filename,
55
- mimeType: input.mimeType,
56
- createdAt,
57
- },
58
- })
59
-
60
- return { id, filename: input.filename, mimeType: input.mimeType, sizeBytes, createdAt }
61
- }
62
-
63
- async get(fileId: string): Promise<StoredFileRecord | null> {
64
- const obj = await this.bucket.get(toKey(fileId))
65
- if (!obj) return null
66
- const meta = (obj.customMetadata ?? {}) as MetaFields
67
- const createdAt = parseCreatedAt(meta.createdAt, obj.uploaded)
68
- const filename = meta.filename ?? fileId
69
- const mimeType = meta.mimeType ?? obj.httpMetadata?.contentType ?? "application/octet-stream"
70
- const bytes = new Uint8Array(await obj.arrayBuffer())
71
- return {
72
- id: fileId,
73
- filename,
74
- mimeType,
75
- sizeBytes: bytes.byteLength,
76
- createdAt,
77
- bytes,
78
- }
79
- }
80
-
81
- async list(): Promise<ReadonlyArray<StoredFileMeta>> {
82
- return await this.listAllFiles()
83
- }
84
-
85
- async delete(fileId: string): Promise<boolean> {
86
- await this.bucket.delete(toKey(fileId))
87
- return true
88
- }
89
-
90
- async stats(): Promise<unknown> {
91
- const files = await this.listAllFiles()
92
- const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0)
93
- return {
94
- backend: "r2",
95
- policy: this.policy,
96
- stats: {
97
- fileCount: files.length,
98
- totalBytes,
99
- },
100
- }
101
- }
102
-
103
- async cleanup(): Promise<unknown> {
104
- const files = await this.listAllFiles()
105
- const expired = files.filter((f) => isExpired(f.createdAt, this.policy.ttlHours))
106
- const active = files.filter((f) => !isExpired(f.createdAt, this.policy.ttlHours))
107
- if (expired.length > 0) {
108
- await this.bucket.delete(expired.map((f) => toKey(f.id)))
109
- }
110
- const evict = this.pickEvictions(active, 0)
111
- if (evict.length > 0) {
112
- await this.bucket.delete(evict.map((f) => toKey(f.id)))
113
- }
114
- const evictIds = new Set(evict.map((f) => f.id))
115
- const after = active.filter((f) => !evictIds.has(f.id))
116
- const totalBytes = after.reduce((sum, file) => sum + file.sizeBytes, 0)
117
- return {
118
- backend: "r2",
119
- policy: this.policy,
120
- deletedExpired: expired.length,
121
- deletedEvicted: evict.length,
122
- stats: {
123
- fileCount: after.length,
124
- totalBytes,
125
- },
126
- }
127
- }
128
-
129
- private async cleanupInternal(incomingBytes: number): Promise<void> {
130
- const files = await this.listAllFiles()
131
- const expired = files.filter((f) => isExpired(f.createdAt, this.policy.ttlHours))
132
- const active = files.filter((f) => !isExpired(f.createdAt, this.policy.ttlHours))
133
- if (expired.length > 0) {
134
- await this.bucket.delete(expired.map((f) => toKey(f.id)))
135
- }
136
- const evict = this.pickEvictions(active, incomingBytes)
137
- if (evict.length > 0) {
138
- await this.bucket.delete(evict.map((f) => toKey(f.id)))
139
- }
140
- const evictIds = new Set(evict.map((f) => f.id))
141
- const remaining = active.filter((f) => !evictIds.has(f.id))
142
- const finalTotal = remaining.reduce((sum, file) => sum + file.sizeBytes, 0)
143
- if (finalTotal + incomingBytes > this.policy.maxTotalBytes) {
144
- const err = new Error(
145
- `storage quota exceeded: total ${finalTotal} + incoming ${incomingBytes} > maxTotalBytes ${this.policy.maxTotalBytes}`
146
- )
147
- ;(err as { status?: number; code?: string; details?: unknown }).status = 507
148
- ;(err as { status?: number; code?: string; details?: unknown }).code = "STORAGE_QUOTA_EXCEEDED"
149
- ;(err as { status?: number; code?: string; details?: unknown }).details = { policy: this.policy, totalBytes: finalTotal, incomingBytes }
150
- throw err
151
- }
152
- }
153
-
154
- private pickEvictions(files: ReadonlyArray<StoredFileMeta>, incomingBytes: number): StoredFileMeta[] {
155
- const totalBytes = files.reduce((sum, f) => sum + f.sizeBytes, 0)
156
- const projected = totalBytes + incomingBytes
157
- if (projected <= this.policy.maxTotalBytes) return []
158
-
159
- const needFree = projected - this.policy.maxTotalBytes
160
- const candidates = [...files].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
161
- const evict: StoredFileMeta[] = []
162
- let freed = 0
163
- for (const file of candidates) {
164
- evict.push(file)
165
- freed += file.sizeBytes
166
- if (freed >= needFree) break
167
- if (evict.length >= this.policy.cleanupBatchSize) break
168
- }
169
- return evict
170
- }
171
-
172
- private async listAllFiles(): Promise<StoredFileMeta[]> {
173
- const files: StoredFileMeta[] = []
174
- let cursor: string | undefined
175
- while (true) {
176
- const listed = await this.bucket.list({ prefix: PREFIX, limit: 1000, cursor })
177
- for (const obj of listed.objects) {
178
- const meta = (obj.customMetadata ?? {}) as MetaFields
179
- const createdAt = parseCreatedAt(meta.createdAt, obj.uploaded)
180
- const filename = meta.filename ?? toId(obj.key)
181
- const mimeType = meta.mimeType ?? obj.httpMetadata?.contentType ?? "application/octet-stream"
182
- files.push({
183
- id: toId(obj.key),
184
- filename,
185
- mimeType,
186
- sizeBytes: obj.size,
187
- createdAt,
188
- })
189
- }
190
- if (listed.truncated !== true || !listed.cursor) break
191
- cursor = listed.cursor
192
- }
193
- return files
194
- }
195
- }