@homurajs/storage 0.0.0-stage → 26.10.13

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 CHANGED
@@ -1,3 +1,26 @@
1
- # Temporary Holding Version
1
+ # @homurajs/storage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 对象存储/图床:三后端(local-fs / s3 兼容 / supabase)+ `/files/<key>` 代理路由。
4
+
5
+ ```ts
6
+ import { createLocalStorage } from '@homurajs/storage/local' // 独立形态
7
+ // import { createS3Storage } from '@homurajs/storage/s3' // minio/oss(AWS SDK 动态加载)
8
+ // import { createSupabaseStorage } from '@homurajs/storage/supabase' // qw-pages(手写 REST)
9
+
10
+ const storage = createLocalStorage({ rootDir: './data/files' })
11
+
12
+ // 上传:ns 前缀自动 key;返回代理 URL
13
+ const r = await storage.uploadFile('imgs', 'cat.png', bytes, 'image/png')
14
+ // r.objectKey = 'imgs/files/1760..._cat.png',r.url = '/files/imgs/files/...'
15
+
16
+ // 下载 / 删除 / 列举(列举强制 ns 前缀,无法越权)
17
+ const d = await storage.downloadFile(r.objectKey)
18
+ await storage.deleteFile(r.objectKey)
19
+ const files = await storage.listFiles('imgs', '', 20)
20
+
21
+ // 代理路由(Hono):统一下载入口,presign 弱后端自动降级到它
22
+ import { createFileProxyRoutes } from '@homurajs/storage/proxy'
23
+ app.route('/', createFileProxyRoutes(storage))
24
+ ```
25
+
26
+ 使用示范见模板 `src/server/routes/files.ts`。
@@ -0,0 +1,42 @@
1
+ //#region src/index.d.ts
2
+ /**
3
+ * 文件存储契约(自 Suzuran storage 适配器平移):
4
+ * - 租户/命名空间隔离编码在 key 前缀
5
+ * - URL 统一走代理路径(/files/<key>),不直接暴露存储地址
6
+ * - presign 优雅降级:弱后端返回代理路径
7
+ */
8
+ export interface UploadResult {
9
+ objectKey: string;
10
+ /** 平台相对代理路径(/files/<objectKey>) */
11
+ url: string;
12
+ size: number;
13
+ uploadedAt: Date;
14
+ }
15
+ export interface FileInfo {
16
+ objectKey: string;
17
+ size: number;
18
+ updatedAt: Date;
19
+ contentType?: string;
20
+ }
21
+ export interface FileStorage {
22
+ /** 业务上传:自动生成 `<ns>/files/<unixNano>_<fileName>` 前缀 key */
23
+ uploadFile(ns: string, fileName: string, data: FilePayload, contentType: string): Promise<UploadResult>;
24
+ /** 显式 key 上传(如 code.zip 等精确控制场景) */
25
+ uploadFileRaw(objectKey: string, data: FilePayload, contentType: string): Promise<UploadResult>;
26
+ downloadFile(objectKey: string): Promise<{
27
+ data: Uint8Array;
28
+ contentType?: string;
29
+ }>;
30
+ /** 弱后端降级为代理路径 */
31
+ getPresignedUrl(objectKey: string, expirySeconds: number): Promise<string>;
32
+ deleteFile(objectKey: string): Promise<void>;
33
+ /** 强制 ns 前缀,无法越权列举 */
34
+ listFiles(ns: string, prefix: string, limit: number): Promise<FileInfo[]>;
35
+ }
36
+ /** 上传载荷(Node 侧 Uint8Array;浏览器 File/Blob 由调用方先转) */
37
+ export type FilePayload = Uint8Array | ReadableStream<Uint8Array>;
38
+ /** object key 校验:禁止路径穿越 */
39
+ export declare function assertSafeObjectKey(key: string): void;
40
+ /** 统一代理路径 */
41
+ export declare function proxyUrl(objectKey: string): string;
42
+ //#endregion
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ //#region src/index.ts
2
+ /** object key 校验:禁止路径穿越 */
3
+ function assertSafeObjectKey(key) {
4
+ if (key.includes("..") || key.startsWith("/") || key === "") throw new Error(`unsafe object key: ${key}`);
5
+ }
6
+ /** 统一代理路径 */
7
+ function proxyUrl(objectKey) {
8
+ return `/files/${objectKey}`;
9
+ }
10
+ //#endregion
11
+ export { assertSafeObjectKey, proxyUrl };
@@ -0,0 +1,8 @@
1
+ import { FileStorage } from "./index.js";
2
+ import { createReadStream } from "node:fs";
3
+ //#region src/local.d.ts
4
+ export declare function createLocalStorage(options: {
5
+ rootDir: string;
6
+ }): FileStorage;
7
+ //#endregion
8
+ export { createReadStream };
package/dist/local.js ADDED
@@ -0,0 +1,105 @@
1
+ import { assertSafeObjectKey, proxyUrl } from "./index.js";
2
+ import { createReadStream, existsSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ //#region src/local.ts
6
+ /**
7
+ * local-fs 后端:独立单机形态的缺省实现。
8
+ * - meta sidecar(<key>.meta.json)记录 contentType/size/uploadedAt
9
+ * - presign 降级为代理路径(接口语义:给我一个可下载 URL)
10
+ */
11
+ const metaPath = (root, key) => `${resolve(root, key)}.meta.json`;
12
+ async function* walk(dir, base = dir) {
13
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
14
+ const full = join(dir, entry.name);
15
+ if (entry.isDirectory()) yield* walk(full, base);
16
+ else if (!entry.name.endsWith(".meta.json")) yield full.slice(base.length + 1);
17
+ }
18
+ }
19
+ function createLocalStorage(options) {
20
+ const root = resolve(options.rootDir);
21
+ const put = async (key, data, contentType) => {
22
+ assertSafeObjectKey(key);
23
+ const abs = join(root, key);
24
+ await mkdir(dirname(abs), { recursive: true });
25
+ await writeFile(abs, data);
26
+ const uploadedAt = /* @__PURE__ */ new Date();
27
+ const meta = {
28
+ contentType,
29
+ size: data.byteLength,
30
+ uploadedAt: uploadedAt.toISOString()
31
+ };
32
+ await writeFile(metaPath(root, key), JSON.stringify(meta));
33
+ return {
34
+ objectKey: key,
35
+ url: proxyUrl(key),
36
+ size: data.byteLength,
37
+ uploadedAt
38
+ };
39
+ };
40
+ return {
41
+ async uploadFile(ns, fileName, data, contentType) {
42
+ assertSafeObjectKey(ns);
43
+ const safeName = fileName.replace(/[/\\]/g, "_");
44
+ const key = `${ns}/files/${Date.now()}_${safeName}`;
45
+ return put(key, toBytes(data), contentType);
46
+ },
47
+ async uploadFileRaw(objectKey, data, contentType) {
48
+ return put(objectKey, toBytes(data), contentType);
49
+ },
50
+ async downloadFile(objectKey) {
51
+ const abs = join(root, objectKey);
52
+ if (!existsSync(abs)) throw new Error(`object not found: ${objectKey}`);
53
+ const data = await readFile(abs);
54
+ let contentType;
55
+ try {
56
+ contentType = JSON.parse(await readFile(metaPath(root, objectKey), "utf-8")).contentType;
57
+ } catch {
58
+ contentType = void 0;
59
+ }
60
+ return {
61
+ data,
62
+ contentType
63
+ };
64
+ },
65
+ async getPresignedUrl(objectKey) {
66
+ return proxyUrl(objectKey);
67
+ },
68
+ async deleteFile(objectKey) {
69
+ await rm(join(root, objectKey), { force: true });
70
+ await rm(metaPath(root, objectKey), { force: true });
71
+ },
72
+ async listFiles(ns, prefix, limit) {
73
+ assertSafeObjectKey(ns);
74
+ const dir = join(root, ns);
75
+ if (!existsSync(dir)) return [];
76
+ const out = [];
77
+ for await (const rel of walk(dir)) {
78
+ if (prefix && !rel.startsWith(prefix)) continue;
79
+ const abs = join(dir, rel);
80
+ const s = await stat(abs);
81
+ let contentType;
82
+ const key = `${ns}/${rel}`;
83
+ try {
84
+ contentType = JSON.parse(await readFile(metaPath(root, key), "utf-8")).contentType;
85
+ } catch {
86
+ contentType = void 0;
87
+ }
88
+ out.push({
89
+ objectKey: key,
90
+ size: s.size,
91
+ updatedAt: s.mtime,
92
+ contentType
93
+ });
94
+ if (out.length >= limit) break;
95
+ }
96
+ return out;
97
+ }
98
+ };
99
+ }
100
+ function toBytes(data) {
101
+ if (data instanceof Uint8Array) return data;
102
+ throw new Error("local 后端不支持流式载荷:请先聚合为 Uint8Array");
103
+ }
104
+ //#endregion
105
+ export { createLocalStorage, createReadStream };
@@ -0,0 +1,7 @@
1
+ import { FileStorage } from "./index.js";
2
+ import { Hono } from "hono";
3
+ //#region src/proxy.d.ts
4
+ export declare function createFileProxyRoutes(storage: FileStorage, options?: {
5
+ mount?: string;
6
+ }): Hono;
7
+ //#endregion
package/dist/proxy.js ADDED
@@ -0,0 +1,25 @@
1
+ import { Hono } from "hono";
2
+ //#region src/proxy.ts
3
+ /**
4
+ * Hono 代理路由:/files/<key> 统一下载入口(对冲各后端 URL 策略差异)。
5
+ * 所有后端的 UploadResult.url 都指向本路由——不直接暴露存储地址。
6
+ */
7
+ function createFileProxyRoutes(storage, options) {
8
+ const mount = options?.mount ?? "/files";
9
+ const app = new Hono();
10
+ app.get(`${mount}/:key{.+}`, async (c) => {
11
+ const key = c.req.param("key");
12
+ try {
13
+ const { data, contentType } = await storage.downloadFile(key);
14
+ return new Response(data, { headers: {
15
+ "content-type": contentType ?? "application/octet-stream",
16
+ "cache-control": "public, max-age=31536000, immutable"
17
+ } });
18
+ } catch {
19
+ return c.text("not found", 404);
20
+ }
21
+ });
22
+ return app;
23
+ }
24
+ //#endregion
25
+ export { createFileProxyRoutes };
package/dist/s3.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { FileStorage } from "./index.js";
2
+ //#region src/s3.d.ts
3
+ export interface S3StorageOptions {
4
+ endpoint: string;
5
+ region: string;
6
+ bucket: string;
7
+ accessKeyId: string;
8
+ secretAccessKey: string;
9
+ /** 自举:bucket 不存在时创建(默认 true) */
10
+ autoCreateBucket?: boolean;
11
+ /** 预签名走直连 URL(默认 false:走代理路径降级,避免暴露 endpoint) */
12
+ presignDirect?: boolean;
13
+ }
14
+ export declare function createS3Storage(options: S3StorageOptions): FileStorage;
15
+ //#endregion
package/dist/s3.js ADDED
@@ -0,0 +1,115 @@
1
+ import { assertSafeObjectKey, proxyUrl } from "./index.js";
2
+ //#region src/s3.ts
3
+ function createS3Storage(options) {
4
+ let ready = null;
5
+ async function init() {
6
+ const [clientMod, presignerMod] = await Promise.all([import("@aws-sdk/client-s3"), import("@aws-sdk/s3-request-presigner")]);
7
+ const deps = {
8
+ S3Client: clientMod.S3Client,
9
+ PutObjectCommand: clientMod.PutObjectCommand,
10
+ GetObjectCommand: clientMod.GetObjectCommand,
11
+ DeleteObjectCommand: clientMod.DeleteObjectCommand,
12
+ ListObjectsV2Command: clientMod.ListObjectsV2Command,
13
+ HeadBucketCommand: clientMod.HeadBucketCommand,
14
+ CreateBucketCommand: clientMod.CreateBucketCommand,
15
+ getSignedUrl: presignerMod.getSignedUrl
16
+ };
17
+ const client = new deps.S3Client({
18
+ endpoint: options.endpoint,
19
+ region: options.region,
20
+ forcePathStyle: true,
21
+ credentials: {
22
+ accessKeyId: options.accessKeyId,
23
+ secretAccessKey: options.secretAccessKey
24
+ }
25
+ });
26
+ if (options.autoCreateBucket !== false) try {
27
+ await client.send(new deps.HeadBucketCommand({ Bucket: options.bucket }));
28
+ } catch {
29
+ await client.send(new deps.CreateBucketCommand({ Bucket: options.bucket }));
30
+ }
31
+ return {
32
+ client,
33
+ deps
34
+ };
35
+ }
36
+ function ensure() {
37
+ ready ??= init().catch((e) => {
38
+ ready = null;
39
+ throw e;
40
+ });
41
+ return ready;
42
+ }
43
+ const put = async (key, data, contentType) => {
44
+ assertSafeObjectKey(key);
45
+ const { client, deps } = await ensure();
46
+ await client.send(new deps.PutObjectCommand({
47
+ Bucket: options.bucket,
48
+ Key: key,
49
+ Body: data,
50
+ ContentType: contentType
51
+ }));
52
+ return {
53
+ objectKey: key,
54
+ url: proxyUrl(key),
55
+ size: data.byteLength,
56
+ uploadedAt: /* @__PURE__ */ new Date()
57
+ };
58
+ };
59
+ return {
60
+ async uploadFile(ns, fileName, data, contentType) {
61
+ assertSafeObjectKey(ns);
62
+ const safeName = fileName.replace(/[/\\]/g, "_");
63
+ return put(`${ns}/files/${Date.now()}_${safeName}`, toBytes(data), contentType);
64
+ },
65
+ async uploadFileRaw(objectKey, data, contentType) {
66
+ return put(objectKey, toBytes(data), contentType);
67
+ },
68
+ async downloadFile(objectKey) {
69
+ const { client, deps } = await ensure();
70
+ const res = await client.send(new deps.GetObjectCommand({
71
+ Bucket: options.bucket,
72
+ Key: objectKey
73
+ }));
74
+ if (!res.Body) throw new Error(`s3 object missing body: ${objectKey}`);
75
+ return {
76
+ data: await res.Body.transformToByteArray(),
77
+ contentType: res.ContentType
78
+ };
79
+ },
80
+ async getPresignedUrl(objectKey, expirySeconds) {
81
+ if (!options.presignDirect) return proxyUrl(objectKey);
82
+ const { client, deps } = await ensure();
83
+ return deps.getSignedUrl(client, new deps.GetObjectCommand({
84
+ Bucket: options.bucket,
85
+ Key: objectKey
86
+ }), { expiresIn: expirySeconds });
87
+ },
88
+ async deleteFile(objectKey) {
89
+ const { client, deps } = await ensure();
90
+ await client.send(new deps.DeleteObjectCommand({
91
+ Bucket: options.bucket,
92
+ Key: objectKey
93
+ }));
94
+ },
95
+ async listFiles(ns, prefix, limit) {
96
+ assertSafeObjectKey(ns);
97
+ const { client, deps } = await ensure();
98
+ return ((await client.send(new deps.ListObjectsV2Command({
99
+ Bucket: options.bucket,
100
+ Prefix: `${ns}/${prefix}`,
101
+ MaxKeys: limit
102
+ }))).Contents ?? []).map((obj) => ({
103
+ objectKey: obj.Key ?? "",
104
+ size: obj.Size ?? 0,
105
+ updatedAt: obj.LastModified ?? /* @__PURE__ */ new Date()
106
+ }));
107
+ }
108
+ };
109
+ }
110
+ function toBytes(data) {
111
+ if (data instanceof Uint8Array) return data;
112
+ throw new Error("s3 后端不支持流式载荷:请先聚合为 Uint8Array");
113
+ }
114
+ //#endregion
115
+ export { createS3Storage };
@@ -0,0 +1,11 @@
1
+ import { FileStorage } from "./index.js";
2
+ //#region src/supabase.d.ts
3
+ export interface SupabaseStorageOptions {
4
+ platformUrl: string;
5
+ /** public bucket 用 anonKey;private bucket 用 service role key */
6
+ apiKey: string;
7
+ bucket: string;
8
+ fetchImpl?: typeof fetch;
9
+ }
10
+ export declare function createSupabaseStorage(options: SupabaseStorageOptions): FileStorage;
11
+ //#endregion
@@ -0,0 +1,109 @@
1
+ import { assertSafeObjectKey, proxyUrl } from "./index.js";
2
+ //#region src/supabase.ts
3
+ /**
4
+ * supabase storage 后端:qw-pages 云形态。
5
+ * 手写 REST(零 SDK 依赖,fetchImpl 可注入测试):
6
+ * - upload: POST /storage/v1/object/{bucket}/{key}
7
+ * - download: GET /storage/v1/object/{bucket}/{key}
8
+ * - presign: POST /storage/v1/object/sign/{bucket}/{key}
9
+ * - delete: DELETE /storage/v1/object/{bucket}/{key}
10
+ * - list: POST /storage/v1/object/list/{bucket}
11
+ */
12
+ function createSupabaseStorage(options) {
13
+ const fetchImpl = options.fetchImpl ?? fetch;
14
+ const base = options.platformUrl.replace(/\/$/, "");
15
+ const bucket = options.bucket;
16
+ const headers = () => ({
17
+ authorization: `Bearer ${options.apiKey}`,
18
+ apikey: options.apiKey
19
+ });
20
+ const put = async (key, data, contentType) => {
21
+ assertSafeObjectKey(key);
22
+ const res = await fetchImpl(`${base}/storage/v1/object/${bucket}/${key}`, {
23
+ method: "POST",
24
+ headers: {
25
+ ...headers(),
26
+ "content-type": contentType,
27
+ "x-upsert": "true"
28
+ },
29
+ body: data
30
+ });
31
+ if (!res.ok) throw new Error(`supabase upload failed: ${res.status} ${await res.text()}`);
32
+ const uploadedAt = /* @__PURE__ */ new Date();
33
+ return {
34
+ objectKey: key,
35
+ url: proxyUrl(key),
36
+ size: data.byteLength,
37
+ uploadedAt
38
+ };
39
+ };
40
+ return {
41
+ async uploadFile(ns, fileName, data, contentType) {
42
+ assertSafeObjectKey(ns);
43
+ const safeName = fileName.replace(/[/\\]/g, "_");
44
+ return put(`${ns}/files/${Date.now()}_${safeName}`, toBytes(data), contentType);
45
+ },
46
+ async uploadFileRaw(objectKey, data, contentType) {
47
+ return put(objectKey, toBytes(data), contentType);
48
+ },
49
+ async downloadFile(objectKey) {
50
+ const res = await fetchImpl(`${base}/storage/v1/object/${bucket}/${objectKey}`, { headers: headers() });
51
+ if (!res.ok) throw new Error(`supabase download failed: ${res.status}`);
52
+ return {
53
+ data: new Uint8Array(await res.arrayBuffer()),
54
+ contentType: res.headers.get("content-type") ?? void 0
55
+ };
56
+ },
57
+ async getPresignedUrl(objectKey, expirySeconds) {
58
+ const res = await fetchImpl(`${base}/storage/v1/object/sign/${bucket}/${objectKey}`, {
59
+ method: "POST",
60
+ headers: {
61
+ ...headers(),
62
+ "content-type": "application/json"
63
+ },
64
+ body: JSON.stringify({ expiresIn: expirySeconds })
65
+ });
66
+ if (!res.ok) return proxyUrl(objectKey);
67
+ const { signedURL } = await res.json();
68
+ return `${base}/storage/v1${signedURL}`;
69
+ },
70
+ async deleteFile(objectKey) {
71
+ const res = await fetchImpl(`${base}/storage/v1/object/${bucket}/${objectKey}`, {
72
+ method: "DELETE",
73
+ headers: headers()
74
+ });
75
+ if (!res.ok) throw new Error(`supabase delete failed: ${res.status}`);
76
+ },
77
+ async listFiles(ns, prefix, limit) {
78
+ assertSafeObjectKey(ns);
79
+ const res = await fetchImpl(`${base}/storage/v1/object/list/${bucket}`, {
80
+ method: "POST",
81
+ headers: {
82
+ ...headers(),
83
+ "content-type": "application/json"
84
+ },
85
+ body: JSON.stringify({
86
+ prefix: `${ns}/${prefix}`,
87
+ limit,
88
+ sortBy: {
89
+ column: "name",
90
+ order: "asc"
91
+ }
92
+ })
93
+ });
94
+ if (!res.ok) throw new Error(`supabase list failed: ${res.status}`);
95
+ return (await res.json()).filter((it) => it.id !== null).map((it) => ({
96
+ objectKey: `${ns}/${prefix}${it.name}`,
97
+ size: it.metadata?.size ?? 0,
98
+ updatedAt: new Date(it.metadata?.lastModified ?? Date.now()),
99
+ contentType: it.metadata?.mimetype
100
+ }));
101
+ }
102
+ };
103
+ }
104
+ function toBytes(data) {
105
+ if (data instanceof Uint8Array) return data;
106
+ throw new Error("supabase 后端不支持流式载荷:请先聚合为 Uint8Array");
107
+ }
108
+ //#endregion
109
+ export { createSupabaseStorage };
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@homurajs/storage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "26.10.13",
4
+ "type": "module",
5
+ "description": "homura 对象存储/图床适配:supabase-storage / s3 兼容(minio/oss) / local-fs 三后端 + 代理路由",
6
+ "engines": {
7
+ "node": ">=22.5"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./local": {
18
+ "types": "./dist/local.d.ts",
19
+ "import": "./dist/local.js"
20
+ },
21
+ "./s3": {
22
+ "types": "./dist/s3.d.ts",
23
+ "import": "./dist/s3.js"
24
+ },
25
+ "./supabase": {
26
+ "types": "./dist/supabase.d.ts",
27
+ "import": "./dist/supabase.js"
28
+ },
29
+ "./proxy": {
30
+ "types": "./dist/proxy.d.ts",
31
+ "import": "./dist/proxy.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "dependencies": {
39
+ "@homurajs/core": "26.10.13"
40
+ },
41
+ "peerDependencies": {
42
+ "@aws-sdk/client-s3": ">=3.0.0",
43
+ "@aws-sdk/s3-request-presigner": ">=3.0.0",
44
+ "hono": ">=4.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@aws-sdk/client-s3": {
48
+ "optional": true
49
+ },
50
+ "@aws-sdk/s3-request-presigner": {
51
+ "optional": true
52
+ },
53
+ "hono": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@aws-sdk/client-s3": "^3.700.0",
59
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
60
+ "hono": "^4.10.0",
61
+ "tsdown": "^0.23.1"
62
+ },
63
+ "scripts": {
64
+ "build": "tsdown",
65
+ "typecheck": "tsc --noEmit",
66
+ "test": "vitest run"
67
+ }
6
68
  }