From 1d6a0f612fabd932987d551d32d3eaa0b41ad2dc Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 04:04:56 +0000 Subject: [PATCH 01/10] feat(assets): port Nuxt DevTools' Assets tab as a framework-neutral plugin Adds @devframes/plugin-assets, a Preact SPA that browses, previews, uploads, renames, and deletes the files in a managed directory (default /public). Full RPC surface (list/capabilities/read-text/ read-image-meta/upload/rename/delete/mkdir/write-text/open-in-editor/ reveal-in-folder), a live chokidar-backed change broadcast, real byte serving via ctx.views.hostStatic(), and binary uploads over devframe's streaming-channel primitive. Preact ports the same @antfu/design class vocabulary every other plugin uses. Core changes in support of this: - DevframeDefinition.capabilities.build now actually does something: createCac skips the build subcommand when it's false, and createBuild throws DF0042 for the same case unless {force: true} is passed. The assets plugin uses this by default since a static export can never carry real bytes or write actions. - Wired into alias.ts/tsconfig.base.json/turbo.json/vitest.config.ts and dogfooded in examples/minimal-vite-devframe-hub. Co-authored-by: opencode --- alias.ts | 6 + docs/.vitepress/config.ts | 1 + docs/errors/DF0042.md | 32 + docs/plugins/assets.md | 84 ++ docs/plugins/index.md | 4 +- .../minimal-vite-devframe-hub/package.json | 1 + .../minimal-vite-devframe-hub/vite.config.ts | 2 + .../src/adapters/__tests__/build.test.ts | 63 + .../src/adapters/__tests__/cac.test.ts | 45 + packages/devframe/src/adapters/build.ts | 11 + packages/devframe/src/adapters/cac.ts | 21 +- packages/devframe/src/node/diagnostics.ts | 4 + packages/devframe/src/types/devframe.ts | 10 + plugins/assets/.storybook/main.ts | 22 + plugins/assets/.storybook/preview.tsx | 43 + plugins/assets/.storybook/shims.d.ts | 3 + plugins/assets/README.md | 11 + plugins/assets/bin.mjs | 12 + plugins/assets/package.json | 89 ++ plugins/assets/src/cli.ts | 7 + plugins/assets/src/client/index.ts | 8 + plugins/assets/src/constants.ts | 5 + plugins/assets/src/diagnostics.ts | 38 + plugins/assets/src/index.ts | 112 ++ plugins/assets/src/node/context.ts | 56 + plugins/assets/src/node/index.ts | 75 + plugins/assets/src/node/paths.ts | 17 + plugins/assets/src/node/scanner.ts | 65 + plugins/assets/src/node/watcher.ts | 33 + .../assets/src/rpc/functions/capabilities.ts | 44 + plugins/assets/src/rpc/functions/delete.ts | 45 + plugins/assets/src/rpc/functions/list.ts | 40 + plugins/assets/src/rpc/functions/mkdir.ts | 35 + .../src/rpc/functions/open-in-editor.ts | 36 + .../src/rpc/functions/read-image-meta.ts | 43 + plugins/assets/src/rpc/functions/read-text.ts | 38 + plugins/assets/src/rpc/functions/rename.ts | 77 + .../src/rpc/functions/reveal-in-folder.ts | 37 + plugins/assets/src/rpc/functions/upload.ts | 80 ++ .../assets/src/rpc/functions/write-text.ts | 41 + plugins/assets/src/rpc/index.ts | 44 + plugins/assets/src/spa/app/App.tsx | 214 +++ .../app/components/AssetDetails.stories.tsx | 44 + .../src/spa/app/components/AssetDetails.tsx | 266 ++++ .../src/spa/app/components/AssetGrid.tsx | 63 + .../app/components/AssetGridItem.stories.tsx | 33 + .../src/spa/app/components/AssetGridItem.tsx | 29 + .../src/spa/app/components/AssetListItem.tsx | 63 + .../app/components/AssetPreview.stories.tsx | 64 + .../src/spa/app/components/AssetPreview.tsx | 75 + .../src/spa/app/components/AssetTree.tsx | 31 + .../app/components/CodeSnippets.stories.tsx | 29 + .../src/spa/app/components/CodeSnippets.tsx | 41 + .../src/spa/app/components/DropZone.tsx | 132 ++ .../spa/app/components/ExtensionFilter.tsx | 38 + .../src/spa/app/components/FontPreview.tsx | 25 + .../src/spa/app/components/SectionBlock.tsx | 25 + .../spa/app/components/Toolbar.stories.tsx | 39 + .../assets/src/spa/app/components/Toolbar.tsx | 76 + .../src/spa/app/components/ui/Badge.tsx | 13 + .../src/spa/app/components/ui/Button.tsx | 13 + .../src/spa/app/components/ui/Checkbox.tsx | 15 + .../src/spa/app/components/ui/Dialog.tsx | 35 + .../src/spa/app/components/ui/Drawer.tsx | 40 + .../assets/src/spa/app/components/ui/Icon.tsx | 9 + .../src/spa/app/components/ui/IconButton.tsx | 17 + .../assets/src/spa/app/components/ui/Tabs.tsx | 37 + .../src/spa/app/components/ui/TextInput.tsx | 10 + plugins/assets/src/spa/app/design.ts | 3 + plugins/assets/src/spa/app/hooks/useAssets.ts | 98 ++ .../src/spa/app/hooks/useLocalStorage.ts | 26 + plugins/assets/src/spa/app/hooks/useUpload.ts | 54 + plugins/assets/src/spa/app/utils/format.ts | 46 + plugins/assets/src/spa/app/utils/snippets.ts | 33 + plugins/assets/src/spa/app/utils/tree.ts | 65 + plugins/assets/src/spa/index.html | 13 + plugins/assets/src/spa/main.tsx | 19 + plugins/assets/src/spa/shims.d.ts | 3 + plugins/assets/src/spa/vite.config.ts | 22 + plugins/assets/src/types.ts | 50 + plugins/assets/src/vite.ts | 16 + plugins/assets/test/_utils.ts | 118 ++ plugins/assets/test/assets.test.ts | 191 +++ plugins/assets/tsconfig.json | 14 + plugins/assets/tsdown.config.ts | 41 + plugins/assets/uno.config.ts | 15 + pnpm-lock.yaml | 167 +++ pnpm-workspace.yaml | 3 + .../plugin-assets/cli.snapshot.d.ts | 6 + .../@devframes/plugin-assets/cli.snapshot.js | 6 + .../plugin-assets/client.snapshot.d.ts | 13 + .../plugin-assets/client.snapshot.js | 6 + .../plugin-assets/index.snapshot.d.ts | 52 + .../plugin-assets/index.snapshot.js | 15 + .../plugin-assets/node.snapshot.d.ts | 21 + .../@devframes/plugin-assets/node.snapshot.js | 12 + .../plugin-assets/rpc.snapshot.d.ts | 1239 +++++++++++++++++ .../@devframes/plugin-assets/rpc.snapshot.js | 22 + .../plugin-assets/vite.snapshot.d.ts | 14 + .../@devframes/plugin-assets/vite.snapshot.js | 6 + .../devframe/adapters/build.snapshot.d.ts | 1 + tsconfig.base.json | 18 + turbo.json | 8 +- vitest.config.ts | 1 + 104 files changed, 5343 insertions(+), 10 deletions(-) create mode 100644 docs/errors/DF0042.md create mode 100644 docs/plugins/assets.md create mode 100644 packages/devframe/src/adapters/__tests__/build.test.ts create mode 100644 packages/devframe/src/adapters/__tests__/cac.test.ts create mode 100644 plugins/assets/.storybook/main.ts create mode 100644 plugins/assets/.storybook/preview.tsx create mode 100644 plugins/assets/.storybook/shims.d.ts create mode 100644 plugins/assets/README.md create mode 100755 plugins/assets/bin.mjs create mode 100644 plugins/assets/package.json create mode 100644 plugins/assets/src/cli.ts create mode 100644 plugins/assets/src/client/index.ts create mode 100644 plugins/assets/src/constants.ts create mode 100644 plugins/assets/src/diagnostics.ts create mode 100644 plugins/assets/src/index.ts create mode 100644 plugins/assets/src/node/context.ts create mode 100644 plugins/assets/src/node/index.ts create mode 100644 plugins/assets/src/node/paths.ts create mode 100644 plugins/assets/src/node/scanner.ts create mode 100644 plugins/assets/src/node/watcher.ts create mode 100644 plugins/assets/src/rpc/functions/capabilities.ts create mode 100644 plugins/assets/src/rpc/functions/delete.ts create mode 100644 plugins/assets/src/rpc/functions/list.ts create mode 100644 plugins/assets/src/rpc/functions/mkdir.ts create mode 100644 plugins/assets/src/rpc/functions/open-in-editor.ts create mode 100644 plugins/assets/src/rpc/functions/read-image-meta.ts create mode 100644 plugins/assets/src/rpc/functions/read-text.ts create mode 100644 plugins/assets/src/rpc/functions/rename.ts create mode 100644 plugins/assets/src/rpc/functions/reveal-in-folder.ts create mode 100644 plugins/assets/src/rpc/functions/upload.ts create mode 100644 plugins/assets/src/rpc/functions/write-text.ts create mode 100644 plugins/assets/src/rpc/index.ts create mode 100644 plugins/assets/src/spa/app/App.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetDetails.stories.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetDetails.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetGrid.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetGridItem.stories.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetGridItem.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetListItem.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetPreview.stories.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetPreview.tsx create mode 100644 plugins/assets/src/spa/app/components/AssetTree.tsx create mode 100644 plugins/assets/src/spa/app/components/CodeSnippets.stories.tsx create mode 100644 plugins/assets/src/spa/app/components/CodeSnippets.tsx create mode 100644 plugins/assets/src/spa/app/components/DropZone.tsx create mode 100644 plugins/assets/src/spa/app/components/ExtensionFilter.tsx create mode 100644 plugins/assets/src/spa/app/components/FontPreview.tsx create mode 100644 plugins/assets/src/spa/app/components/SectionBlock.tsx create mode 100644 plugins/assets/src/spa/app/components/Toolbar.stories.tsx create mode 100644 plugins/assets/src/spa/app/components/Toolbar.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/Badge.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/Button.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/Checkbox.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/Dialog.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/Drawer.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/Icon.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/IconButton.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/Tabs.tsx create mode 100644 plugins/assets/src/spa/app/components/ui/TextInput.tsx create mode 100644 plugins/assets/src/spa/app/design.ts create mode 100644 plugins/assets/src/spa/app/hooks/useAssets.ts create mode 100644 plugins/assets/src/spa/app/hooks/useLocalStorage.ts create mode 100644 plugins/assets/src/spa/app/hooks/useUpload.ts create mode 100644 plugins/assets/src/spa/app/utils/format.ts create mode 100644 plugins/assets/src/spa/app/utils/snippets.ts create mode 100644 plugins/assets/src/spa/app/utils/tree.ts create mode 100644 plugins/assets/src/spa/index.html create mode 100644 plugins/assets/src/spa/main.tsx create mode 100644 plugins/assets/src/spa/shims.d.ts create mode 100644 plugins/assets/src/spa/vite.config.ts create mode 100644 plugins/assets/src/types.ts create mode 100644 plugins/assets/src/vite.ts create mode 100644 plugins/assets/test/_utils.ts create mode 100644 plugins/assets/test/assets.test.ts create mode 100644 plugins/assets/tsconfig.json create mode 100644 plugins/assets/tsdown.config.ts create mode 100644 plugins/assets/uno.config.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/cli.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/cli.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/index.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/node.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/node.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/vite.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-assets/vite.snapshot.js diff --git a/alias.ts b/alias.ts index 96f5805c..508f958f 100644 --- a/alias.ts +++ b/alias.ts @@ -105,6 +105,12 @@ export const alias = { '@devframes/plugin-messages/cli': p('messages/src/cli.ts'), '@devframes/plugin-messages/vite': p('messages/src/vite.ts'), '@devframes/plugin-messages': p('messages/src/index.ts'), + '@devframes/plugin-assets/client': p('assets/src/client/index.ts'), + '@devframes/plugin-assets/node': p('assets/src/node/index.ts'), + '@devframes/plugin-assets/rpc': p('assets/src/rpc/index.ts'), + '@devframes/plugin-assets/cli': p('assets/src/cli.ts'), + '@devframes/plugin-assets/vite': p('assets/src/vite.ts'), + '@devframes/plugin-assets': p('assets/src/index.ts'), } // update tsconfig.base.json — CSS aliases exist for Vite resolution only; diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0c63445a..6d489910 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -70,6 +70,7 @@ function pluginsItems(prefix: string) { { text: 'Git', link: `${prefix}/plugins/git` }, { text: 'Terminals', link: `${prefix}/plugins/terminals` }, { text: 'Code Server', link: `${prefix}/plugins/code-server` }, + { text: 'Assets', link: `${prefix}/plugins/assets` }, ] satisfies DefaultTheme.NavItemWithLink[] } diff --git a/docs/errors/DF0042.md b/docs/errors/DF0042.md new file mode 100644 index 00000000..96c364c4 --- /dev/null +++ b/docs/errors/DF0042.md @@ -0,0 +1,32 @@ +--- +outline: deep +--- + +# DF0042: Static Build Disabled By The Definition + +## Message + +> "`{id}`" declares `capabilities.build: false` — its static export is not meaningful (writes are excluded and any live-served data won't be there). + +## Cause + +`createBuild` runs unconditionally when called directly, but a definition can opt out of static export via `capabilities.build: false` — typically because the devframe is inherently live (it manages real files on disk, spawns a process, etc.) and a `mode: 'build'` export would only ever produce a broken, write-less shell of the tool. `createCac` already skips registering the `build` subcommand for such a definition; this diagnostic covers the remaining path — a caller invoking `createBuild()` directly, bypassing the CLI. + +## Example + +```ts +// ✗ Bad — builds a devframe that opted out of static export +await createBuild(assetsDevframe) // throws DF0042 + +// ✓ Good — the degraded export is still useful to you +await createBuild(assetsDevframe, { force: true }) +``` + +## Fix + +- Pass `{ force: true }` to `createBuild()` if the degraded export is still useful to you. +- Otherwise, drop `capabilities.build: false` on the definition if a static export should be supported after all. + +## Source + +- [`packages/devframe/src/adapters/build.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/build.ts) — `createBuild()` throws this when `capabilities.build` is `false` and `force` isn't set. diff --git a/docs/plugins/assets.md b/docs/plugins/assets.md new file mode 100644 index 00000000..62a85660 --- /dev/null +++ b/docs/plugins/assets.md @@ -0,0 +1,84 @@ +--- +outline: deep +--- + +# Assets + +Browse, preview, upload, rename, and delete the files in a directory, built as a **Preact** SPA — a framework-neutral port of Nuxt DevTools' Assets tab. + +Package: `@devframes/plugin-assets` · framework: **Preact** + +## What it does + +Search and filter by extension, switch between a thumbnail grid (grouped by folder) and a file tree, and open a details panel with a live preview (image, video, audio, font, or text), file metadata, and ready-to-copy usage snippets (``, CSS `background-image`, `@font-face`, a download link). Drag-and-drop files to upload them, or select multiple assets to delete them together. A live file watcher keeps every connected client's listing in sync with changes made outside the UI. + +The standalone server requires devframe's trust handshake by default because it can read, write, and delete real files. Uploads, renames, deletes, and folder creation are enabled by default — pass `{ write: false }` (or `--read-only` on the standalone CLI) for a browse-only deployment. + +## Standalone + +```sh +pnpx @devframes/plugin-assets # manages /public +pnpx @devframes/plugin-assets --read-only # disable upload / rename / delete / mkdir +``` + +## Mount into a Vite host + +```ts +// vite.config.ts +import { assetsVitePlugin } from '@devframes/plugin-assets/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + assetsVitePlugin(), + ], +}) +``` + +## Programmatic + +`createAssetsDevframe(options)` returns a definition you can deploy through any adapter: + +```ts +import { createAssetsDevframe } from '@devframes/plugin-assets' + +export default createAssetsDevframe({ + dir: 'static', // defaults to `/public` + write: true, + uploadExtensions: ['png', 'jpg', 'svg', 'webp'], // defaults to Nuxt DevTools' own allow-list, or '*' for any +}) +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `dir` | `/public` | Directory this devframe manages. | +| `write` | `true` | Enable upload, rename, delete, and folder creation from the UI. | +| `uploadExtensions` | Nuxt DevTools' allow-list | Extensions `upload` accepts, or `'*'` for any. | +| `build` | `false` | Register the `build` CLI subcommand. See [why it's off by default](#static-export) below. | + +## RPC surface + +All functions are namespaced `devframes:plugin:assets:*`: + +| Function | Type | Notes | +|----------|------|-------| +| `list` | `query`, `snapshot: true` | Every file under the managed directory, with type, size, and last-modified time. | +| `capabilities` | `query`, `snapshot: true` | Whether write actions are enabled, and the upload allow-list — lets the UI gate itself proactively. | +| `read-image-meta` | `query` | Width, height, and orientation for an image asset. | +| `read-text` | `query` | Truncated text content, for preview or editing. | +| `upload` | `action` | Allocates a streaming upload slot; the client pipes the file's bytes over the paired channel. | +| `rename` | `action` | Renames an asset within its folder, preserving its extension. | +| `delete` | `action` | Deletes one or more assets in a single call. | +| `mkdir` | `action` | Creates a folder, including missing parents. | +| `write-text` | `action` | Overwrites a text asset's content in place (the details panel's inline editor). | +| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager. Always registered, regardless of `write`. | + +`upload` / `rename` / `delete` / `mkdir` / `write-text` are registered only when `write` is enabled. + +## Static export + +Every devframe's `build` CLI subcommand is disabled here by default (`capabilities: { build: false }`). Real byte serving for previews goes through `ctx.views.hostStatic()`, which only mounts real files under a live adapter (`cli` / `vite` / `embedded`) — a static export can never copy those bytes, and every write action is inherently excluded from a static dump. Rather than ship a broken, preview-less, write-less shell of the tool, the `build` command is simply not registered. Pass `{ build: true }` to `createAssetsDevframe()` (and `{ force: true }` if calling `createBuild()` directly) if that degraded export is still useful to you — the file listing itself still bakes into the static RPC dump. + +## Source + +[`plugins/assets`](https://github.com/devframes/devframe/tree/main/plugins/assets) diff --git a/docs/plugins/index.md b/docs/plugins/index.md index afccdc2c..176a1e7d 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -17,10 +17,11 @@ Each plugin is built with a **different UI framework**. That is deliberate: devf | [Git](./git) | React (Next.js) | A repository dashboard — status, a commit graph, branches, and diffs, with optional staging and committing. | | [Terminals](./terminals) | Svelte | Stream read-only command output and run fully interactive PTY shells in the browser. | | [Code Server](./code-server) | Vue | Launch VS Code in the browser (code-server, `code serve-web`, or a tunnel) on demand and embed it in an auto-authenticated iframe. | +| [Assets](./assets) | Preact | Browse, preview, upload, rename, and delete the files in a managed directory. | ## One client, any framework -The collection spans Vue, Solid, React, and Svelte, yet every plugin shares the same node-side surface: register RPC functions, publish shared state, and connect from the browser with `connectDevframe`. Whatever renders the UI, it talks to the backend through the same protocol — the framework-free Vite hub example drives it with a handful of DOM calls. +The collection spans Vue, Solid, React, Svelte, and Preact, yet every plugin shares the same node-side surface: register RPC functions, publish shared state, and connect from the browser with `connectDevframe`. Whatever renders the UI, it talks to the backend through the same protocol — the framework-free Vite hub example drives it with a handful of DOM calls. This is the framework-agnostic promise in practice. The browser bundle is the author's to choose; devframe handles the transport, the data model, the adapters, and the agent surface underneath. @@ -32,6 +33,7 @@ Most plugins publish a `bin`, so the quickest path is `pnpx`: pnpx @devframes/plugin-inspect # the Devframe Inspector, standalone pnpx @devframes/plugin-og # inspect Open Graph metadata and social cards pnpx @devframes/plugin-git # the Git dashboard against the current repo +pnpx @devframes/plugin-assets # manage the files under /public ``` Each also exports a `create…Devframe` factory (or, for the Accessibility Inspector, a ready-made definition) you can drive through any adapter — see the individual pages for the factory name, options, and host-mount snippets. diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json index ceef72c7..2798026c 100644 --- a/examples/minimal-vite-devframe-hub/package.json +++ b/examples/minimal-vite-devframe-hub/package.json @@ -16,6 +16,7 @@ "@devframes/json-render": "workspace:*", "@devframes/json-render-ui": "workspace:*", "@devframes/plugin-a11y": "workspace:*", + "@devframes/plugin-assets": "workspace:*", "@devframes/plugin-code-server": "workspace:*", "@devframes/plugin-data-inspector": "workspace:*", "@devframes/plugin-git": "workspace:*", diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index d9a2223e..77657f95 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -1,6 +1,7 @@ import { mountDevframe } from '@devframes/hub/node' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' +import assetsDevframe from '@devframes/plugin-assets' import codeServerDevframe from '@devframes/plugin-code-server' import dataInspectorDevframe from '@devframes/plugin-data-inspector' import { registerDataSource } from '@devframes/plugin-data-inspector/registry' @@ -72,6 +73,7 @@ export default defineConfig({ a11yDevframe, messagesDevframe, ogDevframe, + assetsDevframe, ], // Attach the a11y inspector's in-page agent as its dock's client script. // The hub client runtime (booted in src/client/main.ts) imports it into diff --git a/packages/devframe/src/adapters/__tests__/build.test.ts b/packages/devframe/src/adapters/__tests__/build.test.ts new file mode 100644 index 00000000..516ac687 --- /dev/null +++ b/packages/devframe/src/adapters/__tests__/build.test.ts @@ -0,0 +1,63 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { defineDevframe } from '../../types/devframe' +import { createBuild } from '../build' + +function makeTmpDist(): string { + const dir = mkdtempSync(join(tmpdir(), 'devframe-build-test-')) + writeFileSync(join(dir, 'index.html'), 'test', 'utf-8') + return dir +} + +function baseDevframe(overrides: Partial> = {}) { + const distDir = makeTmpDist() + return defineDevframe({ + id: 'devframe-build-test', + name: 'Devframe Build Test', + version: '0.0.0', + packageName: 'devframe-build-test', + homepage: 'https://example.test', + description: 'Test devframe.', + cli: { distDir }, + setup: () => {}, + ...overrides, + }) +} + +describe('adapters/build', () => { + it('rejects a definition with capabilities.build: false by default', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-')) + try { + await expect( + createBuild(baseDevframe({ capabilities: { build: false } }), { outDir }), + ).rejects.toThrow(/capabilities\.build: false/) + } + finally { + rmSync(outDir, { recursive: true, force: true }) + } + }) + + it('proceeds past capabilities.build: false when force is set', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-')) + try { + await expect( + createBuild(baseDevframe({ capabilities: { build: false } }), { outDir, force: true }), + ).resolves.toBeUndefined() + } + finally { + rmSync(outDir, { recursive: true, force: true }) + } + }) + + it('proceeds normally when capabilities.build is unset', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-')) + try { + await expect(createBuild(baseDevframe(), { outDir })).resolves.toBeUndefined() + } + finally { + rmSync(outDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/devframe/src/adapters/__tests__/cac.test.ts b/packages/devframe/src/adapters/__tests__/cac.test.ts new file mode 100644 index 00000000..c0eb9ea6 --- /dev/null +++ b/packages/devframe/src/adapters/__tests__/cac.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { defineDevframe } from '../../types/devframe' +import { createCac } from '../cac' + +function baseDevframe(overrides: Partial> = {}) { + return defineDevframe({ + id: 'devframe-test', + name: 'Devframe Test', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + ...overrides, + }) +} + +describe('adapters/cac', () => { + it('registers the build subcommand by default', () => { + const { cli } = createCac(baseDevframe()) + expect(cli.commands.map(c => c.name)).toContain('build') + }) + + it('skips the build subcommand when capabilities.build is false', () => { + const { cli } = createCac(baseDevframe({ capabilities: { build: false } })) + expect(cli.commands.map(c => c.name)).not.toContain('build') + }) + + it('still registers build when capabilities.build is true or a record', () => { + const truthy = createCac(baseDevframe({ capabilities: { build: true } })) + expect(truthy.cli.commands.map(c => c.name)).toContain('build') + + const record = createCac(baseDevframe({ capabilities: { build: { dump: true } } })) + expect(record.cli.commands.map(c => c.name)).toContain('build') + }) + + it('always registers the dev and mcp commands regardless of capabilities.build', () => { + const { cli } = createCac(baseDevframe({ capabilities: { build: false } })) + const names = cli.commands.map(c => c.name) + expect(names).toContain('mcp') + // The dev command is registered as the catch-all `[...args]` command, + // which cac surfaces with an empty `name`. + expect(cli.commands.some(c => c.rawName === '[...args]')).toBe(true) + }) +}) diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index 3ea701ef..5b833d08 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -12,6 +12,7 @@ import { DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, } from '../constants' import { createHostContext } from '../node/context' +import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { collectStaticRpcDump } from '../rpc/dump/static' import { strictJsonStringify } from '../rpc/serialization' @@ -34,6 +35,13 @@ export interface CreateBuildOptions { * minified. Set `true` when you need to diff / read the dumps by hand. */ pretty?: boolean + /** + * Proceed even when the definition declares `capabilities.build: false`. + * `createCac` already skips registering the `build` subcommand for such + * a definition — this only matters for a caller invoking `createBuild` + * directly, bypassing the CLI. + */ + force?: boolean } /** @@ -50,6 +58,9 @@ export interface CreateBuildOptions { * works at `/`, `/devframe/`, or any base, no rewriting required. */ export async function createBuild(d: DevframeDefinition, options: CreateBuildOptions = {}): Promise { + if (d.capabilities?.build === false && !options.force) + throw diagnostics.DF0042({ id: d.id }) + const outDir = resolve(options.outDir ?? 'dist-static') const distDir = options.distDir ?? d.cli?.distDir if (!distDir) diff --git a/packages/devframe/src/adapters/cac.ts b/packages/devframe/src/adapters/cac.ts index c51bc05b..a513e220 100644 --- a/packages/devframe/src/adapters/cac.ts +++ b/packages/devframe/src/adapters/cac.ts @@ -108,14 +108,19 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) }) }) - cli - .command('build', 'Build a self-contained static deploy of the devframe') - .option('--out-dir ', 'Output directory', { default: 'dist-static' }) - .option('--base ', 'URL base', { default: '/' }) - .option('--pretty', 'Pretty-print dump JSON (larger on disk)') - .action(async (flags: { outDir: string, base?: string, pretty?: boolean }) => { - await createBuild(d, { outDir: flags.outDir, base: flags.base, pretty: flags.pretty }) - }) + // `capabilities.build === false` opts a devframe out of the `build` + // subcommand entirely, rather than registering it only to have it + // produce a broken or misleading static export. + if (d.capabilities?.build !== false) { + cli + .command('build', 'Build a self-contained static deploy of the devframe') + .option('--out-dir ', 'Output directory', { default: 'dist-static' }) + .option('--base ', 'URL base', { default: '/' }) + .option('--pretty', 'Pretty-print dump JSON (larger on disk)') + .action(async (flags: { outDir: string, base?: string, pretty?: boolean }) => { + await createBuild(d, { outDir: flags.outDir, base: flags.base, pretty: flags.pretty }) + }) + } cli .command('mcp', 'Start an MCP server exposing agent-facing tools (stdio) [experimental]') diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 58d4be24..000247ea 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -76,5 +76,9 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string }) => `A service is already provided under "${p.id}".`, fix: 'Service ids are unique per context. Revoke the existing provider first (the `provide()` call returns a revoke function), or namespace the id with your plugin id to avoid collisions.', }, + DF0042: { + why: (p: { id: string }) => `"${p.id}" declares \`capabilities.build: false\` — its static export is not meaningful (writes are excluded and any live-served data won't be there).`, + fix: 'Pass `{ force: true }` to `createBuild()` if the degraded export is still useful to you, or drop `capabilities.build: false` on the definition.', + }, }, }) diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 5f57777f..cd650357 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -296,6 +296,16 @@ export interface DevframeDefinition { * @default 'warn' */ duplicationStrategy?: DevframeDuplicationStrategy + /** + * Declares which runtimes meaningfully support this devframe. Consulted + * by adapters that can act on a plain `false` before doing any work: + * `createCac` skips registering the `build` subcommand entirely when + * `capabilities.build` is `false` — useful for a devframe whose value is + * inherently live (e.g. it manages real files on disk), so a static + * export would only ever produce a broken, write-less shell of the tool. + * The `Record` shape is reserved for finer-grained + * sub-capabilities and currently unconsumed by any adapter. + */ capabilities?: { dev?: boolean | Record build?: boolean | Record diff --git a/plugins/assets/.storybook/main.ts b/plugins/assets/.storybook/main.ts new file mode 100644 index 00000000..4e5de130 --- /dev/null +++ b/plugins/assets/.storybook/main.ts @@ -0,0 +1,22 @@ +import type { StorybookConfig } from '@storybook/preact-vite' +import preact from '@preact/preset-vite' +import UnoCSS from 'unocss/vite' + +const config: StorybookConfig = { + stories: ['../src/spa/app/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-docs', '@storybook/addon-a11y'], + framework: { + name: '@storybook/preact-vite', + options: {}, + }, + viteFinal(viteConfig) { + viteConfig.plugins ??= [] + viteConfig.plugins.push(preact(), UnoCSS()) + // Dev tool reached from arbitrary hostnames (LAN IPs, tunnels, tailnets), + // e.g. when iframed by the storybook-hub example: accept any Host header. + viteConfig.server = { ...viteConfig.server, allowedHosts: true } + return viteConfig + }, +} + +export default config diff --git a/plugins/assets/.storybook/preview.tsx b/plugins/assets/.storybook/preview.tsx new file mode 100644 index 00000000..cba1411f --- /dev/null +++ b/plugins/assets/.storybook/preview.tsx @@ -0,0 +1,43 @@ +import type { Decorator, Preview } from '@storybook/preact-vite' +import { useEffect } from 'preact/hooks' +import '@antfu/design/styles.css' +import 'virtual:uno.css' + +const withTheme: Decorator = (Story, context) => { + const theme = context.globals.theme ?? 'dark' + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark') + }, [theme]) + return ( +
+
+ +
+
+ ) +} + +const preview: Preview = { + parameters: { + layout: 'fullscreen', + controls: { expanded: true }, + }, + globalTypes: { + theme: { + description: 'Color theme', + defaultValue: 'dark', + toolbar: { + title: 'Theme', + icon: 'contrast', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [withTheme], +} + +export default preview diff --git a/plugins/assets/.storybook/shims.d.ts b/plugins/assets/.storybook/shims.d.ts new file mode 100644 index 00000000..c1fdbad2 --- /dev/null +++ b/plugins/assets/.storybook/shims.d.ts @@ -0,0 +1,3 @@ +// Side-effect style imports used by the Storybook preview. +declare module '*.css' {} +declare module 'virtual:uno.css' {} diff --git a/plugins/assets/README.md b/plugins/assets/README.md new file mode 100644 index 00000000..369dbe99 --- /dev/null +++ b/plugins/assets/README.md @@ -0,0 +1,11 @@ +# `@devframes/plugin-assets` + +Browse, preview, upload, rename, and delete the files in a directory — a framework-neutral port of Nuxt DevTools' Assets tab, built as a **Preact** SPA. + +```sh +pnpx @devframes/plugin-assets +``` + +Manages `/public` by default. The package exports `createAssetsDevframe()` for custom definitions and `assetsVitePlugin()` from `@devframes/plugin-assets/vite` for Vite hosts. Pass `{ dir }` to manage a different directory, or `{ write: false }` (`--read-only` on the standalone CLI) for a browse-only deployment. + +The standalone server requires devframe's trust handshake by default because it can read, write, and delete real files. The `build` CLI subcommand is disabled by default — see the docs page for why. diff --git a/plugins/assets/bin.mjs b/plugins/assets/bin.mjs new file mode 100755 index 00000000..6f40a699 --- /dev/null +++ b/plugins/assets/bin.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createAssetsCli } from './dist/cli.mjs' + +async function main() { + await createAssetsCli().parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/assets/package.json b/plugins/assets/package.json new file mode 100644 index 00000000..e4d2548d --- /dev/null +++ b/plugins/assets/package.json @@ -0,0 +1,89 @@ +{ + "name": "@devframes/plugin-assets", + "type": "module", + "version": "0.7.14", + "description": "Devframe plugin for browsing, previewing, uploading, and managing static assets in a directory — a framework-neutral port of Nuxt DevTools' Assets tab.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "plugins/assets", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devframe-plugin", + "devtools", + "assets", + "static-files" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./client": "./dist/client/index.mjs", + "./cli": "./dist/cli.mjs", + "./node": "./dist/node/index.mjs", + "./rpc": "./dist/rpc/index.mjs", + "./vite": "./dist/vite.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "bin": { + "devframe-assets": "./bin.mjs" + }, + "files": [ + "bin.mjs", + "dist" + ], + "scripts": { + "build": "tsdown && vite build --config src/spa/vite.config.ts", + "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", + "watch": "tsdown --watch", + "storybook": "storybook dev -p 6013", + "storybook:build": "storybook build", + "prepack": "pnpm build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "devframe": "workspace:*", + "vite": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "dependencies": { + "cac": "catalog:deps", + "chokidar": "catalog:deps", + "image-meta": "catalog:deps", + "nostics": "catalog:deps", + "pathe": "catalog:deps", + "perfect-debounce": "catalog:deps", + "tinyglobby": "catalog:deps", + "ufo": "catalog:deps", + "valibot": "catalog:deps" + }, + "devDependencies": { + "@antfu/design": "catalog:frontend", + "@iconify-json/ph": "catalog:frontend", + "@preact/preset-vite": "catalog:build", + "@storybook/addon-a11y": "catalog:storybook", + "@storybook/addon-docs": "catalog:storybook", + "@storybook/preact-vite": "catalog:storybook", + "@types/node": "catalog:types", + "devframe": "workspace:*", + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "preact": "catalog:frontend", + "storybook": "catalog:storybook", + "tsdown": "catalog:build", + "unocss": "catalog:frontend", + "vite": "catalog:build", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/plugins/assets/src/cli.ts b/plugins/assets/src/cli.ts new file mode 100644 index 00000000..cebf16eb --- /dev/null +++ b/plugins/assets/src/cli.ts @@ -0,0 +1,7 @@ +import type { CacHandle } from 'devframe/adapters/cac' +import { createCac } from 'devframe/adapters/cac' +import assetsDevframe from './index' + +export function createAssetsCli(): CacHandle { + return createCac(assetsDevframe) +} diff --git a/plugins/assets/src/client/index.ts b/plugins/assets/src/client/index.ts new file mode 100644 index 00000000..16674c77 --- /dev/null +++ b/plugins/assets/src/client/index.ts @@ -0,0 +1,8 @@ +import type { DevframeRpcClientOptions } from 'devframe/client' +import { connectDevframe } from 'devframe/client' + +export type { AssetImageMeta, AssetInfo, AssetType, CodeSnippet } from '../types' + +export function connectAssets(options?: DevframeRpcClientOptions) { + return connectDevframe(options) +} diff --git a/plugins/assets/src/constants.ts b/plugins/assets/src/constants.ts new file mode 100644 index 00000000..991b3482 --- /dev/null +++ b/plugins/assets/src/constants.ts @@ -0,0 +1,5 @@ +/** Broadcast whenever a file is added, removed, or changed under the managed directory. */ +export const CHANGED_EVENT = 'devframes:plugin:assets:changed' + +/** Preferred standalone CLI port. */ +export const DEFAULT_PORT = 9015 diff --git a/plugins/assets/src/diagnostics.ts b/plugins/assets/src/diagnostics.ts new file mode 100644 index 00000000..fad59a0b --- /dev/null +++ b/plugins/assets/src/diagnostics.ts @@ -0,0 +1,38 @@ +import { defineDiagnostics } from 'nostics' + +// Uses the plugin's own `DP_ASSETS_` prefix per the built-in plugin +// convention, keeping it collision-free with devframe core (`DF`) and the +// hub (`DF8xxx`). +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DP_ASSETS_0001: { + why: (p: { path: string }) => `"${p.path}" resolves outside the managed directory.`, + fix: 'Use a path relative to the configured `dir`, without leading `/` or `..` segments.', + }, + DP_ASSETS_0002: { + why: (p: { path: string, extension: string, allowed: readonly string[] }) => `Cannot upload "${p.path}" because the "${p.extension}" extension is not allowed. Allowed extensions: ${p.allowed.join(', ')}.`, + fix: 'Configure `uploadExtensions` on the assets devframe to allow this extension, or pass `\'*\'` to allow any.', + }, + DP_ASSETS_0003: { + why: (p: { path: string }) => `Cannot rename to "${p.path}" because a file already exists there.`, + fix: 'Choose a different name.', + }, + DP_ASSETS_0004: { + why: (p: { path: string }) => `No asset found at "${p.path}".`, + fix: 'Refresh the asset list — it may have been moved or deleted already.', + }, + DP_ASSETS_0005: { + why: (p: { path: string }) => `Cannot create folder "${p.path}" because a file already exists there.`, + fix: 'Choose a different folder name.', + }, + DP_ASSETS_0006: { + why: (p: { name: string }) => `"${p.name}" is not a valid file name.`, + fix: 'Names must be non-empty and cannot contain `/` or `\\`.', + }, + DP_ASSETS_0007: { + why: 'The upload streaming channel is unavailable because this devframe was set up with `write: false`.', + fix: 'This indicates an internal registration bug — `upload` should never be reachable without `write: true`. Please report it.', + }, + }, +}) diff --git a/plugins/assets/src/index.ts b/plugins/assets/src/index.ts new file mode 100644 index 00000000..fffb280d --- /dev/null +++ b/plugins/assets/src/index.ts @@ -0,0 +1,112 @@ +import type { DevframeDefinition } from 'devframe/types' +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import { dirname, resolve } from 'pathe' +import pkg from '../package.json' with { type: 'json' } +import { DEFAULT_PORT } from './constants' +import { setupAssets } from './node/index' +import { DEFAULT_ALLOWED_UPLOAD_EXTENSIONS } from './types' + +export type { AssetImageMeta, AssetInfo, AssetType, CodeSnippet } from './types' +export { DEFAULT_ALLOWED_UPLOAD_EXTENSIONS } from './types' + +// Package root, resolved one level up from this module — which sits at +// `/src/index.ts` in dev and `/dist/index.mjs` once built, so +// the bundled SPA is always `/dist/spa`. +const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url))) + +const DEFAULT_ID = 'devframes_plugin_assets' + +export interface AssetsDevframeOptions { + id?: string + name?: string + icon?: string + /** + * Directory this devframe manages. Defaults to `/public` — the + * conventional static-asset folder for Nuxt, Vite, and most static-site + * frameworks. Pass an absolute path, or one relative to the devframe's + * own `cwd`. + */ + dir?: string + basePath?: string + distDir?: string + /** Preferred standalone CLI port (default 9015). */ + port?: number + /** + * Enable upload, rename, delete, and folder creation from the UI. + * Enabled by default — set `false` (or pass `--read-only` on the + * standalone CLI) for a browse-only deployment. + */ + write?: boolean + /** + * Extensions `upload` accepts, or `'*'` to accept any file. Defaults to + * the same allow-list Nuxt DevTools ships. + */ + uploadExtensions?: readonly string[] | '*' + /** + * Require the trust handshake on the standalone server. Enabled by + * default — this devframe can read, write, and delete real files. + */ + auth?: boolean + /** + * Register the `build` CLI subcommand. Disabled by default: a static + * export can only ever list file metadata from a baked snapshot — real + * previews need `ctx.views.hostStatic()`'s live byte serving, and every + * write action is inherently excluded from a static dump — so the + * command would produce a broken, write-less shell of the tool. Opt + * back in if that degraded export is still useful to you. + */ + build?: boolean +} + +/** + * Create the assets manager devframe — a framework-neutral port of Nuxt + * DevTools' Assets tab. Mount it into any host via devframe's adapters, or + * run it standalone with the bundled CLI (`devframe-assets`). + * + * @experimental This plugin is experimental and may change without a major + * version bump until it stabilizes. + */ +export function createAssetsDevframe(options: AssetsDevframeOptions = {}): DevframeDefinition { + const id = options.id ?? DEFAULT_ID + const distDir = options.distDir ?? resolve(PKG_ROOT, 'dist/spa') + const write = options.write ?? true + const rawBase = `/__${id}-raw/` + + return defineDevframe({ + id, + name: options.name ?? 'Assets', + version: pkg.version, + packageName: pkg.name, + homepage: pkg.homepage, + description: pkg.description, + icon: options.icon ?? 'ph:image-square-duotone', + basePath: options.basePath, + capabilities: { build: options.build ?? false }, + cli: { + command: 'devframe-assets', + port: options.port ?? DEFAULT_PORT, + distDir, + auth: options.auth ?? true, + configure(cli) { + cli.option('--read-only', 'Disable upload, rename, delete, and folder creation') + }, + }, + spa: { loader: 'none' }, + dock: { category: '~builtin' }, + async setup(ctx, info) { + const readOnlyFlag = info?.flags?.readOnly === true + const dir = options.dir ? resolve(ctx.cwd, options.dir) : resolve(ctx.cwd, 'public') + await setupAssets(ctx, { + dir, + write: readOnlyFlag ? false : write, + uploadExtensions: options.uploadExtensions ?? DEFAULT_ALLOWED_UPLOAD_EXTENSIONS, + rawBase, + }) + }, + }) +} + +const assetsDevframe: DevframeDefinition = createAssetsDevframe() + +export default assetsDevframe diff --git a/plugins/assets/src/node/context.ts b/plugins/assets/src/node/context.ts new file mode 100644 index 00000000..114c80d6 --- /dev/null +++ b/plugins/assets/src/node/context.ts @@ -0,0 +1,56 @@ +import type { DevframeNodeContext, RpcStreamingChannel } from 'devframe/types' +import { resolveAssetPath } from './paths' + +export interface AssetsConfig { + /** Directory this devframe manages. */ + dir: string + /** Whether upload / rename / delete / mkdir are registered. */ + write: boolean + /** Extensions `upload` accepts, or `'*'` to accept any. */ + uploadExtensions: readonly string[] | '*' + /** URL prefix the raw bytes are mounted at (live adapters only). */ + rawBase: string + /** The upload streaming channel, created once in `setupAssets` when `write` is enabled. */ + uploadChannel?: RpcStreamingChannel +} + +export interface AssetsContext extends AssetsConfig { + /** Resolve a root-relative path to an absolute one, rejecting escapes. */ + resolvePath: (relativePath: string) => string +} + +const configs = new WeakMap() +const contexts = new WeakMap() + +/** + * Record the managed directory and write policy for a context. Called from + * the devframe's `setup` before any RPC handler runs. + */ +export function configureAssets(ctx: DevframeNodeContext, config: AssetsConfig): void { + configs.set(ctx, config) + contexts.delete(ctx) +} + +/** + * Per-`DevframeNodeContext` assets state. Each RPC function pulls its + * managed directory and path resolver from here instead of re-reading + * `configureAssets`'s config on every call. + */ +export function getAssetsContext(ctx: DevframeNodeContext): AssetsContext { + const existing = contexts.get(ctx) + if (existing) + return existing + + const config = configs.get(ctx) + const dir = config?.dir ?? ctx.cwd + const built: AssetsContext = { + dir, + write: config?.write ?? true, + uploadExtensions: config?.uploadExtensions ?? '*', + rawBase: config?.rawBase ?? '/__devframes_plugin_assets_raw/', + uploadChannel: config?.uploadChannel, + resolvePath: (relativePath: string) => resolveAssetPath(dir, relativePath), + } + contexts.set(ctx, built) + return built +} diff --git a/plugins/assets/src/node/index.ts b/plugins/assets/src/node/index.ts new file mode 100644 index 00000000..8378cf41 --- /dev/null +++ b/plugins/assets/src/node/index.ts @@ -0,0 +1,75 @@ +import type { DevframeNodeContext } from 'devframe/types' +import { existsSync } from 'node:fs' +import fsp from 'node:fs/promises' +import { UPLOAD_CHANNEL } from '../rpc/functions/upload' +import { alwaysFunctions, readFunctions, writeFunctions } from '../rpc/index' +import { configureAssets } from './context' +import { watchAssetsDir } from './watcher' + +export interface SetupAssetsOptions { + /** Directory this devframe manages. */ + dir: string + /** Whether upload / rename / delete / mkdir are registered. */ + write: boolean + /** Extensions `upload` accepts, or `'*'` to accept any. */ + uploadExtensions: readonly string[] | '*' + /** URL prefix the raw bytes are mounted at (live adapters only). */ + rawBase: string +} + +const watchers = new WeakMap Promise>() + +/** + * Register the assets RPC surface on a devframe node context: ensures the + * managed directory exists, hosts it for raw byte access, registers the + * read RPCs (always) and write RPCs (when enabled), and starts a live + * file watcher in dev mode. + * + * Called from the definition's `setup(ctx)` and reusable by host adapters + * that wire their own context. + */ +export async function setupAssets(ctx: DevframeNodeContext, options: SetupAssetsOptions): Promise { + if (!existsSync(options.dir)) + await fsp.mkdir(options.dir, { recursive: true }) + + const uploadChannel = options.write + ? ctx.rpc.streaming.create(UPLOAD_CHANNEL) + : undefined + + configureAssets(ctx, { ...options, uploadChannel }) + + // Real byte serving for ``/`