@devframes/plugin-assets 0.7.15

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.
@@ -0,0 +1,2 @@
1
+ import { _ as UPLOAD_CHANNEL, a as revealInFolder, c as readImageMeta, d as assetInfoSchema, f as list, g as capabilities, h as deleteAssets, i as writeFunctions, l as openInEditor, n as readFunctions, o as rename, r as serverFunctions, s as readText, t as alwaysFunctions, u as mkdir, v as upload } from "../rpc-lfFykVQ_.mjs";
2
+ export { UPLOAD_CHANNEL, alwaysFunctions, assetInfoSchema, capabilities, deleteAssets, list, mkdir, openInEditor, readFunctions, readImageMeta, readText, rename, revealInFolder, serverFunctions, upload, writeFunctions };
@@ -0,0 +1,489 @@
1
+ import { dirname, extname, join, resolve } from "pathe";
2
+ import { createWriteStream } from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import { createDefineWrapperWithContext } from "devframe/rpc";
5
+ import * as v from "valibot";
6
+ import { defineDiagnostics } from "nostics";
7
+ import { glob } from "tinyglobby";
8
+ import { joinURL } from "ufo";
9
+ import { launchEditor } from "devframe/utils/launch-editor";
10
+ import { imageMeta } from "image-meta";
11
+ import { open } from "devframe/utils/open";
12
+ //#region src/diagnostics.ts
13
+ const diagnostics = defineDiagnostics({
14
+ docsBase: "https://devfra.me/errors",
15
+ codes: {
16
+ DP_ASSETS_0001: {
17
+ why: (p) => `"${p.path}" resolves outside the managed directory.`,
18
+ fix: "Use a path relative to the configured `dir`, without leading `/` or `..` segments."
19
+ },
20
+ DP_ASSETS_0002: {
21
+ why: (p) => `Cannot upload "${p.path}" because the "${p.extension}" extension is not allowed. Allowed extensions: ${p.allowed.join(", ")}.`,
22
+ fix: "Configure `uploadExtensions` on the assets devframe to allow this extension, or pass `'*'` to allow any."
23
+ },
24
+ DP_ASSETS_0003: {
25
+ why: (p) => `Cannot rename to "${p.path}" because a file already exists there.`,
26
+ fix: "Choose a different name."
27
+ },
28
+ DP_ASSETS_0004: {
29
+ why: (p) => `No asset found at "${p.path}".`,
30
+ fix: "Refresh the asset list — it may have been moved or deleted already."
31
+ },
32
+ DP_ASSETS_0005: {
33
+ why: (p) => `Cannot create folder "${p.path}" because a file already exists there.`,
34
+ fix: "Choose a different folder name."
35
+ },
36
+ DP_ASSETS_0006: {
37
+ why: (p) => `"${p.name}" is not a valid file name.`,
38
+ fix: "Names must be non-empty and cannot contain `/` or `\\`."
39
+ },
40
+ DP_ASSETS_0007: {
41
+ why: "The upload streaming channel is unavailable because this devframe was set up with `write: false`.",
42
+ fix: "This indicates an internal registration bug — `upload` should never be reachable without `write: true`. Please report it."
43
+ }
44
+ }
45
+ });
46
+ //#endregion
47
+ //#region src/node/paths.ts
48
+ /**
49
+ * Resolve a client-supplied, root-relative path against the managed
50
+ * directory, rejecting anything that would escape it (`..` traversal, a
51
+ * rogue absolute path, etc.). Every RPC handler that touches the
52
+ * filesystem goes through this — never trust a path from the wire.
53
+ */
54
+ function resolveAssetPath(root, relativePath) {
55
+ const cleaned = relativePath.replace(/^[/\\]+/, "");
56
+ const normalizedRoot = resolve(root);
57
+ const absolute = resolve(normalizedRoot, cleaned);
58
+ if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}/`)) throw diagnostics.DP_ASSETS_0001({ path: relativePath });
59
+ return absolute;
60
+ }
61
+ //#endregion
62
+ //#region src/node/context.ts
63
+ const configs = /* @__PURE__ */ new WeakMap();
64
+ const contexts = /* @__PURE__ */ new WeakMap();
65
+ /**
66
+ * Record the managed directory and write policy for a context. Called from
67
+ * the devframe's `setup` before any RPC handler runs.
68
+ */
69
+ function configureAssets(ctx, config) {
70
+ configs.set(ctx, config);
71
+ contexts.delete(ctx);
72
+ }
73
+ /**
74
+ * Per-`DevframeNodeContext` assets state. Each RPC function pulls its
75
+ * managed directory and path resolver from here instead of re-reading
76
+ * `configureAssets`'s config on every call.
77
+ */
78
+ function getAssetsContext(ctx) {
79
+ const existing = contexts.get(ctx);
80
+ if (existing) return existing;
81
+ const config = configs.get(ctx);
82
+ const dir = config?.dir ?? ctx.cwd;
83
+ const built = {
84
+ dir,
85
+ write: config?.write ?? true,
86
+ uploadExtensions: config?.uploadExtensions ?? "*",
87
+ baseURL: config?.baseURL ?? "/",
88
+ uploadChannel: config?.uploadChannel,
89
+ resolvePath: (relativePath) => resolveAssetPath(dir, relativePath)
90
+ };
91
+ contexts.set(ctx, built);
92
+ return built;
93
+ }
94
+ //#endregion
95
+ //#region src/rpc/functions/upload.ts
96
+ const defineAssetsRpc$9 = createDefineWrapperWithContext();
97
+ /** Streaming channel name, namespaced like every other RPC name in this plugin. */
98
+ const UPLOAD_CHANNEL = "devframes:plugin:assets:upload";
99
+ function isExtensionAllowed(path, allowed) {
100
+ if (allowed === "*") return true;
101
+ const ext = extname(path).slice(1).toLowerCase();
102
+ return allowed.includes(ext);
103
+ }
104
+ /**
105
+ * Allocates an upload and returns a streaming id. Actual bytes flow over
106
+ * the `upload` channel opened in `setupAssets` — see the client-side
107
+ * `useUpload` hook for the matching `rpc.streaming.upload()` call.
108
+ */
109
+ const upload = defineAssetsRpc$9({
110
+ name: "devframes:plugin:assets:upload",
111
+ type: "action",
112
+ jsonSerializable: true,
113
+ args: [v.object({ path: v.string() })],
114
+ returns: v.object({ uploadId: v.string() }),
115
+ agent: {
116
+ title: "Upload an asset",
117
+ description: "Allocate an upload slot for a new file at the given path. The caller streams the bytes over the paired upload channel.",
118
+ safety: "action",
119
+ tags: ["assets"]
120
+ },
121
+ setup: (ctx) => {
122
+ const assets = getAssetsContext(ctx);
123
+ return { handler: (async ({ path }) => {
124
+ if (!isExtensionAllowed(path, assets.uploadExtensions)) throw diagnostics.DP_ASSETS_0002({
125
+ path,
126
+ extension: extname(path).slice(1).toLowerCase() || "(none)",
127
+ allowed: assets.uploadExtensions === "*" ? [] : assets.uploadExtensions
128
+ });
129
+ const absolute = assets.resolvePath(path);
130
+ await fsp.mkdir(dirname(absolute), { recursive: true });
131
+ const channel = assets.uploadChannel;
132
+ if (!channel) throw diagnostics.DP_ASSETS_0007();
133
+ const reader = channel.openInbound();
134
+ (async () => {
135
+ const file = createWriteStream(absolute);
136
+ try {
137
+ for await (const chunk of reader) file.write(chunk);
138
+ } catch {
139
+ file.close();
140
+ await fsp.unlink(absolute).catch(() => {});
141
+ return;
142
+ }
143
+ file.close();
144
+ })();
145
+ return { uploadId: reader.id };
146
+ }) };
147
+ }
148
+ });
149
+ /**
150
+ * Lets the UI know upfront whether upload / rename / delete / mkdir are
151
+ * available, so it can hide or disable those affordances proactively
152
+ * instead of letting the user hit a "method not found" error — the same
153
+ * `canWrite`-gating idea the git plugin's `GitStatus.canWrite` follows.
154
+ */
155
+ const capabilities = createDefineWrapperWithContext()({
156
+ name: "devframes:plugin:assets:capabilities",
157
+ type: "query",
158
+ snapshot: true,
159
+ jsonSerializable: true,
160
+ args: [],
161
+ returns: v.object({
162
+ write: v.boolean(),
163
+ uploadExtensions: v.union([v.array(v.string()), v.literal("*")])
164
+ }),
165
+ agent: {
166
+ title: "Read assets capabilities",
167
+ description: "Read whether upload, rename, delete, and folder creation are enabled, and which upload extensions are allowed.",
168
+ safety: "read",
169
+ tags: ["assets"]
170
+ },
171
+ setup: (ctx) => {
172
+ const assets = getAssetsContext(ctx);
173
+ return { handler: (async () => ({
174
+ write: assets.write,
175
+ uploadExtensions: assets.uploadExtensions
176
+ })) };
177
+ }
178
+ });
179
+ /** One request covers both single- and multi-select delete. */
180
+ const deleteAssets = createDefineWrapperWithContext()({
181
+ name: "devframes:plugin:assets:delete",
182
+ type: "action",
183
+ jsonSerializable: true,
184
+ args: [v.object({ paths: v.array(v.string()) })],
185
+ returns: v.object({ deleted: v.array(v.string()) }),
186
+ agent: {
187
+ title: "Delete assets",
188
+ description: "Delete one or more assets from the managed directory.",
189
+ safety: "destructive",
190
+ tags: ["assets"]
191
+ },
192
+ setup: (ctx) => {
193
+ const assets = getAssetsContext(ctx);
194
+ return { handler: (async ({ paths }) => {
195
+ const deleted = [];
196
+ for (const path of paths) {
197
+ const absolute = assets.resolvePath(path);
198
+ try {
199
+ await fsp.unlink(absolute);
200
+ deleted.push(path);
201
+ } catch (error) {
202
+ if (error.code !== "ENOENT") throw error;
203
+ }
204
+ }
205
+ return { deleted };
206
+ }) };
207
+ }
208
+ });
209
+ //#endregion
210
+ //#region src/node/scanner.ts
211
+ const reImage = /\.(?:png|jpe?g|jxl|gif|svg|webp|avif|ico|bmp|tiff?)$/i;
212
+ const reVideo = /\.(?:mp4|webm|ogv|mov|avi|flv|wmv|mpg|mpeg|mkv|3gp|3g2|ts|mts|m2ts|vob|ogm|ogx|rm|rmvb|asf|amv|divx|m4v|svi|viv|f4v|f4p|f4a|f4b)$/i;
213
+ const reAudio = /\.(?:mp3|wav|ogg|flac|aac|wma|alac|ape|ac3|dts|tta|opus|amr|aiff|au|mid|midi|ra|rm|wv|weba|dss|spx|vox|tak|dsf|dff|dsd|cda)$/i;
214
+ const reFont = /\.(?:woff2?|eot|ttf|otf|ttc|pfa|pfb|pfm|afm)$/i;
215
+ const reText = /\.(?:json[5c]?|te?xt|[mc]?[jt]sx?|md[cx]?|markdown|ya?ml|toml|csv|css|scss|less|styl|html?|xml|vue|svelte|astro|env|ini|conf|sh)$/i;
216
+ /** Classifies a path by extension, driving preview and icon choice. */
217
+ function guessAssetType(path) {
218
+ if (reImage.test(path)) return "image";
219
+ if (reVideo.test(path)) return "video";
220
+ if (reAudio.test(path)) return "audio";
221
+ if (reFont.test(path)) return "font";
222
+ if (reText.test(path)) return "text";
223
+ return "other";
224
+ }
225
+ function toPublicPath(baseURL, posixPath) {
226
+ return joinURL(baseURL, posixPath.split("/").map(encodeURIComponent).join("/"));
227
+ }
228
+ /** Builds an {@link AssetInfo} from an already-resolved `fs.Stats`. */
229
+ function statToAssetInfo(dir, baseURL, relPath, stat) {
230
+ const posixPath = relPath.replace(/\\/g, "/");
231
+ return {
232
+ path: posixPath,
233
+ type: guessAssetType(posixPath),
234
+ publicPath: toPublicPath(baseURL, posixPath),
235
+ size: stat.size,
236
+ mtime: stat.mtimeMs
237
+ };
238
+ }
239
+ /** Recursively lists every file under `dir`, sorted alphabetically by path. */
240
+ async function scanAssets(dir, baseURL) {
241
+ const files = await glob(["**/*"], {
242
+ cwd: dir,
243
+ onlyFiles: true,
244
+ dot: false
245
+ });
246
+ return (await Promise.all(files.map(async (relPath) => {
247
+ try {
248
+ return statToAssetInfo(dir, baseURL, relPath, await fsp.lstat(join(dir, relPath)));
249
+ } catch {
250
+ return;
251
+ }
252
+ }))).filter((info) => info !== void 0).sort((a, b) => a.path.localeCompare(b.path));
253
+ }
254
+ //#endregion
255
+ //#region src/rpc/functions/list.ts
256
+ const defineAssetsRpc$6 = createDefineWrapperWithContext();
257
+ const assetInfoSchema = v.object({
258
+ path: v.string(),
259
+ type: v.picklist([
260
+ "image",
261
+ "font",
262
+ "video",
263
+ "audio",
264
+ "text",
265
+ "other"
266
+ ]),
267
+ publicPath: v.string(),
268
+ size: v.number(),
269
+ mtime: v.number()
270
+ });
271
+ const list = defineAssetsRpc$6({
272
+ name: "devframes:plugin:assets:list",
273
+ type: "query",
274
+ snapshot: true,
275
+ jsonSerializable: true,
276
+ args: [],
277
+ returns: v.array(assetInfoSchema),
278
+ agent: {
279
+ title: "List managed assets",
280
+ description: "List every file under the managed directory with its type, size, and last-modified time.",
281
+ safety: "read",
282
+ tags: ["assets", "files"]
283
+ },
284
+ setup: (ctx) => {
285
+ const assets = getAssetsContext(ctx);
286
+ return { handler: (async () => scanAssets(assets.dir, assets.baseURL)) };
287
+ }
288
+ });
289
+ const mkdir = createDefineWrapperWithContext()({
290
+ name: "devframes:plugin:assets:mkdir",
291
+ type: "action",
292
+ jsonSerializable: true,
293
+ args: [v.object({ path: v.string() })],
294
+ returns: v.void(),
295
+ agent: {
296
+ title: "Create a folder",
297
+ description: "Create a folder under the managed directory, including any missing parent folders.",
298
+ safety: "action",
299
+ tags: ["assets"]
300
+ },
301
+ setup: (ctx) => {
302
+ const assets = getAssetsContext(ctx);
303
+ return { handler: (async ({ path }) => {
304
+ const absolute = assets.resolvePath(path);
305
+ const stat = await fsp.stat(absolute).catch(() => void 0);
306
+ if (stat && !stat.isDirectory()) throw diagnostics.DP_ASSETS_0005({ path });
307
+ await fsp.mkdir(absolute, { recursive: true });
308
+ }) };
309
+ }
310
+ });
311
+ /**
312
+ * Reuses devframe's `launchEditor` utility (the same one backing the core
313
+ * `devframe:open-in-editor` recipe) but resolves the path against the
314
+ * managed directory first, so the client only ever sends a root-relative
315
+ * path — never the server's absolute filesystem layout.
316
+ */
317
+ const openInEditor = createDefineWrapperWithContext()({
318
+ name: "devframes:plugin:assets:open-in-editor",
319
+ type: "action",
320
+ jsonSerializable: true,
321
+ args: [v.string()],
322
+ returns: v.void(),
323
+ agent: {
324
+ title: "Open an asset in the editor",
325
+ description: "Open an asset in the user's configured editor.",
326
+ safety: "action",
327
+ tags: ["assets"]
328
+ },
329
+ setup: (ctx) => {
330
+ const assets = getAssetsContext(ctx);
331
+ return { handler: (async (path) => {
332
+ launchEditor(assets.resolvePath(path));
333
+ }) };
334
+ }
335
+ });
336
+ const readImageMeta = createDefineWrapperWithContext()({
337
+ name: "devframes:plugin:assets:read-image-meta",
338
+ type: "query",
339
+ jsonSerializable: true,
340
+ args: [v.string()],
341
+ returns: v.nullable(v.object({
342
+ width: v.optional(v.number()),
343
+ height: v.optional(v.number()),
344
+ orientation: v.optional(v.number())
345
+ })),
346
+ agent: {
347
+ title: "Read image dimensions",
348
+ description: "Read width, height, and orientation for an image asset.",
349
+ safety: "read",
350
+ tags: ["assets", "images"]
351
+ },
352
+ setup: (ctx) => {
353
+ const assets = getAssetsContext(ctx);
354
+ return { handler: (async (path) => {
355
+ try {
356
+ const meta = imageMeta(await fsp.readFile(assets.resolvePath(path)));
357
+ return {
358
+ width: meta.width,
359
+ height: meta.height,
360
+ orientation: meta.orientation
361
+ };
362
+ } catch {
363
+ return null;
364
+ }
365
+ }) };
366
+ }
367
+ });
368
+ //#endregion
369
+ //#region src/rpc/functions/read-text.ts
370
+ const defineAssetsRpc$2 = createDefineWrapperWithContext();
371
+ const DEFAULT_LIMIT = 5e3;
372
+ const readText = defineAssetsRpc$2({
373
+ name: "devframes:plugin:assets:read-text",
374
+ type: "query",
375
+ jsonSerializable: true,
376
+ args: [v.string(), v.optional(v.number())],
377
+ returns: v.nullable(v.string()),
378
+ agent: {
379
+ title: "Read a text asset",
380
+ description: "Read the (possibly truncated) text content of a text-type asset, for preview or editing.",
381
+ safety: "read",
382
+ tags: ["assets", "text"]
383
+ },
384
+ setup: (ctx) => {
385
+ const assets = getAssetsContext(ctx);
386
+ return { handler: (async (path, limit = DEFAULT_LIMIT) => {
387
+ try {
388
+ return (await fsp.readFile(assets.resolvePath(path), "utf-8")).slice(0, limit);
389
+ } catch {
390
+ return null;
391
+ }
392
+ }) };
393
+ }
394
+ });
395
+ const rename = createDefineWrapperWithContext()({
396
+ name: "devframes:plugin:assets:rename",
397
+ type: "action",
398
+ jsonSerializable: true,
399
+ args: [v.object({
400
+ path: v.string(),
401
+ newName: v.string()
402
+ })],
403
+ returns: assetInfoSchema,
404
+ agent: {
405
+ title: "Rename an asset",
406
+ description: "Rename an asset, keeping it in the same folder.",
407
+ safety: "destructive",
408
+ tags: ["assets"]
409
+ },
410
+ setup: (ctx) => {
411
+ const assets = getAssetsContext(ctx);
412
+ return { handler: (async ({ path, newName }) => {
413
+ const trimmed = newName.trim();
414
+ if (!trimmed || /[/\\]/.test(trimmed)) throw diagnostics.DP_ASSETS_0006({ name: newName });
415
+ const folder = dirname(path);
416
+ const nextName = `${trimmed}${extname(path)}`;
417
+ const nextRelPath = folder === "." ? nextName : `${folder}/${nextName}`;
418
+ const from = assets.resolvePath(path);
419
+ const to = assets.resolvePath(nextRelPath);
420
+ if (from === to) {
421
+ const stat = await fsp.lstat(from);
422
+ return statToAssetInfo(assets.dir, assets.baseURL, path, stat);
423
+ }
424
+ if (await fsp.access(to).then(() => true).catch(() => false)) throw diagnostics.DP_ASSETS_0003({ path: nextRelPath });
425
+ await fsp.mkdir(dirname(to), { recursive: true });
426
+ try {
427
+ await fsp.rename(from, to);
428
+ } catch (error) {
429
+ if (error.code === "ENOENT") throw diagnostics.DP_ASSETS_0004({ path });
430
+ throw error;
431
+ }
432
+ const stat = await fsp.lstat(to);
433
+ return statToAssetInfo(assets.dir, assets.baseURL, nextRelPath, stat);
434
+ }) };
435
+ }
436
+ });
437
+ /**
438
+ * Reuses devframe's `open` utility (the same one backing the core
439
+ * `devframe:open-in-finder` recipe), opening the asset's containing folder
440
+ * so it's revealed in the OS file manager rather than launched with its
441
+ * default app (which `download` already covers).
442
+ */
443
+ const revealInFolder = createDefineWrapperWithContext()({
444
+ name: "devframes:plugin:assets:reveal-in-folder",
445
+ type: "action",
446
+ jsonSerializable: true,
447
+ args: [v.string()],
448
+ returns: v.void(),
449
+ agent: {
450
+ title: "Reveal an asset in the file manager",
451
+ description: "Open the OS file manager at the asset's containing folder.",
452
+ safety: "action",
453
+ tags: ["assets"]
454
+ },
455
+ setup: (ctx) => {
456
+ const assets = getAssetsContext(ctx);
457
+ return { handler: (async (path) => {
458
+ await open(dirname(assets.resolvePath(path)));
459
+ }) };
460
+ }
461
+ });
462
+ //#endregion
463
+ //#region src/rpc/index.ts
464
+ /** Read-only RPC — always registered. */
465
+ const readFunctions = [
466
+ list,
467
+ readImageMeta,
468
+ readText,
469
+ capabilities
470
+ ];
471
+ /**
472
+ * Informational actions — launch external apps, never touch the managed
473
+ * directory's contents. Always registered regardless of `write`.
474
+ */
475
+ const alwaysFunctions = [openInEditor, revealInFolder];
476
+ /** Mutating RPC — registered only when write actions are enabled. */
477
+ const writeFunctions = [
478
+ upload,
479
+ rename,
480
+ deleteAssets,
481
+ mkdir
482
+ ];
483
+ const serverFunctions = [
484
+ ...readFunctions,
485
+ ...alwaysFunctions,
486
+ ...writeFunctions
487
+ ];
488
+ //#endregion
489
+ export { UPLOAD_CHANNEL as _, revealInFolder as a, getAssetsContext as b, readImageMeta as c, assetInfoSchema as d, list as f, capabilities as g, deleteAssets as h, writeFunctions as i, openInEditor as l, scanAssets as m, readFunctions as n, rename as o, guessAssetType as p, serverFunctions as r, readText as s, alwaysFunctions as t, mkdir as u, upload as v, resolveAssetPath as x, configureAssets as y };