@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 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.
@@ -0,0 +1,5 @@
1
+ # DSH bundle patch: inserts the Huaqiu artifact store + its HTTP routes.
2
+ - insert:
3
+ - id: huaqiu-artifacts
4
+ name: '@huaqiu/dsh-artifacts'
5
+ inject: ['webServer']
@@ -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 };
@@ -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
+ }