@huaqiu/dsh-artifacts 0.1.1
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/LICENSE +21 -0
- package/README.md +39 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.d.mts +17 -0
- package/lib/index.mjs +99 -0
- package/lib/service.d.mts +63 -0
- package/lib/service.mjs +197 -0
- package/package.json +44 -0
- package/src/index.ts +55 -0
- package/src/routes.ts +95 -0
- package/src/service.ts +261 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 深圳华秋智联股份有限公司
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @huaqiu/dsh-artifacts
|
|
2
|
+
|
|
3
|
+
Huaqiu artifact storage plugin for DSH: persist generated KiCad files (symbols,
|
|
4
|
+
footprints, schematics, project zips) and serve them back over the profile's
|
|
5
|
+
`webServer` so the DSH web UI can preview/download them.
|
|
6
|
+
|
|
7
|
+
## What it provides
|
|
8
|
+
|
|
9
|
+
- Node service `ctx.huaqiuArtifacts` — a filesystem-backed artifact store with
|
|
10
|
+
metadata (`meta.json`), atomic writes, TTL expiry, a `maxBytes` cap, and strict
|
|
11
|
+
id validation (`^art_[0-9a-f]+$`). Filenames never affect the storage path.
|
|
12
|
+
- HTTP routes under `/api/v1/huaqiu/artifacts`:
|
|
13
|
+
- `GET /api/v1/huaqiu/artifacts/<id>` — artifact metadata
|
|
14
|
+
- `GET /api/v1/huaqiu/artifacts/<id>/content` — raw content with
|
|
15
|
+
`Content-Disposition: inline` for browser preview
|
|
16
|
+
- Injects `webServer` (via `cordis.patch.yml`).
|
|
17
|
+
|
|
18
|
+
Port of `hq-edge`'s `DshPreviewArtifactService` with zero dependency on the HQ
|
|
19
|
+
Edge monorepo (storage root defaults to `dshHomePath('artifacts')`).
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// Node half (DSH plugin). Service is provided by the plugin entry:
|
|
25
|
+
ctx.huaqiuArtifacts.create({ type: 'symbol', filename: 's.kicad_sym', content })
|
|
26
|
+
ctx.huaqiuArtifacts.readContent(id)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// Direct service use (no cordis):
|
|
31
|
+
import { HuaqiuArtifactService } from '@huaqiu/dsh-artifacts/service'
|
|
32
|
+
const svc = new HuaqiuArtifactService({ baseDir: '/tmp/artifacts' })
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Status
|
|
36
|
+
|
|
37
|
+
Phase 0B — service + routes implemented and tested (10 tests). The `zip` artifact
|
|
38
|
+
type already supports the system-design project zip; tool-level integration lands
|
|
39
|
+
in Phases 1–3.
|
package/cordis.patch.yml
ADDED
package/lib/index.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ArtifactMeta, ArtifactType, CreateArtifactInput, CreateArtifactResult, HuaqiuArtifacts } from "./service.mjs";
|
|
2
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
declare const name = "@huaqiu/dsh-artifacts";
|
|
5
|
+
declare const inject: readonly ["webServer"];
|
|
6
|
+
interface HuaqiuArtifactsConfig {
|
|
7
|
+
baseDir?: string;
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
declare module '@deepseek-ai/cordis' {
|
|
11
|
+
interface Context {
|
|
12
|
+
huaqiuArtifacts: HuaqiuArtifacts;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
declare function apply(ctx: Context, config?: HuaqiuArtifactsConfig): void;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { type ArtifactMeta, type ArtifactType, type CreateArtifactInput, type CreateArtifactResult, type HuaqiuArtifacts, HuaqiuArtifactsConfig, apply, inject, name };
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { HuaqiuArtifactService, log } from "./service.mjs";
|
|
2
|
+
//#region src/routes.ts
|
|
3
|
+
const ARTIFACTS_ROUTE_PREFIX = "/api/v1/huaqiu/artifacts";
|
|
4
|
+
function sendJson(res, status, body) {
|
|
5
|
+
const payload = JSON.stringify(body);
|
|
6
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
7
|
+
res.end(payload);
|
|
8
|
+
}
|
|
9
|
+
/** Parse `<id>` or `<id>/content` off the route prefix. Returns null on bad shape. */
|
|
10
|
+
function parsePath(req) {
|
|
11
|
+
const url = req.url ?? "";
|
|
12
|
+
const q = url.indexOf("?");
|
|
13
|
+
const pathname = q >= 0 ? url.slice(0, q) : url;
|
|
14
|
+
if (!pathname.startsWith("/api/v1/huaqiu/artifacts")) return null;
|
|
15
|
+
const rest = pathname.slice(24);
|
|
16
|
+
if (rest === "") return null;
|
|
17
|
+
const segs = rest.split("/").filter(Boolean);
|
|
18
|
+
if (segs.length === 1) return {
|
|
19
|
+
id: segs[0],
|
|
20
|
+
content: false
|
|
21
|
+
};
|
|
22
|
+
if (segs.length === 2 && segs[1] === "content") return {
|
|
23
|
+
id: segs[0],
|
|
24
|
+
content: true
|
|
25
|
+
};
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function createArtifactsHandler(service) {
|
|
29
|
+
return async (req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = parsePath(req);
|
|
32
|
+
if (!parsed) {
|
|
33
|
+
sendJson(res, 404, { error: "not found" });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const { id, content } = parsed;
|
|
37
|
+
if (content) {
|
|
38
|
+
const meta = await service.get(id);
|
|
39
|
+
if (!meta) {
|
|
40
|
+
sendJson(res, 404, { error: "artifact not found or expired" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const bytes = await service.readContent(id);
|
|
44
|
+
if (!bytes) {
|
|
45
|
+
sendJson(res, 404, { error: "artifact content missing" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const safeFilename = encodeURIComponent(meta.filename);
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
"content-type": meta.mimeType || "application/octet-stream",
|
|
51
|
+
"content-length": String(bytes.byteLength),
|
|
52
|
+
"content-disposition": `inline; filename*=UTF-8''${safeFilename}`,
|
|
53
|
+
"cache-control": "no-store"
|
|
54
|
+
});
|
|
55
|
+
res.end(Buffer.from(bytes));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const meta = await service.get(id);
|
|
59
|
+
if (!meta) {
|
|
60
|
+
sendJson(res, 404, { error: "artifact not found or expired" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
sendJson(res, 200, {
|
|
64
|
+
id: meta.id,
|
|
65
|
+
type: meta.type,
|
|
66
|
+
filename: meta.filename,
|
|
67
|
+
mimeType: meta.mimeType,
|
|
68
|
+
size: meta.size,
|
|
69
|
+
createdAt: meta.createdAt
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
sendJson(res, 500, {
|
|
73
|
+
error: "internal error resolving artifact",
|
|
74
|
+
detail: String(err)
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/index.ts
|
|
81
|
+
const name = "@huaqiu/dsh-artifacts";
|
|
82
|
+
const inject = ["webServer"];
|
|
83
|
+
function apply(ctx, config = {}) {
|
|
84
|
+
const service = new HuaqiuArtifactService(config);
|
|
85
|
+
ctx.effect(() => ctx.provide("huaqiuArtifacts", service));
|
|
86
|
+
ctx.effect(() => ctx.webServer.register({
|
|
87
|
+
kind: "prefix",
|
|
88
|
+
path: ARTIFACTS_ROUTE_PREFIX,
|
|
89
|
+
handler: createArtifactsHandler(service)
|
|
90
|
+
}));
|
|
91
|
+
ctx.effect(() => {
|
|
92
|
+
service.deleteAll({ onlyExpired: true }).then((removed) => {
|
|
93
|
+
if (removed > 0) log("debug", "artifacts: expired sweep removed", { removed });
|
|
94
|
+
}).catch((err) => log("warn", "artifacts: expired sweep failed", { err }));
|
|
95
|
+
return () => {};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/service.d.ts
|
|
2
|
+
type ArtifactType = 'symbol' | 'footprint' | 'schematic' | 'pcb' | 'zip';
|
|
3
|
+
interface ArtifactMeta {
|
|
4
|
+
id: string;
|
|
5
|
+
type: ArtifactType;
|
|
6
|
+
filename: string;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
size: number;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
expiresAt?: string;
|
|
11
|
+
}
|
|
12
|
+
interface CreateArtifactInput {
|
|
13
|
+
type: ArtifactType;
|
|
14
|
+
filename: string;
|
|
15
|
+
/** string is UTF-8 (or base64 when `contentEncoding === 'base64'`). */
|
|
16
|
+
content: string | Uint8Array;
|
|
17
|
+
contentEncoding?: 'utf8' | 'base64';
|
|
18
|
+
/** Optional TTL seconds from now; undefined = manual cleanup only. */
|
|
19
|
+
ttlSeconds?: number;
|
|
20
|
+
}
|
|
21
|
+
interface CreateArtifactResult {
|
|
22
|
+
id: string;
|
|
23
|
+
type: ArtifactType;
|
|
24
|
+
filename: string;
|
|
25
|
+
size: number;
|
|
26
|
+
}
|
|
27
|
+
interface ArtifactsServiceOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Base directory hosting `dsh-artifacts/`. Defaults to
|
|
30
|
+
* `dshHomePath('artifacts')` (`~/.dsh/artifacts/`). Overridable for tests.
|
|
31
|
+
*/
|
|
32
|
+
baseDir?: string;
|
|
33
|
+
/** Hard cap on stored artifact bytes. Default 16 MiB. */
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
}
|
|
36
|
+
interface HuaqiuArtifacts {
|
|
37
|
+
create(input: CreateArtifactInput): Promise<CreateArtifactResult>;
|
|
38
|
+
get(id: string): Promise<ArtifactMeta | null>;
|
|
39
|
+
readContent(id: string): Promise<Uint8Array | null>;
|
|
40
|
+
delete(id: string): Promise<void>;
|
|
41
|
+
deleteAll(opts?: {
|
|
42
|
+
onlyExpired?: boolean;
|
|
43
|
+
}): Promise<number>;
|
|
44
|
+
}
|
|
45
|
+
declare function log(level: 'debug' | 'warn', msg: string, extra?: Record<string, unknown>): void;
|
|
46
|
+
declare class HuaqiuArtifactService implements HuaqiuArtifacts {
|
|
47
|
+
private readonly baseDir;
|
|
48
|
+
private readonly maxBytes;
|
|
49
|
+
constructor(options?: ArtifactsServiceOptions);
|
|
50
|
+
private artifactsRoot;
|
|
51
|
+
private artifactDir;
|
|
52
|
+
create(input: CreateArtifactInput): Promise<CreateArtifactResult>;
|
|
53
|
+
get(id: string): Promise<ArtifactMeta | null>;
|
|
54
|
+
/** Returns `null` when missing or expired (miss signal for the HTTP 404). */
|
|
55
|
+
readContent(id: string): Promise<Uint8Array | null>;
|
|
56
|
+
delete(id: string): Promise<void>;
|
|
57
|
+
deleteAll(opts?: {
|
|
58
|
+
onlyExpired?: boolean;
|
|
59
|
+
}): Promise<number>;
|
|
60
|
+
private isExpired;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { ArtifactMeta, ArtifactType, ArtifactsServiceOptions, CreateArtifactInput, CreateArtifactResult, HuaqiuArtifactService, HuaqiuArtifacts, log };
|
package/lib/service.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
5
|
+
//#region src/service.ts
|
|
6
|
+
/**
|
|
7
|
+
* `@huaqiu/dsh-artifacts` — user-wide, flat storage for ECAD preview artifacts
|
|
8
|
+
* (KiCad symbol / footprint / schematic / pcb / zip) in DSH.
|
|
9
|
+
*
|
|
10
|
+
* Port of `hq-edge`'s `DshPreviewArtifactService` (apps/server/src/artifacts/
|
|
11
|
+
* dsh-preview-artifacts.service.ts) with zero dependency on the HQ Edge
|
|
12
|
+
* monorepo:
|
|
13
|
+
* - default root = `dshHomePath('artifacts')` (`~/.dsh/artifacts/`)
|
|
14
|
+
* - `node:crypto.randomUUID` instead of `uuid`
|
|
15
|
+
* - no logger framework (minimal internal logger)
|
|
16
|
+
* - hardening: strict `^art_[0-9a-f]+$` id, filename never in the storage
|
|
17
|
+
* path, max-size cap, atomic writes, TTL expiry
|
|
18
|
+
* - `readContent(): Promise<Uint8Array | null>` (no streaming abstraction)
|
|
19
|
+
*
|
|
20
|
+
* Layout on disk:
|
|
21
|
+
* <baseDir>/dsh-artifacts/<artifactId>/
|
|
22
|
+
* meta.json # id, type, filename, mimeType, size, createdAt, expiresAt?
|
|
23
|
+
* content # raw bytes
|
|
24
|
+
*/
|
|
25
|
+
const VALID_TYPES = /* @__PURE__ */ new Set([
|
|
26
|
+
"symbol",
|
|
27
|
+
"footprint",
|
|
28
|
+
"schematic",
|
|
29
|
+
"pcb",
|
|
30
|
+
"zip"
|
|
31
|
+
]);
|
|
32
|
+
function isValidArtifactType(v) {
|
|
33
|
+
return typeof v === "string" && VALID_TYPES.has(v);
|
|
34
|
+
}
|
|
35
|
+
/** Opaque id we mint ourselves — hardened against any path use. */
|
|
36
|
+
const ARTIFACT_ID_PATTERN = /^art_[0-9a-f]+$/;
|
|
37
|
+
function mimeTypeFor(type, filename) {
|
|
38
|
+
const lower = filename.toLowerCase();
|
|
39
|
+
if (lower.endsWith(".kicad_sym")) return "application/x.kicad-symbol";
|
|
40
|
+
if (lower.endsWith(".kicad_mod")) return "application/x.kicad-footprint";
|
|
41
|
+
if (lower.endsWith(".kicad_footprint")) return "application/x.kicad-footprint";
|
|
42
|
+
if (lower.endsWith(".kicad_sch")) return "application/x.kicad-schematic";
|
|
43
|
+
if (lower.endsWith(".kicad_pcb")) return "application/x.kicad-pcb";
|
|
44
|
+
if (lower.endsWith(".kicad_pro")) return "application/x.kicad-project";
|
|
45
|
+
if (lower.endsWith(".zip")) return "application/zip";
|
|
46
|
+
switch (type) {
|
|
47
|
+
case "symbol": return "application/x.kicad-symbol";
|
|
48
|
+
case "footprint": return "application/x.kicad-footprint";
|
|
49
|
+
case "schematic": return "application/x.kicad-schematic";
|
|
50
|
+
case "pcb": return "application/x.kicad-pcb";
|
|
51
|
+
case "zip": return "application/zip";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function toBytes(content, encoding) {
|
|
55
|
+
if (typeof content === "string") return encoding === "base64" ? Buffer.from(content, "base64") : Buffer.from(content, "utf8");
|
|
56
|
+
return content instanceof Uint8Array ? content : new Uint8Array(content);
|
|
57
|
+
}
|
|
58
|
+
function log(level, msg, extra) {
|
|
59
|
+
if (level === "warn" || process.env.DSH_ARTIFACTS_DEBUG) {
|
|
60
|
+
const line = `[dsh-artifacts] ${msg}${extra ? " " + JSON.stringify(extra) : ""}`;
|
|
61
|
+
if (level === "warn") console.warn(line);
|
|
62
|
+
else console.debug(line);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
var HuaqiuArtifactService = class {
|
|
66
|
+
baseDir;
|
|
67
|
+
maxBytes;
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.baseDir = options.baseDir ?? dshHomePath("artifacts");
|
|
70
|
+
this.maxBytes = options.maxBytes ?? 16777216;
|
|
71
|
+
}
|
|
72
|
+
artifactsRoot() {
|
|
73
|
+
return path.resolve(this.baseDir, "dsh-artifacts");
|
|
74
|
+
}
|
|
75
|
+
artifactDir(artifactId) {
|
|
76
|
+
if (!ARTIFACT_ID_PATTERN.test(artifactId)) throw new Error(`Invalid artifactId: does not match ${String(ARTIFACT_ID_PATTERN)}`);
|
|
77
|
+
return path.join(this.artifactsRoot(), artifactId);
|
|
78
|
+
}
|
|
79
|
+
async create(input) {
|
|
80
|
+
if (!isValidArtifactType(input.type)) throw new Error(`Invalid artifact type: ${String(input.type)}`);
|
|
81
|
+
if (typeof input.filename !== "string" || input.filename.length === 0) throw new Error("filename is required");
|
|
82
|
+
const buf = toBytes(input.content, input.contentEncoding);
|
|
83
|
+
if (buf.byteLength > this.maxBytes) throw new Error(`artifact content exceeds max size (${buf.byteLength} > ${this.maxBytes})`);
|
|
84
|
+
const id = "art_" + randomUUID().replace(/-/g, "").slice(0, 16);
|
|
85
|
+
const now = /* @__PURE__ */ new Date();
|
|
86
|
+
const meta = {
|
|
87
|
+
id,
|
|
88
|
+
type: input.type,
|
|
89
|
+
filename: input.filename,
|
|
90
|
+
mimeType: mimeTypeFor(input.type, input.filename),
|
|
91
|
+
size: buf.byteLength,
|
|
92
|
+
createdAt: now.toISOString()
|
|
93
|
+
};
|
|
94
|
+
if (typeof input.ttlSeconds === "number" && Number.isFinite(input.ttlSeconds) && input.ttlSeconds > 0) meta.expiresAt = new Date(now.getTime() + input.ttlSeconds * 1e3).toISOString();
|
|
95
|
+
const dir = this.artifactDir(id);
|
|
96
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
97
|
+
const tmpContent = path.join(dir, "content.tmp");
|
|
98
|
+
await fs.promises.writeFile(tmpContent, buf);
|
|
99
|
+
await fs.promises.rename(tmpContent, path.join(dir, "content"));
|
|
100
|
+
const tmpMeta = path.join(dir, "meta.json.tmp");
|
|
101
|
+
await fs.promises.writeFile(tmpMeta, JSON.stringify(meta, null, 2), "utf8");
|
|
102
|
+
await fs.promises.rename(tmpMeta, path.join(dir, "meta.json"));
|
|
103
|
+
log("debug", "created", {
|
|
104
|
+
id,
|
|
105
|
+
type: input.type,
|
|
106
|
+
size: buf.byteLength
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
type: meta.type,
|
|
111
|
+
filename: meta.filename,
|
|
112
|
+
size: meta.size
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async get(id) {
|
|
116
|
+
if (typeof id !== "string" || !ARTIFACT_ID_PATTERN.test(id)) return null;
|
|
117
|
+
try {
|
|
118
|
+
const dir = this.artifactDir(id);
|
|
119
|
+
const fp = path.join(dir, "meta.json");
|
|
120
|
+
const raw = await fs.promises.readFile(fp, "utf8");
|
|
121
|
+
const meta = JSON.parse(raw);
|
|
122
|
+
if (this.isExpired(meta)) {
|
|
123
|
+
this.delete(id);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return meta;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Returns `null` when missing or expired (miss signal for the HTTP 404). */
|
|
132
|
+
async readContent(id) {
|
|
133
|
+
if (!await this.get(id)) return null;
|
|
134
|
+
try {
|
|
135
|
+
return new Uint8Array(await fs.promises.readFile(path.join(this.artifactDir(id), "content")));
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async delete(id) {
|
|
141
|
+
if (typeof id !== "string" || !ARTIFACT_ID_PATTERN.test(id)) return;
|
|
142
|
+
try {
|
|
143
|
+
const dir = this.artifactDir(id);
|
|
144
|
+
await fs.promises.rm(dir, {
|
|
145
|
+
recursive: true,
|
|
146
|
+
force: true
|
|
147
|
+
});
|
|
148
|
+
log("debug", "deleted", { id });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log("warn", "delete failed", {
|
|
151
|
+
id,
|
|
152
|
+
err
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async deleteAll(opts = {}) {
|
|
157
|
+
const { onlyExpired = false } = opts;
|
|
158
|
+
const root = this.artifactsRoot();
|
|
159
|
+
let removed = 0;
|
|
160
|
+
if (!fs.existsSync(root)) return 0;
|
|
161
|
+
try {
|
|
162
|
+
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
if (!entry.isDirectory()) continue;
|
|
165
|
+
if (onlyExpired) try {
|
|
166
|
+
const raw = await fs.promises.readFile(path.join(root, entry.name, "meta.json"), "utf8");
|
|
167
|
+
const meta = JSON.parse(raw);
|
|
168
|
+
if (!this.isExpired(meta)) continue;
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
await fs.promises.rm(path.join(root, entry.name), {
|
|
173
|
+
recursive: true,
|
|
174
|
+
force: true
|
|
175
|
+
});
|
|
176
|
+
removed += 1;
|
|
177
|
+
}
|
|
178
|
+
log("debug", "deleteAll complete", {
|
|
179
|
+
onlyExpired,
|
|
180
|
+
removed
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
log("warn", "deleteAll failed", { err });
|
|
184
|
+
}
|
|
185
|
+
return removed;
|
|
186
|
+
}
|
|
187
|
+
isExpired(meta) {
|
|
188
|
+
if (!meta.expiresAt) return false;
|
|
189
|
+
try {
|
|
190
|
+
return new Date(meta.expiresAt).getTime() <= Date.now();
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
//#endregion
|
|
197
|
+
export { HuaqiuArtifactService, log };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huaqiu/dsh-artifacts",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./lib/index.mjs",
|
|
6
|
+
"types": "./lib/index.d.mts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.mts",
|
|
10
|
+
"default": "./lib/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./service": {
|
|
13
|
+
"types": "./lib/service.d.mts",
|
|
14
|
+
"default": "./lib/service.mjs"
|
|
15
|
+
},
|
|
16
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
26
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.0 <0.2.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@deepseek-ai/dsh-home-paths": ">=0.1.0-rc.0 <0.2.0"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"lib",
|
|
33
|
+
"src",
|
|
34
|
+
"cordis.patch.yml"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"build": "tsdown",
|
|
42
|
+
"test": "vitest run"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/dsh-artifacts` — DSH plugin entry.
|
|
3
|
+
*
|
|
4
|
+
* Provides the `huaqiuArtifacts` service (node tools call it in-process; no
|
|
5
|
+
* HTTP loopback) and mounts the read-only preview routes on `ctx.webServer`
|
|
6
|
+
* for the browser UI.
|
|
7
|
+
*/
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
10
|
+
import { HuaqiuArtifactService, log, type HuaqiuArtifacts } from './service.js'
|
|
11
|
+
import { ARTIFACTS_ROUTE_PREFIX, createArtifactsHandler } from './routes.js'
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
ArtifactMeta,
|
|
15
|
+
ArtifactType,
|
|
16
|
+
CreateArtifactInput,
|
|
17
|
+
CreateArtifactResult,
|
|
18
|
+
HuaqiuArtifacts,
|
|
19
|
+
} from './service.js'
|
|
20
|
+
|
|
21
|
+
export const name = '@huaqiu/dsh-artifacts'
|
|
22
|
+
export const inject = ['webServer'] as const
|
|
23
|
+
|
|
24
|
+
export interface HuaqiuArtifactsConfig {
|
|
25
|
+
baseDir?: string
|
|
26
|
+
maxBytes?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare module '@deepseek-ai/cordis' {
|
|
30
|
+
interface Context {
|
|
31
|
+
huaqiuArtifacts: HuaqiuArtifacts
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function apply(ctx: Context, config: HuaqiuArtifactsConfig = {}): void {
|
|
36
|
+
const service = new HuaqiuArtifactService(config)
|
|
37
|
+
ctx.effect(() => ctx.provide('huaqiuArtifacts', service))
|
|
38
|
+
|
|
39
|
+
ctx.effect(() => ctx.webServer.register({
|
|
40
|
+
kind: 'prefix',
|
|
41
|
+
path: ARTIFACTS_ROUTE_PREFIX,
|
|
42
|
+
handler: createArtifactsHandler(service),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
// Boot-time GC: `deleteAll({ onlyExpired: true })` is implemented but nothing
|
|
46
|
+
// ever calls it, so `~/.dsh/artifacts/` (and any migrated location) grows
|
|
47
|
+
// without bound (spec risk R6). Sweep once at startup; never fail startup on a
|
|
48
|
+
// disk error, and never block activation on the scan.
|
|
49
|
+
ctx.effect(() => {
|
|
50
|
+
void service.deleteAll({ onlyExpired: true })
|
|
51
|
+
.then((removed) => { if (removed > 0) log('debug', 'artifacts: expired sweep removed', { removed }) })
|
|
52
|
+
.catch((err) => log('warn', 'artifacts: expired sweep failed', { err }))
|
|
53
|
+
return () => {}
|
|
54
|
+
})
|
|
55
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP adapter for `@huaqiu/dsh-artifacts` (browser half of the preview flow).
|
|
3
|
+
*
|
|
4
|
+
* Browser → same-origin DSH webServer → `ctx.webServer` prefix route
|
|
5
|
+
* `/api/v1/huaqiu/artifacts` (the `edge-bridge` `/hq-edge` precedent, but
|
|
6
|
+
* plugin-owned). Parameterized sub-paths are parsed in the handler:
|
|
7
|
+
*
|
|
8
|
+
* GET /api/v1/huaqiu/artifacts/<id> → metadata JSON
|
|
9
|
+
* GET /api/v1/huaqiu/artifacts/<id>/content → raw bytes
|
|
10
|
+
*
|
|
11
|
+
* A single prefix route is used because the DSH `WebRoute` supports exact or
|
|
12
|
+
* prefix matches only (no `:id` path params), and two prefix routes on the
|
|
13
|
+
* same base path would collide.
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
16
|
+
import type { HuaqiuArtifacts } from './service.js'
|
|
17
|
+
|
|
18
|
+
export const ARTIFACTS_ROUTE_PREFIX = '/api/v1/huaqiu/artifacts'
|
|
19
|
+
|
|
20
|
+
export type ArtifactsHandler = (
|
|
21
|
+
req: IncomingMessage,
|
|
22
|
+
res: ServerResponse,
|
|
23
|
+
) => Promise<void> | void
|
|
24
|
+
|
|
25
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
26
|
+
const payload = JSON.stringify(body)
|
|
27
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
28
|
+
res.end(payload)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parse `<id>` or `<id>/content` off the route prefix. Returns null on bad shape. */
|
|
32
|
+
function parsePath(req: IncomingMessage): { id: string; content: boolean } | null {
|
|
33
|
+
const url = req.url ?? ''
|
|
34
|
+
const q = url.indexOf('?')
|
|
35
|
+
const pathname = q >= 0 ? url.slice(0, q) : url
|
|
36
|
+
if (!pathname.startsWith(ARTIFACTS_ROUTE_PREFIX)) return null
|
|
37
|
+
const rest = pathname.slice(ARTIFACTS_ROUTE_PREFIX.length)
|
|
38
|
+
// rest === '' or startsWith('/')
|
|
39
|
+
if (rest === '') return null
|
|
40
|
+
const segs = rest.split('/').filter(Boolean)
|
|
41
|
+
if (segs.length === 1) return { id: segs[0]!, content: false }
|
|
42
|
+
if (segs.length === 2 && segs[1] === 'content') return { id: segs[0]!, content: true }
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createArtifactsHandler(service: HuaqiuArtifacts): ArtifactsHandler {
|
|
47
|
+
return async (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = parsePath(req)
|
|
50
|
+
if (!parsed) {
|
|
51
|
+
sendJson(res, 404, { error: 'not found' })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const { id, content } = parsed
|
|
55
|
+
|
|
56
|
+
if (content) {
|
|
57
|
+
const meta = await service.get(id)
|
|
58
|
+
if (!meta) {
|
|
59
|
+
sendJson(res, 404, { error: 'artifact not found or expired' })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const bytes = await service.readContent(id)
|
|
63
|
+
if (!bytes) {
|
|
64
|
+
sendJson(res, 404, { error: 'artifact content missing' })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const safeFilename = encodeURIComponent(meta.filename)
|
|
68
|
+
res.writeHead(200, {
|
|
69
|
+
'content-type': meta.mimeType || 'application/octet-stream',
|
|
70
|
+
'content-length': String(bytes.byteLength),
|
|
71
|
+
'content-disposition': `inline; filename*=UTF-8''${safeFilename}`,
|
|
72
|
+
'cache-control': 'no-store',
|
|
73
|
+
})
|
|
74
|
+
res.end(Buffer.from(bytes))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const meta = await service.get(id)
|
|
79
|
+
if (!meta) {
|
|
80
|
+
sendJson(res, 404, { error: 'artifact not found or expired' })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
sendJson(res, 200, {
|
|
84
|
+
id: meta.id,
|
|
85
|
+
type: meta.type,
|
|
86
|
+
filename: meta.filename,
|
|
87
|
+
mimeType: meta.mimeType,
|
|
88
|
+
size: meta.size,
|
|
89
|
+
createdAt: meta.createdAt,
|
|
90
|
+
})
|
|
91
|
+
} catch (err) {
|
|
92
|
+
sendJson(res, 500, { error: 'internal error resolving artifact', detail: String(err) })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/dsh-artifacts` — user-wide, flat storage for ECAD preview artifacts
|
|
3
|
+
* (KiCad symbol / footprint / schematic / pcb / zip) in DSH.
|
|
4
|
+
*
|
|
5
|
+
* Port of `hq-edge`'s `DshPreviewArtifactService` (apps/server/src/artifacts/
|
|
6
|
+
* dsh-preview-artifacts.service.ts) with zero dependency on the HQ Edge
|
|
7
|
+
* monorepo:
|
|
8
|
+
* - default root = `dshHomePath('artifacts')` (`~/.dsh/artifacts/`)
|
|
9
|
+
* - `node:crypto.randomUUID` instead of `uuid`
|
|
10
|
+
* - no logger framework (minimal internal logger)
|
|
11
|
+
* - hardening: strict `^art_[0-9a-f]+$` id, filename never in the storage
|
|
12
|
+
* path, max-size cap, atomic writes, TTL expiry
|
|
13
|
+
* - `readContent(): Promise<Uint8Array | null>` (no streaming abstraction)
|
|
14
|
+
*
|
|
15
|
+
* Layout on disk:
|
|
16
|
+
* <baseDir>/dsh-artifacts/<artifactId>/
|
|
17
|
+
* meta.json # id, type, filename, mimeType, size, createdAt, expiresAt?
|
|
18
|
+
* content # raw bytes
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'node:fs'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
24
|
+
|
|
25
|
+
// ── Public types ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export type ArtifactType = 'symbol' | 'footprint' | 'schematic' | 'pcb' | 'zip'
|
|
28
|
+
|
|
29
|
+
export interface ArtifactMeta {
|
|
30
|
+
id: string
|
|
31
|
+
type: ArtifactType
|
|
32
|
+
filename: string
|
|
33
|
+
mimeType: string
|
|
34
|
+
size: number
|
|
35
|
+
createdAt: string
|
|
36
|
+
expiresAt?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CreateArtifactInput {
|
|
40
|
+
type: ArtifactType
|
|
41
|
+
filename: string
|
|
42
|
+
/** string is UTF-8 (or base64 when `contentEncoding === 'base64'`). */
|
|
43
|
+
content: string | Uint8Array
|
|
44
|
+
contentEncoding?: 'utf8' | 'base64'
|
|
45
|
+
/** Optional TTL seconds from now; undefined = manual cleanup only. */
|
|
46
|
+
ttlSeconds?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CreateArtifactResult {
|
|
50
|
+
id: string
|
|
51
|
+
type: ArtifactType
|
|
52
|
+
filename: string
|
|
53
|
+
size: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ArtifactsServiceOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Base directory hosting `dsh-artifacts/`. Defaults to
|
|
59
|
+
* `dshHomePath('artifacts')` (`~/.dsh/artifacts/`). Overridable for tests.
|
|
60
|
+
*/
|
|
61
|
+
baseDir?: string
|
|
62
|
+
/** Hard cap on stored artifact bytes. Default 16 MiB. */
|
|
63
|
+
maxBytes?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface HuaqiuArtifacts {
|
|
67
|
+
create(input: CreateArtifactInput): Promise<CreateArtifactResult>
|
|
68
|
+
get(id: string): Promise<ArtifactMeta | null>
|
|
69
|
+
readContent(id: string): Promise<Uint8Array | null>
|
|
70
|
+
delete(id: string): Promise<void>
|
|
71
|
+
deleteAll(opts?: { onlyExpired?: boolean }): Promise<number>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const VALID_TYPES: ReadonlySet<ArtifactType> = new Set([
|
|
77
|
+
'symbol', 'footprint', 'schematic', 'pcb', 'zip',
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
function isValidArtifactType(v: unknown): v is ArtifactType {
|
|
81
|
+
return typeof v === 'string' && VALID_TYPES.has(v as ArtifactType)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Opaque id we mint ourselves — hardened against any path use. */
|
|
85
|
+
const ARTIFACT_ID_PATTERN = /^art_[0-9a-f]+$/
|
|
86
|
+
|
|
87
|
+
function mimeTypeFor(type: ArtifactType, filename: string): string {
|
|
88
|
+
const lower = filename.toLowerCase()
|
|
89
|
+
if (lower.endsWith('.kicad_sym')) return 'application/x.kicad-symbol'
|
|
90
|
+
if (lower.endsWith('.kicad_mod')) return 'application/x.kicad-footprint'
|
|
91
|
+
if (lower.endsWith('.kicad_footprint')) return 'application/x.kicad-footprint'
|
|
92
|
+
if (lower.endsWith('.kicad_sch')) return 'application/x.kicad-schematic'
|
|
93
|
+
if (lower.endsWith('.kicad_pcb')) return 'application/x.kicad-pcb'
|
|
94
|
+
if (lower.endsWith('.kicad_pro')) return 'application/x.kicad-project'
|
|
95
|
+
if (lower.endsWith('.zip')) return 'application/zip'
|
|
96
|
+
switch (type) {
|
|
97
|
+
case 'symbol': return 'application/x.kicad-symbol'
|
|
98
|
+
case 'footprint': return 'application/x.kicad-footprint'
|
|
99
|
+
case 'schematic': return 'application/x.kicad-schematic'
|
|
100
|
+
case 'pcb': return 'application/x.kicad-pcb'
|
|
101
|
+
case 'zip': return 'application/zip'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toBytes(content: string | Uint8Array, encoding: 'utf8' | 'base64' | undefined): Uint8Array {
|
|
106
|
+
if (typeof content === 'string') {
|
|
107
|
+
return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8')
|
|
108
|
+
}
|
|
109
|
+
return content instanceof Uint8Array ? content : new Uint8Array(content)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function log(level: 'debug' | 'warn', msg: string, extra?: Record<string, unknown>): void {
|
|
113
|
+
// Minimal, dependency-free logger. DSH plugins should not pull a logger lib.
|
|
114
|
+
if (level === 'warn' || process.env.DSH_ARTIFACTS_DEBUG) {
|
|
115
|
+
const line = `[dsh-artifacts] ${msg}${extra ? ' ' + JSON.stringify(extra) : ''}`
|
|
116
|
+
if (level === 'warn') console.warn(line)
|
|
117
|
+
else console.debug(line)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Service ─────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export class HuaqiuArtifactService implements HuaqiuArtifacts {
|
|
124
|
+
private readonly baseDir: string
|
|
125
|
+
private readonly maxBytes: number
|
|
126
|
+
|
|
127
|
+
constructor(options: ArtifactsServiceOptions = {}) {
|
|
128
|
+
this.baseDir = options.baseDir ?? dshHomePath('artifacts')
|
|
129
|
+
this.maxBytes = options.maxBytes ?? 16 * 1024 * 1024
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private artifactsRoot(): string {
|
|
133
|
+
return path.resolve(this.baseDir, 'dsh-artifacts')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private artifactDir(artifactId: string): string {
|
|
137
|
+
// Strict id-only guard: ids are minted by us, but enforce the pattern so
|
|
138
|
+
// no user-derived input can ever construct a path.
|
|
139
|
+
if (!ARTIFACT_ID_PATTERN.test(artifactId)) {
|
|
140
|
+
throw new Error(`Invalid artifactId: does not match ${String(ARTIFACT_ID_PATTERN)}`)
|
|
141
|
+
}
|
|
142
|
+
return path.join(this.artifactsRoot(), artifactId)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async create(input: CreateArtifactInput): Promise<CreateArtifactResult> {
|
|
146
|
+
if (!isValidArtifactType(input.type)) {
|
|
147
|
+
throw new Error(`Invalid artifact type: ${String(input.type)}`)
|
|
148
|
+
}
|
|
149
|
+
if (typeof input.filename !== 'string' || input.filename.length === 0) {
|
|
150
|
+
throw new Error('filename is required')
|
|
151
|
+
}
|
|
152
|
+
const buf = toBytes(input.content, input.contentEncoding)
|
|
153
|
+
if (buf.byteLength > this.maxBytes) {
|
|
154
|
+
throw new Error(`artifact content exceeds max size (${buf.byteLength} > ${this.maxBytes})`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const id = 'art_' + randomUUID().replace(/-/g, '').slice(0, 16)
|
|
158
|
+
const now = new Date()
|
|
159
|
+
const meta: ArtifactMeta = {
|
|
160
|
+
id,
|
|
161
|
+
type: input.type,
|
|
162
|
+
filename: input.filename,
|
|
163
|
+
mimeType: mimeTypeFor(input.type, input.filename),
|
|
164
|
+
size: buf.byteLength,
|
|
165
|
+
createdAt: now.toISOString(),
|
|
166
|
+
}
|
|
167
|
+
if (typeof input.ttlSeconds === 'number' && Number.isFinite(input.ttlSeconds) && input.ttlSeconds > 0) {
|
|
168
|
+
meta.expiresAt = new Date(now.getTime() + input.ttlSeconds * 1000).toISOString()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const dir = this.artifactDir(id)
|
|
172
|
+
await fs.promises.mkdir(dir, { recursive: true })
|
|
173
|
+
// Write content FIRST so an interrupted write never leaves a "valid" meta
|
|
174
|
+
// pointing at partial bytes. Both writes are atomic (tmp + rename).
|
|
175
|
+
const tmpContent = path.join(dir, 'content.tmp')
|
|
176
|
+
await fs.promises.writeFile(tmpContent, buf)
|
|
177
|
+
await fs.promises.rename(tmpContent, path.join(dir, 'content'))
|
|
178
|
+
const tmpMeta = path.join(dir, 'meta.json.tmp')
|
|
179
|
+
await fs.promises.writeFile(tmpMeta, JSON.stringify(meta, null, 2), 'utf8')
|
|
180
|
+
await fs.promises.rename(tmpMeta, path.join(dir, 'meta.json'))
|
|
181
|
+
|
|
182
|
+
log('debug', 'created', { id, type: input.type, size: buf.byteLength })
|
|
183
|
+
return { id, type: meta.type, filename: meta.filename, size: meta.size }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async get(id: string): Promise<ArtifactMeta | null> {
|
|
187
|
+
if (typeof id !== 'string' || !ARTIFACT_ID_PATTERN.test(id)) return null
|
|
188
|
+
try {
|
|
189
|
+
const dir = this.artifactDir(id)
|
|
190
|
+
const fp = path.join(dir, 'meta.json')
|
|
191
|
+
const raw = await fs.promises.readFile(fp, 'utf8')
|
|
192
|
+
const meta = JSON.parse(raw) as ArtifactMeta
|
|
193
|
+
if (this.isExpired(meta)) {
|
|
194
|
+
void this.delete(id) // best-effort cleanup, non-fatal
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
return meta
|
|
198
|
+
} catch {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Returns `null` when missing or expired (miss signal for the HTTP 404). */
|
|
204
|
+
async readContent(id: string): Promise<Uint8Array | null> {
|
|
205
|
+
const meta = await this.get(id)
|
|
206
|
+
if (!meta) return null
|
|
207
|
+
try {
|
|
208
|
+
return new Uint8Array(await fs.promises.readFile(path.join(this.artifactDir(id), 'content')))
|
|
209
|
+
} catch {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async delete(id: string): Promise<void> {
|
|
215
|
+
if (typeof id !== 'string' || !ARTIFACT_ID_PATTERN.test(id)) return
|
|
216
|
+
try {
|
|
217
|
+
const dir = this.artifactDir(id)
|
|
218
|
+
await fs.promises.rm(dir, { recursive: true, force: true })
|
|
219
|
+
log('debug', 'deleted', { id })
|
|
220
|
+
} catch (err) {
|
|
221
|
+
log('warn', 'delete failed', { id, err })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async deleteAll(opts: { onlyExpired?: boolean } = {}): Promise<number> {
|
|
226
|
+
const { onlyExpired = false } = opts
|
|
227
|
+
const root = this.artifactsRoot()
|
|
228
|
+
let removed = 0
|
|
229
|
+
if (!fs.existsSync(root)) return 0
|
|
230
|
+
try {
|
|
231
|
+
const entries = await fs.promises.readdir(root, { withFileTypes: true })
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (!entry.isDirectory()) continue
|
|
234
|
+
if (onlyExpired) {
|
|
235
|
+
try {
|
|
236
|
+
const raw = await fs.promises.readFile(path.join(root, entry.name, 'meta.json'), 'utf8')
|
|
237
|
+
const meta = JSON.parse(raw) as ArtifactMeta
|
|
238
|
+
if (!this.isExpired(meta)) continue
|
|
239
|
+
} catch {
|
|
240
|
+
continue // missing/corrupt meta — leave to admin cleanup
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await fs.promises.rm(path.join(root, entry.name), { recursive: true, force: true })
|
|
244
|
+
removed += 1
|
|
245
|
+
}
|
|
246
|
+
log('debug', 'deleteAll complete', { onlyExpired, removed })
|
|
247
|
+
} catch (err) {
|
|
248
|
+
log('warn', 'deleteAll failed', { err })
|
|
249
|
+
}
|
|
250
|
+
return removed
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private isExpired(meta: ArtifactMeta): boolean {
|
|
254
|
+
if (!meta.expiresAt) return false
|
|
255
|
+
try {
|
|
256
|
+
return new Date(meta.expiresAt).getTime() <= Date.now()
|
|
257
|
+
} catch {
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|