@echofiles/echo-pdf 0.4.1 → 0.4.2
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/README.md +75 -0
- package/dist/agent-defaults.d.ts +3 -0
- package/dist/agent-defaults.js +18 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +24 -0
- package/dist/core/index.d.ts +50 -0
- package/dist/core/index.js +7 -0
- package/dist/file-ops.d.ts +11 -0
- package/dist/file-ops.js +36 -0
- package/dist/file-store-do.d.ts +36 -0
- package/dist/file-store-do.js +298 -0
- package/dist/file-utils.d.ts +6 -0
- package/dist/file-utils.js +36 -0
- package/dist/http-error.d.ts +9 -0
- package/dist/http-error.js +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.js +127 -0
- package/dist/pdf-agent.d.ts +18 -0
- package/dist/pdf-agent.js +217 -0
- package/dist/pdf-config.d.ts +4 -0
- package/dist/pdf-config.js +130 -0
- package/dist/pdf-storage.d.ts +8 -0
- package/dist/pdf-storage.js +86 -0
- package/dist/pdf-types.d.ts +79 -0
- package/dist/pdf-types.js +1 -0
- package/dist/pdfium-engine.d.ts +9 -0
- package/dist/pdfium-engine.js +180 -0
- package/dist/provider-client.d.ts +12 -0
- package/dist/provider-client.js +134 -0
- package/dist/provider-keys.d.ts +10 -0
- package/dist/provider-keys.js +27 -0
- package/dist/r2-file-store.d.ts +20 -0
- package/dist/r2-file-store.js +176 -0
- package/dist/response-schema.d.ts +15 -0
- package/dist/response-schema.js +159 -0
- package/dist/tool-registry.d.ts +16 -0
- package/dist/tool-registry.js +175 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/worker.d.ts +7 -0
- package/dist/worker.js +366 -0
- package/package.json +22 -4
- package/wrangler.toml +1 -1
- package/src/agent-defaults.ts +0 -25
- package/src/file-ops.ts +0 -50
- package/src/file-store-do.ts +0 -349
- package/src/file-utils.ts +0 -43
- package/src/http-error.ts +0 -21
- package/src/index.ts +0 -415
- package/src/mcp-server.ts +0 -171
- package/src/pdf-agent.ts +0 -252
- package/src/pdf-config.ts +0 -143
- package/src/pdf-storage.ts +0 -109
- package/src/pdf-types.ts +0 -85
- package/src/pdfium-engine.ts +0 -207
- package/src/provider-client.ts +0 -176
- package/src/provider-keys.ts +0 -44
- package/src/r2-file-store.ts +0 -195
- package/src/response-schema.ts +0 -182
- package/src/tool-registry.ts +0 -203
- package/src/types.ts +0 -40
- package/src/wasm.d.ts +0 -4
package/README.md
CHANGED
|
@@ -13,6 +13,80 @@
|
|
|
13
13
|
- CLI
|
|
14
14
|
- HTTP API
|
|
15
15
|
|
|
16
|
+
## Using echo-pdf as a library
|
|
17
|
+
|
|
18
|
+
`@echofiles/echo-pdf` 支持直接作为库导入,面向下游复用 `pdf_extract_pages / pdf_ocr_pages / pdf_tables_to_latex / file_ops` 工具实现。
|
|
19
|
+
|
|
20
|
+
### Public entrypoints(semver 稳定)
|
|
21
|
+
|
|
22
|
+
- `@echofiles/echo-pdf`:core API(推荐)
|
|
23
|
+
- `@echofiles/echo-pdf/core`:与根入口等价的 core API
|
|
24
|
+
- `@echofiles/echo-pdf/worker`:Worker 路由入口(给 Wrangler/Worker 集成用)
|
|
25
|
+
|
|
26
|
+
仅以上 `exports` 子路径视为公开 API。`src/*`、`dist/*` 等深路径导入不受兼容性承诺保护,可能在次版本中变动。
|
|
27
|
+
|
|
28
|
+
### Runtime expectations
|
|
29
|
+
|
|
30
|
+
- Node.js: `>=20`(与 `package.json#engines` 一致)
|
|
31
|
+
- 需要 ESM `import` 能力与标准 `fetch`(Node 20+ 原生支持)
|
|
32
|
+
- 建议使用支持 package `exports` 的现代 bundler/runtime(Vite、Webpack 5、Rspack、esbuild、Wrangler 等)
|
|
33
|
+
- TypeScript 消费方建议:`module=NodeNext` + `moduleResolution=NodeNext`
|
|
34
|
+
|
|
35
|
+
### Clean project import smoke
|
|
36
|
+
|
|
37
|
+
下面这段命令与仓库中的集成测试保持一致,可在全新目录验证 npm 包“可直接 import”:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
tmpdir="$(mktemp -d)"
|
|
41
|
+
cd "$tmpdir"
|
|
42
|
+
npm init -y
|
|
43
|
+
npm i /path/to/echofiles-echo-pdf-<version>.tgz
|
|
44
|
+
node --input-type=module -e "await import('@echofiles/echo-pdf'); await import('@echofiles/echo-pdf/core'); await import('@echofiles/echo-pdf/worker'); console.log('ok')"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Example
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { callTool, listToolSchemas } from "@echofiles/echo-pdf"
|
|
51
|
+
import configJson from "./echo-pdf.config.json" with { type: "json" }
|
|
52
|
+
|
|
53
|
+
const fileStore = {
|
|
54
|
+
async put(input) {
|
|
55
|
+
const id = crypto.randomUUID()
|
|
56
|
+
const record = { ...input, id, sizeBytes: input.bytes.byteLength, createdAt: new Date().toISOString() }
|
|
57
|
+
memory.set(id, record)
|
|
58
|
+
return record
|
|
59
|
+
},
|
|
60
|
+
async get(id) {
|
|
61
|
+
return memory.get(id) ?? null
|
|
62
|
+
},
|
|
63
|
+
async list() {
|
|
64
|
+
return [...memory.values()]
|
|
65
|
+
},
|
|
66
|
+
async delete(id) {
|
|
67
|
+
return memory.delete(id)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const memory = new Map()
|
|
72
|
+
const env = {}
|
|
73
|
+
|
|
74
|
+
console.log(listToolSchemas().map((tool) => tool.name))
|
|
75
|
+
|
|
76
|
+
const result = await callTool(
|
|
77
|
+
"pdf_extract_pages",
|
|
78
|
+
{ fileId: "<FILE_ID>", pages: [1], returnMode: "inline" },
|
|
79
|
+
{ config: configJson, env, fileStore }
|
|
80
|
+
)
|
|
81
|
+
console.log(result)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
版本策略:
|
|
85
|
+
|
|
86
|
+
- `exports` 列出的入口及其导出符号按 semver 管理
|
|
87
|
+
- 对公开 API 的破坏性变更只会在 major 版本发布
|
|
88
|
+
- 新增导出、参数扩展(向后兼容)会在 minor/patch 发布
|
|
89
|
+
|
|
16
90
|
## 1. 服务地址
|
|
17
91
|
|
|
18
92
|
请先确定你的线上地址(Worker 域名)。文档里用:
|
|
@@ -287,6 +361,7 @@ curl -sS -X POST https://echo-pdf.echofilesai.workers.dev/tools/call \
|
|
|
287
361
|
鉴权注意:
|
|
288
362
|
|
|
289
363
|
- 如果配置了 `authHeader/authEnv` 但未注入对应 secret,服务会返回配置错误(fail-closed),不会默认放行。
|
|
364
|
+
- 仅开发调试场景可显式设置 `ECHO_PDF_ALLOW_MISSING_AUTH_SECRET=1` 临时放行“缺 secret”的请求。
|
|
290
365
|
|
|
291
366
|
## 7. 本地开发与测试
|
|
292
367
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { EchoPdfConfig } from "./pdf-types.js";
|
|
2
|
+
export declare const resolveProviderAlias: (config: EchoPdfConfig, requestedProvider?: string) => string;
|
|
3
|
+
export declare const resolveModelForProvider: (config: EchoPdfConfig, _providerAlias: string, requestedModel?: string) => string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const normalize = (value) => value.trim();
|
|
2
|
+
export const resolveProviderAlias = (config, requestedProvider) => {
|
|
3
|
+
const raw = normalize(requestedProvider ?? "");
|
|
4
|
+
if (raw.length === 0)
|
|
5
|
+
return config.agent.defaultProvider;
|
|
6
|
+
if (config.providers[raw])
|
|
7
|
+
return raw;
|
|
8
|
+
const fromType = Object.entries(config.providers).find(([, provider]) => provider.type === raw)?.[0];
|
|
9
|
+
if (fromType)
|
|
10
|
+
return fromType;
|
|
11
|
+
throw new Error(`Provider "${raw}" not configured`);
|
|
12
|
+
};
|
|
13
|
+
export const resolveModelForProvider = (config, _providerAlias, requestedModel) => {
|
|
14
|
+
const explicit = normalize(requestedModel ?? "");
|
|
15
|
+
if (explicit.length > 0)
|
|
16
|
+
return explicit;
|
|
17
|
+
return normalize(config.agent.defaultModel ?? "");
|
|
18
|
+
};
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Env } from "./types.js";
|
|
2
|
+
export interface AuthCheckOptions {
|
|
3
|
+
readonly authHeader?: string;
|
|
4
|
+
readonly authEnv?: string;
|
|
5
|
+
readonly allowMissingSecret?: boolean;
|
|
6
|
+
readonly misconfiguredCode: string;
|
|
7
|
+
readonly unauthorizedCode: string;
|
|
8
|
+
readonly contextName: string;
|
|
9
|
+
}
|
|
10
|
+
export type AuthCheckResult = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
} | {
|
|
13
|
+
readonly ok: false;
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly code: string;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const checkHeaderAuth: (request: Request, env: Env, options: AuthCheckOptions) => AuthCheckResult;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const checkHeaderAuth = (request, env, options) => {
|
|
2
|
+
if (!options.authHeader || !options.authEnv)
|
|
3
|
+
return { ok: true };
|
|
4
|
+
const required = env[options.authEnv];
|
|
5
|
+
if (typeof required !== "string" || required.length === 0) {
|
|
6
|
+
if (options.allowMissingSecret === true)
|
|
7
|
+
return { ok: true };
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
status: 500,
|
|
11
|
+
code: options.misconfiguredCode,
|
|
12
|
+
message: `${options.contextName} auth is configured but env "${options.authEnv}" is missing`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (request.headers.get(options.authHeader) !== required) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
status: 401,
|
|
19
|
+
code: options.unauthorizedCode,
|
|
20
|
+
message: "Unauthorized",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { ok: true };
|
|
24
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export { callTool, listToolSchemas } from "../tool-registry.js";
|
|
2
|
+
export type { ToolRuntimeContext } from "../tool-registry.js";
|
|
3
|
+
export type { ToolSchema } from "../pdf-types.js";
|
|
4
|
+
export type { Env, FileStore, JsonObject } from "../types.js";
|
|
5
|
+
import type { ReturnMode } from "../types.js";
|
|
6
|
+
export interface PdfExtractPagesArgs {
|
|
7
|
+
readonly fileId?: string;
|
|
8
|
+
readonly url?: string;
|
|
9
|
+
readonly base64?: string;
|
|
10
|
+
readonly filename?: string;
|
|
11
|
+
readonly pages: ReadonlyArray<number>;
|
|
12
|
+
readonly renderScale?: number;
|
|
13
|
+
readonly returnMode?: ReturnMode;
|
|
14
|
+
}
|
|
15
|
+
export interface PdfOcrPagesArgs {
|
|
16
|
+
readonly fileId?: string;
|
|
17
|
+
readonly url?: string;
|
|
18
|
+
readonly base64?: string;
|
|
19
|
+
readonly filename?: string;
|
|
20
|
+
readonly pages: ReadonlyArray<number>;
|
|
21
|
+
readonly renderScale?: number;
|
|
22
|
+
readonly provider?: string;
|
|
23
|
+
readonly model?: string;
|
|
24
|
+
readonly prompt?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PdfTablesToLatexArgs {
|
|
27
|
+
readonly fileId?: string;
|
|
28
|
+
readonly url?: string;
|
|
29
|
+
readonly base64?: string;
|
|
30
|
+
readonly filename?: string;
|
|
31
|
+
readonly pages: ReadonlyArray<number>;
|
|
32
|
+
readonly renderScale?: number;
|
|
33
|
+
readonly provider?: string;
|
|
34
|
+
readonly model?: string;
|
|
35
|
+
readonly prompt?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface FileOpsArgs {
|
|
38
|
+
readonly op: "list" | "read" | "delete" | "put";
|
|
39
|
+
readonly fileId?: string;
|
|
40
|
+
readonly includeBase64?: boolean;
|
|
41
|
+
readonly text?: string;
|
|
42
|
+
readonly filename?: string;
|
|
43
|
+
readonly mimeType?: string;
|
|
44
|
+
readonly base64?: string;
|
|
45
|
+
readonly returnMode?: ReturnMode;
|
|
46
|
+
}
|
|
47
|
+
export declare const pdf_extract_pages: (args: PdfExtractPagesArgs, ctx: import("../tool-registry.js").ToolRuntimeContext) => Promise<unknown>;
|
|
48
|
+
export declare const pdf_ocr_pages: (args: PdfOcrPagesArgs, ctx: import("../tool-registry.js").ToolRuntimeContext) => Promise<unknown>;
|
|
49
|
+
export declare const pdf_tables_to_latex: (args: PdfTablesToLatexArgs, ctx: import("../tool-registry.js").ToolRuntimeContext) => Promise<unknown>;
|
|
50
|
+
export declare const file_ops: (args: FileOpsArgs, ctx: import("../tool-registry.js").ToolRuntimeContext) => Promise<unknown>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { callTool, listToolSchemas } from "../tool-registry.js";
|
|
2
|
+
import { callTool } from "../tool-registry.js";
|
|
3
|
+
const asJsonObject = (value) => value;
|
|
4
|
+
export const pdf_extract_pages = async (args, ctx) => callTool("pdf_extract_pages", asJsonObject(args), ctx);
|
|
5
|
+
export const pdf_ocr_pages = async (args, ctx) => callTool("pdf_ocr_pages", asJsonObject(args), ctx);
|
|
6
|
+
export const pdf_tables_to_latex = async (args, ctx) => callTool("pdf_tables_to_latex", asJsonObject(args), ctx);
|
|
7
|
+
export const file_ops = async (args, ctx) => callTool("file_ops", asJsonObject(args), ctx);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FileStore, ReturnMode } from "./types.js";
|
|
2
|
+
export declare const runFileOp: (fileStore: FileStore, input: {
|
|
3
|
+
readonly op: "list" | "read" | "delete" | "put";
|
|
4
|
+
readonly fileId?: string;
|
|
5
|
+
readonly includeBase64?: boolean;
|
|
6
|
+
readonly text?: string;
|
|
7
|
+
readonly filename?: string;
|
|
8
|
+
readonly mimeType?: string;
|
|
9
|
+
readonly base64?: string;
|
|
10
|
+
readonly returnMode?: ReturnMode;
|
|
11
|
+
}) => Promise<unknown>;
|
package/dist/file-ops.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fromBase64, normalizeReturnMode, toInlineFilePayload } from "./file-utils.js";
|
|
2
|
+
export const runFileOp = async (fileStore, input) => {
|
|
3
|
+
if (input.op === "list") {
|
|
4
|
+
return { files: await fileStore.list() };
|
|
5
|
+
}
|
|
6
|
+
if (input.op === "put") {
|
|
7
|
+
const bytes = input.base64 ? fromBase64(input.base64) : new TextEncoder().encode(input.text ?? "");
|
|
8
|
+
const meta = await fileStore.put({
|
|
9
|
+
filename: input.filename ?? `file-${Date.now()}.txt`,
|
|
10
|
+
mimeType: input.mimeType ?? "text/plain; charset=utf-8",
|
|
11
|
+
bytes,
|
|
12
|
+
});
|
|
13
|
+
const returnMode = normalizeReturnMode(input.returnMode);
|
|
14
|
+
if (returnMode === "file_id")
|
|
15
|
+
return { returnMode, file: meta };
|
|
16
|
+
if (returnMode === "url")
|
|
17
|
+
return { returnMode, file: meta, url: `/api/files/get?fileId=${encodeURIComponent(meta.id)}` };
|
|
18
|
+
const stored = await fileStore.get(meta.id);
|
|
19
|
+
if (!stored)
|
|
20
|
+
throw new Error(`File not found after put: ${meta.id}`);
|
|
21
|
+
return {
|
|
22
|
+
returnMode,
|
|
23
|
+
...toInlineFilePayload(stored, true),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (!input.fileId) {
|
|
27
|
+
throw new Error("fileId is required");
|
|
28
|
+
}
|
|
29
|
+
if (input.op === "delete") {
|
|
30
|
+
return { deleted: await fileStore.delete(input.fileId), fileId: input.fileId };
|
|
31
|
+
}
|
|
32
|
+
const file = await fileStore.get(input.fileId);
|
|
33
|
+
if (!file)
|
|
34
|
+
throw new Error(`File not found: ${input.fileId}`);
|
|
35
|
+
return toInlineFilePayload(file, Boolean(input.includeBase64));
|
|
36
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { StoragePolicy } from "./pdf-types.js";
|
|
2
|
+
import type { DurableObjectNamespace, DurableObjectState, StoredFileMeta, StoredFileRecord } from "./types.js";
|
|
3
|
+
interface StoreStats {
|
|
4
|
+
readonly fileCount: number;
|
|
5
|
+
readonly totalBytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class FileStoreDO {
|
|
8
|
+
private readonly state;
|
|
9
|
+
constructor(state: DurableObjectState);
|
|
10
|
+
fetch(request: Request): Promise<Response>;
|
|
11
|
+
}
|
|
12
|
+
export declare class DurableObjectFileStore {
|
|
13
|
+
private readonly namespace;
|
|
14
|
+
private readonly policy;
|
|
15
|
+
constructor(namespace: DurableObjectNamespace, policy: StoragePolicy);
|
|
16
|
+
private stub;
|
|
17
|
+
put(input: {
|
|
18
|
+
readonly filename: string;
|
|
19
|
+
readonly mimeType: string;
|
|
20
|
+
readonly bytes: Uint8Array;
|
|
21
|
+
}): Promise<StoredFileMeta>;
|
|
22
|
+
get(fileId: string): Promise<StoredFileRecord | null>;
|
|
23
|
+
list(): Promise<ReadonlyArray<StoredFileMeta>>;
|
|
24
|
+
delete(fileId: string): Promise<boolean>;
|
|
25
|
+
stats(): Promise<{
|
|
26
|
+
policy: StoragePolicy;
|
|
27
|
+
stats: StoreStats;
|
|
28
|
+
}>;
|
|
29
|
+
cleanup(): Promise<{
|
|
30
|
+
policy: StoragePolicy;
|
|
31
|
+
deletedExpired: number;
|
|
32
|
+
deletedEvicted: number;
|
|
33
|
+
stats: StoreStats;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { fromBase64, toBase64 } from "./file-utils.js";
|
|
2
|
+
const json = (data, status = 200) => new Response(JSON.stringify(data), {
|
|
3
|
+
status,
|
|
4
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
5
|
+
});
|
|
6
|
+
const readJson = async (request) => {
|
|
7
|
+
try {
|
|
8
|
+
const body = await request.json();
|
|
9
|
+
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
|
|
10
|
+
return body;
|
|
11
|
+
}
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const defaultPolicy = () => ({
|
|
19
|
+
maxFileBytes: 1_200_000,
|
|
20
|
+
maxTotalBytes: 52_428_800,
|
|
21
|
+
ttlHours: 24,
|
|
22
|
+
cleanupBatchSize: 50,
|
|
23
|
+
});
|
|
24
|
+
const parsePolicy = (input) => {
|
|
25
|
+
const raw = typeof input === "object" && input !== null && !Array.isArray(input)
|
|
26
|
+
? input
|
|
27
|
+
: {};
|
|
28
|
+
const fallback = defaultPolicy();
|
|
29
|
+
const maxFileBytes = Number(raw.maxFileBytes ?? fallback.maxFileBytes);
|
|
30
|
+
const maxTotalBytes = Number(raw.maxTotalBytes ?? fallback.maxTotalBytes);
|
|
31
|
+
const ttlHours = Number(raw.ttlHours ?? fallback.ttlHours);
|
|
32
|
+
const cleanupBatchSize = Number(raw.cleanupBatchSize ?? fallback.cleanupBatchSize);
|
|
33
|
+
return {
|
|
34
|
+
maxFileBytes: Number.isFinite(maxFileBytes) && maxFileBytes > 0 ? Math.floor(maxFileBytes) : fallback.maxFileBytes,
|
|
35
|
+
maxTotalBytes: Number.isFinite(maxTotalBytes) && maxTotalBytes > 0 ? Math.floor(maxTotalBytes) : fallback.maxTotalBytes,
|
|
36
|
+
ttlHours: Number.isFinite(ttlHours) && ttlHours > 0 ? ttlHours : fallback.ttlHours,
|
|
37
|
+
cleanupBatchSize: Number.isFinite(cleanupBatchSize) && cleanupBatchSize > 0 ? Math.floor(cleanupBatchSize) : fallback.cleanupBatchSize,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
const toMeta = (value) => ({
|
|
41
|
+
id: value.id,
|
|
42
|
+
filename: value.filename,
|
|
43
|
+
mimeType: value.mimeType,
|
|
44
|
+
sizeBytes: value.sizeBytes,
|
|
45
|
+
createdAt: value.createdAt,
|
|
46
|
+
});
|
|
47
|
+
const listStoredValues = async (state) => {
|
|
48
|
+
const listed = await state.storage.list({ prefix: "file:" });
|
|
49
|
+
return [...listed.values()];
|
|
50
|
+
};
|
|
51
|
+
const computeStats = (files) => ({
|
|
52
|
+
fileCount: files.length,
|
|
53
|
+
totalBytes: files.reduce((sum, file) => sum + file.sizeBytes, 0),
|
|
54
|
+
});
|
|
55
|
+
const isExpired = (createdAt, ttlHours) => {
|
|
56
|
+
const createdMs = Date.parse(createdAt);
|
|
57
|
+
if (!Number.isFinite(createdMs))
|
|
58
|
+
return false;
|
|
59
|
+
return Date.now() - createdMs > ttlHours * 60 * 60 * 1000;
|
|
60
|
+
};
|
|
61
|
+
const deleteFiles = async (state, files) => {
|
|
62
|
+
let deleted = 0;
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const ok = await state.storage.delete(`file:${file.id}`);
|
|
65
|
+
if (ok)
|
|
66
|
+
deleted += 1;
|
|
67
|
+
}
|
|
68
|
+
return deleted;
|
|
69
|
+
};
|
|
70
|
+
export class FileStoreDO {
|
|
71
|
+
state;
|
|
72
|
+
constructor(state) {
|
|
73
|
+
this.state = state;
|
|
74
|
+
}
|
|
75
|
+
async fetch(request) {
|
|
76
|
+
const url = new URL(request.url);
|
|
77
|
+
if (request.method === "POST" && url.pathname === "/put") {
|
|
78
|
+
const body = await readJson(request);
|
|
79
|
+
const policy = parsePolicy(body.policy);
|
|
80
|
+
const filename = typeof body.filename === "string" ? body.filename : `file-${Date.now()}`;
|
|
81
|
+
const mimeType = typeof body.mimeType === "string" ? body.mimeType : "application/octet-stream";
|
|
82
|
+
const bytesBase64 = typeof body.bytesBase64 === "string" ? body.bytesBase64 : "";
|
|
83
|
+
const bytes = fromBase64(bytesBase64);
|
|
84
|
+
if (bytes.byteLength > policy.maxFileBytes) {
|
|
85
|
+
return json({
|
|
86
|
+
error: `file too large: ${bytes.byteLength} bytes exceeds maxFileBytes ${policy.maxFileBytes}`,
|
|
87
|
+
code: "FILE_TOO_LARGE",
|
|
88
|
+
policy,
|
|
89
|
+
}, 413);
|
|
90
|
+
}
|
|
91
|
+
let files = await listStoredValues(this.state);
|
|
92
|
+
const expired = files.filter((file) => isExpired(file.createdAt, policy.ttlHours));
|
|
93
|
+
if (expired.length > 0) {
|
|
94
|
+
await deleteFiles(this.state, expired);
|
|
95
|
+
files = await listStoredValues(this.state);
|
|
96
|
+
}
|
|
97
|
+
let stats = computeStats(files);
|
|
98
|
+
const projected = stats.totalBytes + bytes.byteLength;
|
|
99
|
+
if (projected > policy.maxTotalBytes) {
|
|
100
|
+
const needFree = projected - policy.maxTotalBytes;
|
|
101
|
+
const candidates = [...files]
|
|
102
|
+
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
|
|
103
|
+
.slice(0, policy.cleanupBatchSize);
|
|
104
|
+
let freed = 0;
|
|
105
|
+
const evictList = [];
|
|
106
|
+
for (const file of candidates) {
|
|
107
|
+
evictList.push(file);
|
|
108
|
+
freed += file.sizeBytes;
|
|
109
|
+
if (freed >= needFree)
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if (evictList.length > 0) {
|
|
113
|
+
await deleteFiles(this.state, evictList);
|
|
114
|
+
files = await listStoredValues(this.state);
|
|
115
|
+
stats = computeStats(files);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (stats.totalBytes + bytes.byteLength > policy.maxTotalBytes) {
|
|
119
|
+
return json({
|
|
120
|
+
error: `storage quota exceeded: total ${stats.totalBytes} + incoming ${bytes.byteLength} > maxTotalBytes ${policy.maxTotalBytes}`,
|
|
121
|
+
code: "STORAGE_QUOTA_EXCEEDED",
|
|
122
|
+
policy,
|
|
123
|
+
stats,
|
|
124
|
+
}, 507);
|
|
125
|
+
}
|
|
126
|
+
const id = crypto.randomUUID();
|
|
127
|
+
const value = {
|
|
128
|
+
id,
|
|
129
|
+
filename,
|
|
130
|
+
mimeType,
|
|
131
|
+
sizeBytes: bytes.byteLength,
|
|
132
|
+
createdAt: new Date().toISOString(),
|
|
133
|
+
bytesBase64,
|
|
134
|
+
};
|
|
135
|
+
await this.state.storage.put(`file:${id}`, value);
|
|
136
|
+
return json({ file: toMeta(value), policy });
|
|
137
|
+
}
|
|
138
|
+
if (request.method === "GET" && url.pathname === "/get") {
|
|
139
|
+
const fileId = url.searchParams.get("fileId");
|
|
140
|
+
if (!fileId)
|
|
141
|
+
return json({ error: "Missing fileId" }, 400);
|
|
142
|
+
const value = await this.state.storage.get(`file:${fileId}`);
|
|
143
|
+
if (!value)
|
|
144
|
+
return json({ file: null });
|
|
145
|
+
return json({ file: value });
|
|
146
|
+
}
|
|
147
|
+
if (request.method === "GET" && url.pathname === "/list") {
|
|
148
|
+
const listed = await this.state.storage.list({ prefix: "file:" });
|
|
149
|
+
const files = [...listed.values()].map(toMeta);
|
|
150
|
+
return json({ files });
|
|
151
|
+
}
|
|
152
|
+
if (request.method === "POST" && url.pathname === "/delete") {
|
|
153
|
+
const body = await readJson(request);
|
|
154
|
+
const fileId = typeof body.fileId === "string" ? body.fileId : "";
|
|
155
|
+
if (!fileId)
|
|
156
|
+
return json({ error: "Missing fileId" }, 400);
|
|
157
|
+
const key = `file:${fileId}`;
|
|
158
|
+
const existing = await this.state.storage.get(key);
|
|
159
|
+
if (!existing)
|
|
160
|
+
return json({ deleted: false });
|
|
161
|
+
await this.state.storage.delete(key);
|
|
162
|
+
return json({ deleted: true });
|
|
163
|
+
}
|
|
164
|
+
if (request.method === "GET" && url.pathname === "/stats") {
|
|
165
|
+
let policyInput;
|
|
166
|
+
const encoded = url.searchParams.get("policy");
|
|
167
|
+
if (encoded) {
|
|
168
|
+
try {
|
|
169
|
+
policyInput = JSON.parse(encoded);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
policyInput = undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const policy = parsePolicy(policyInput);
|
|
176
|
+
const files = await listStoredValues(this.state);
|
|
177
|
+
const stats = computeStats(files);
|
|
178
|
+
return json({ policy, stats });
|
|
179
|
+
}
|
|
180
|
+
if (request.method === "POST" && url.pathname === "/cleanup") {
|
|
181
|
+
const body = await readJson(request);
|
|
182
|
+
const policy = parsePolicy(body.policy);
|
|
183
|
+
const files = await listStoredValues(this.state);
|
|
184
|
+
const expired = files.filter((file) => isExpired(file.createdAt, policy.ttlHours));
|
|
185
|
+
const deletedExpired = await deleteFiles(this.state, expired);
|
|
186
|
+
const afterExpired = await listStoredValues(this.state);
|
|
187
|
+
let stats = computeStats(afterExpired);
|
|
188
|
+
let deletedEvicted = 0;
|
|
189
|
+
if (stats.totalBytes > policy.maxTotalBytes) {
|
|
190
|
+
const sorted = [...afterExpired].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
191
|
+
const evictList = [];
|
|
192
|
+
for (const file of sorted) {
|
|
193
|
+
evictList.push(file);
|
|
194
|
+
const projected = stats.totalBytes - evictList.reduce((sum, item) => sum + item.sizeBytes, 0);
|
|
195
|
+
if (projected <= policy.maxTotalBytes)
|
|
196
|
+
break;
|
|
197
|
+
if (evictList.length >= policy.cleanupBatchSize)
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
deletedEvicted = await deleteFiles(this.state, evictList);
|
|
201
|
+
stats = computeStats(await listStoredValues(this.state));
|
|
202
|
+
}
|
|
203
|
+
return json({
|
|
204
|
+
policy,
|
|
205
|
+
deletedExpired,
|
|
206
|
+
deletedEvicted,
|
|
207
|
+
stats,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return json({ error: "Not found" }, 404);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export class DurableObjectFileStore {
|
|
214
|
+
namespace;
|
|
215
|
+
policy;
|
|
216
|
+
constructor(namespace, policy) {
|
|
217
|
+
this.namespace = namespace;
|
|
218
|
+
this.policy = policy;
|
|
219
|
+
}
|
|
220
|
+
stub() {
|
|
221
|
+
return this.namespace.get(this.namespace.idFromName("echo-pdf-file-store"));
|
|
222
|
+
}
|
|
223
|
+
async put(input) {
|
|
224
|
+
const response = await this.stub().fetch("https://do/put", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "Content-Type": "application/json" },
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
filename: input.filename,
|
|
229
|
+
mimeType: input.mimeType,
|
|
230
|
+
bytesBase64: toBase64(input.bytes),
|
|
231
|
+
policy: this.policy,
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
const payload = (await response.json());
|
|
235
|
+
if (!response.ok || !payload.file) {
|
|
236
|
+
const details = payload;
|
|
237
|
+
const error = new Error(payload.error ?? "DO put failed");
|
|
238
|
+
error.status = response.status;
|
|
239
|
+
error.code = typeof details.code === "string" ? details.code : undefined;
|
|
240
|
+
error.details = { policy: details.policy, stats: details.stats };
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
return payload.file;
|
|
244
|
+
}
|
|
245
|
+
async get(fileId) {
|
|
246
|
+
const response = await this.stub().fetch(`https://do/get?fileId=${encodeURIComponent(fileId)}`);
|
|
247
|
+
const payload = (await response.json());
|
|
248
|
+
if (!response.ok)
|
|
249
|
+
throw new Error("DO get failed");
|
|
250
|
+
if (!payload.file)
|
|
251
|
+
return null;
|
|
252
|
+
return {
|
|
253
|
+
id: payload.file.id,
|
|
254
|
+
filename: payload.file.filename,
|
|
255
|
+
mimeType: payload.file.mimeType,
|
|
256
|
+
sizeBytes: payload.file.sizeBytes,
|
|
257
|
+
createdAt: payload.file.createdAt,
|
|
258
|
+
bytes: fromBase64(payload.file.bytesBase64),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
async list() {
|
|
262
|
+
const response = await this.stub().fetch("https://do/list");
|
|
263
|
+
const payload = (await response.json());
|
|
264
|
+
if (!response.ok)
|
|
265
|
+
throw new Error("DO list failed");
|
|
266
|
+
return payload.files ?? [];
|
|
267
|
+
}
|
|
268
|
+
async delete(fileId) {
|
|
269
|
+
const response = await this.stub().fetch("https://do/delete", {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "Content-Type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ fileId }),
|
|
273
|
+
});
|
|
274
|
+
const payload = (await response.json());
|
|
275
|
+
if (!response.ok)
|
|
276
|
+
throw new Error("DO delete failed");
|
|
277
|
+
return payload.deleted === true;
|
|
278
|
+
}
|
|
279
|
+
async stats() {
|
|
280
|
+
const policyEncoded = encodeURIComponent(JSON.stringify(this.policy));
|
|
281
|
+
const response = await this.stub().fetch(`https://do/stats?policy=${policyEncoded}`);
|
|
282
|
+
const payload = (await response.json());
|
|
283
|
+
if (!response.ok)
|
|
284
|
+
throw new Error("DO stats failed");
|
|
285
|
+
return payload;
|
|
286
|
+
}
|
|
287
|
+
async cleanup() {
|
|
288
|
+
const response = await this.stub().fetch("https://do/cleanup", {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "Content-Type": "application/json" },
|
|
291
|
+
body: JSON.stringify({ policy: this.policy }),
|
|
292
|
+
});
|
|
293
|
+
const payload = (await response.json());
|
|
294
|
+
if (!response.ok)
|
|
295
|
+
throw new Error("DO cleanup failed");
|
|
296
|
+
return payload;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ReturnMode, StoredFileRecord } from "./types.js";
|
|
2
|
+
export declare const fromBase64: (value: string) => Uint8Array;
|
|
3
|
+
export declare const toBase64: (bytes: Uint8Array) => string;
|
|
4
|
+
export declare const toDataUrl: (bytes: Uint8Array, mimeType: string) => string;
|
|
5
|
+
export declare const normalizeReturnMode: (value: unknown) => ReturnMode;
|
|
6
|
+
export declare const toInlineFilePayload: (file: StoredFileRecord, includeBase64: boolean) => Record<string, unknown>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const fromBase64 = (value) => {
|
|
2
|
+
const raw = atob(value.replace(/^data:.*;base64,/, ""));
|
|
3
|
+
const out = new Uint8Array(raw.length);
|
|
4
|
+
for (let i = 0; i < raw.length; i++) {
|
|
5
|
+
out[i] = raw.charCodeAt(i);
|
|
6
|
+
}
|
|
7
|
+
return out;
|
|
8
|
+
};
|
|
9
|
+
export const toBase64 = (bytes) => {
|
|
10
|
+
let binary = "";
|
|
11
|
+
const chunkSize = 0x8000;
|
|
12
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
13
|
+
const chunk = bytes.subarray(i, i + chunkSize);
|
|
14
|
+
binary += String.fromCharCode(...chunk);
|
|
15
|
+
}
|
|
16
|
+
return btoa(binary);
|
|
17
|
+
};
|
|
18
|
+
export const toDataUrl = (bytes, mimeType) => `data:${mimeType};base64,${toBase64(bytes)}`;
|
|
19
|
+
export const normalizeReturnMode = (value) => {
|
|
20
|
+
if (value === "file_id" || value === "url" || value === "inline") {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
return "inline";
|
|
24
|
+
};
|
|
25
|
+
export const toInlineFilePayload = (file, includeBase64) => ({
|
|
26
|
+
file: {
|
|
27
|
+
id: file.id,
|
|
28
|
+
filename: file.filename,
|
|
29
|
+
mimeType: file.mimeType,
|
|
30
|
+
sizeBytes: file.sizeBytes,
|
|
31
|
+
createdAt: file.createdAt,
|
|
32
|
+
},
|
|
33
|
+
dataUrl: file.mimeType.startsWith("image/") ? toDataUrl(file.bytes, file.mimeType) : undefined,
|
|
34
|
+
base64: includeBase64 ? toBase64(file.bytes) : undefined,
|
|
35
|
+
text: file.mimeType.startsWith("text/") ? new TextDecoder().decode(file.bytes) : undefined,
|
|
36
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare class HttpError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly details?: unknown;
|
|
5
|
+
constructor(status: number, code: string, message: string, details?: unknown);
|
|
6
|
+
}
|
|
7
|
+
export declare const badRequest: (code: string, message: string, details?: unknown) => HttpError;
|
|
8
|
+
export declare const notFound: (code: string, message: string, details?: unknown) => HttpError;
|
|
9
|
+
export declare const unprocessable: (code: string, message: string, details?: unknown) => HttpError;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(status, code, message, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.details = details;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const badRequest = (code, message, details) => new HttpError(400, code, message, details);
|
|
13
|
+
export const notFound = (code, message, details) => new HttpError(404, code, message, details);
|
|
14
|
+
export const unprocessable = (code, message, details) => new HttpError(422, code, message, details);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core/index.js";
|