diff --git a/docs/guide/client-assets.md b/docs/guide/client-assets.md index 31d7207d..821410ba 100644 --- a/docs/guide/client-assets.md +++ b/docs/guide/client-assets.md @@ -46,13 +46,13 @@ import pkg from '../package.json' with { type: 'json' } const distDir: RemoteAssets = { package: '@acme/my-tool-assets', version: pkg.version, - resolveFrom: import.meta.url, } export default defineDevframe({ id: 'my-tool', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, cli: { distDir }, setup(ctx) { // … @@ -62,11 +62,13 @@ export default defineDevframe({ The UI mounts as usual — the first request for each file is streamed from a CDN and written to a local cache; subsequent requests are served from disk. +The definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies) supplies the resolution base, so a remote source needs only its `package` and `version`. A per-source `resolveFrom` overrides that base for one source, and an explicit `resolveFrom: null` opts a source out of the installed-copy lookup entirely. + ### How assets resolve For each request the source resolves in order: -1. **Locally installed package** — resolved from `resolveFrom` (`import.meta.url`). If `@acme/my-tool-assets` is installed next to your tool, it's served directly with no network. This is the offline path. +1. **Locally installed package** — resolved from `resolveFrom`, which defaults to the definition's `importMetaUrl`. If `@acme/my-tool-assets` is installed next to your tool, it's served directly with no network. This is the offline path. 2. **On-disk cache** — files already fetched, under the project's storage directory. 3. **CDN back-proxy** — [jsDelivr](https://www.jsdelivr.com/) by default, mirroring npm. Each file streams to the browser and is cached on the way past. @@ -78,7 +80,7 @@ Exact-version URLs are immutable, so a cached file never goes stale. |-------|---------| | `package` | npm package holding the built assets. | | `version` | Exact version to serve — usually your tool's own `pkg.version`. | -| `resolveFrom` | `import.meta.url` of the declaring module; enables the zero-network path from a locally installed copy. Omit to skip straight to cache + CDN. | +| `resolveFrom` | Resolution base for the zero-network path from a locally installed copy. Defaults to the definition's `importMetaUrl`; set it to override that for one source, or to `null` to skip straight to cache + CDN. | | `path` | Subpath inside the package the assets live under. Defaults to `dist`. | | `provider` | `'jsdelivr'` (default), `'unpkg'`, or a custom provider for an internal mirror. | | `offline` | `true` serves only from a local install or the cache — never the network. | @@ -109,7 +111,6 @@ A custom provider supplies the file URL, and optionally a file listing (used for const distDir: RemoteAssets = { package: '@acme/my-tool-assets', version: pkg.version, - resolveFrom: import.meta.url, provider: { fileUrl: (name, version, file) => `https://npm.internal.acme.com/${name}@${version}/${file}`, @@ -119,7 +120,7 @@ const distDir: RemoteAssets = { ### Publishing the assets -The assets package is an ordinary npm package that ships the built UI under `path` (default `dist`) and exposes its `package.json` so `resolveFrom` can locate it: +The assets package is an ordinary npm package that ships the built UI under `path` (default `dist`) and exposes its `package.json` so the resolver can locate it: ```json { diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 2a2bb31b..6057795b 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -17,6 +17,7 @@ export default defineDevframe({ name: 'My Devframe', version: '1.0.0', packageName: 'my-devframe', + importMetaUrl: import.meta.url, homepage: 'https://github.com/me/my-devframe', description: 'A one-line summary of what the tool does.', icon: 'ph:gauge-duotone', @@ -45,6 +46,7 @@ export default defineDevframe({ | `name` | `string` | **Required.** Display name shown in the dock and agent manifests. | | `version` | `string` | **Required.** Semver of the tool, surfaced in hub UIs and diagnostics. | | `packageName` | `string` | **Required.** npm package name the devframe ships in (e.g. `@scope/my-tool`). | +| `importMetaUrl` | `string` | **Recommended.** Always pass `import.meta.url`. The resolution base for the tool's own dependency graph: it becomes the default `resolveFrom` for any [remote assets](./client-assets) the devframe hosts, and the base the host resolves declared [services](./services#wire-services) from — so a plugin ships an assets or service package as its own dependency instead of asking users to install it. See [Resolving against the plugin's own dependencies](#resolving-against-the-plugins-own-dependencies). | | `homepage` | `string` | **Required.** Project homepage or documentation URL. | | `description` | `string` | **Required.** One-line summary of what the tool does. | | `icon` | `string \| { light, dark }` | Optional Iconify name or URL; supports light/dark pairs. | @@ -67,6 +69,7 @@ export default defineDevframe({ name: 'My Devframe', // display label version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, setup(ctx) { /* … */ }, @@ -75,6 +78,36 @@ export default defineDevframe({ The default import with a `with { type: 'json' }` attribute resolves under both bundlers and Node's native TypeScript execution. Bundlers also support the destructured `import { version } from '../package.json'` form when the devframe is always bundled before it runs. +### Resolving against the plugin's own dependencies + +A devframe often ships companion packages — a separate `--assets` package holding its built SPA, or a service package it consumes. `importMetaUrl` lets the host resolve those against the plugin's **own** installed dependencies rather than the consuming app's, so the plugin declares them as its dependencies and users install nothing extra. + +```ts +import pkg from '../package.json' with { type: 'json' } + +export default defineDevframe({ + id: 'my-devframe', + name: 'My Devframe', + version: pkg.version, + packageName: pkg.name, + importMetaUrl: import.meta.url, + homepage: pkg.homepage, + description: pkg.description, + cli: { + // Served from the locally installed `my-devframe--assets` — resolved via + // `importMetaUrl`, so it works under pnpm's strict layout with zero network. + distDir: { package: `${pkg.name}--assets`, version: pkg.version }, + }, + services: [ + // Imported from `my-devframe`'s own dependency graph. + { package: '@scope/my-service', version: pkg.version }, + ], + setup(ctx) { /* … */ }, +}) +``` + +For a remote assets source, `importMetaUrl` is the default `resolveFrom`; a per-source `resolveFrom` still wins, and an explicit `resolveFrom: null` opts out of the installed-copy lookup. See [Client Assets](./client-assets) and [Cross-Plugin Services](./services#wire-services) for the full resolution order. + ### Runtime flags The `ctx.mode` field is either `'dev'` or `'build'`. Use it to gate work that should only run in one runtime: diff --git a/docs/guide/services.md b/docs/guide/services.md index 1fda7e79..2f698975 100644 --- a/docs/guide/services.md +++ b/docs/guide/services.md @@ -103,7 +103,7 @@ Two declaration merges make it fully typed for consumers: the fully-qualified RP ### Installing -A host with the factory at hand installs explicitly; a plugin declares what it consumes on its definition and the adapter resolves the package **against the plugin's own dependencies**: +A host with the factory at hand installs explicitly; a plugin declares what it consumes on its definition and the adapter resolves the package **against the plugin's own dependencies** — the base for that resolution is the definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies), so a plugin ships a service package as its own dependency and users install nothing extra: ```ts // host side (e.g. inside initHub's configure) @@ -111,6 +111,7 @@ ctx.services.install(createShikiService({ themes })) // plugin side — declarative defineDevframe({ + importMetaUrl: import.meta.url, // resolution base for the declared packages services: [ { package: '@devframes/service-open' }, { package: '@devframes/service-shiki', version: '^1', options: { langs: ['vue'] } }, diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts index 97e39525..0405dc7b 100644 --- a/examples/files-inspector/src/devframe.ts +++ b/examples/files-inspector/src/devframe.ts @@ -11,6 +11,7 @@ export default defineDevframe({ name: 'Files Inspector', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: 'ph:folder-open-duotone', diff --git a/examples/hub-next/src/client/devframe/demo-devframe.ts b/examples/hub-next/src/client/devframe/demo-devframe.ts index 0f5d1dc6..d2f2d04a 100644 --- a/examples/hub-next/src/client/devframe/demo-devframe.ts +++ b/examples/hub-next/src/client/devframe/demo-devframe.ts @@ -13,6 +13,7 @@ export default defineDevframe({ name: 'Next Demo Tool', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: 'A tiny demo devframe mounted into the Next.js hub via its `devframes` list.', icon: 'ph:rocket-duotone', diff --git a/examples/hub-next/src/client/devframe/tabbed-devframe.ts b/examples/hub-next/src/client/devframe/tabbed-devframe.ts index 31b42615..46cb55f4 100644 --- a/examples/hub-next/src/client/devframe/tabbed-devframe.ts +++ b/examples/hub-next/src/client/devframe/tabbed-devframe.ts @@ -19,6 +19,7 @@ export default defineDevframe({ name: 'Next Tabbed Tool', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: 'A multi-view SPA hosted as shared-iframe hub docks with soft navigation.', icon: 'ph:squares-four-duotone', diff --git a/examples/hub-vite/src/devframe.ts b/examples/hub-vite/src/devframe.ts index 69b14c8d..dfe6e434 100644 --- a/examples/hub-vite/src/devframe.ts +++ b/examples/hub-vite/src/devframe.ts @@ -19,6 +19,7 @@ export default defineDevframe({ name: 'Demo Tool', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: 'A tiny demo devframe that plugs into the hub via its `devframes` list.', icon: 'ph:rocket-duotone', diff --git a/examples/hub-vite/src/tabbed-tool.ts b/examples/hub-vite/src/tabbed-tool.ts index b6b0f290..c01bb917 100644 --- a/examples/hub-vite/src/tabbed-tool.ts +++ b/examples/hub-vite/src/tabbed-tool.ts @@ -19,6 +19,7 @@ export default defineDevframe({ name: 'Tabbed Tool', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: 'A multi-view SPA hosted as shared-iframe hub docks with soft navigation.', icon: 'ph:squares-four-duotone', diff --git a/examples/next-runtime-snapshot/src/devframe.ts b/examples/next-runtime-snapshot/src/devframe.ts index 0c075065..559813e0 100644 --- a/examples/next-runtime-snapshot/src/devframe.ts +++ b/examples/next-runtime-snapshot/src/devframe.ts @@ -15,6 +15,7 @@ export default defineDevframe({ name: 'Next Runtime Snapshot', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: 'ph:gauge-duotone', diff --git a/examples/streaming-chat/src/devframe.ts b/examples/streaming-chat/src/devframe.ts index a837c66f..991e478f 100644 --- a/examples/streaming-chat/src/devframe.ts +++ b/examples/streaming-chat/src/devframe.ts @@ -15,6 +15,7 @@ export default defineDevframe({ name: 'Streaming Chat', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: 'ph:chat-circle-dots-duotone', diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index 3f25857f..5f3aaa4f 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -73,7 +73,7 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt // A static deploy must be self-contained: a local dir (or a remote source // backed by a locally installed package) is copied; an uninstalled remote // source materializes every listed file from the provider. - const resolved = resolveStaticAssetsSource(distSource, host.getStorageDir('project')) + const resolved = resolveStaticAssetsSource(distSource, host.getStorageDir('project'), d.importMetaUrl) if (typeof resolved === 'string') { console.log(c.cyan`[devframe] copying SPA from ${resolved} -> ${outDir}`) await fs.cp(resolved, outDir, { recursive: true }) @@ -87,9 +87,10 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt cwd: process.cwd(), mode: 'build', host, + importMetaUrl: d.importMetaUrl, }) for (const input of d.services ?? []) - void ctx.services.install(input, { resolveFrom: d.packageName }) + void ctx.services.install(input, { resolveFrom: d.importMetaUrl }) await d.setup(ctx) await ctx.services.ready() diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts index 0fbf7be2..3edfad3c 100644 --- a/packages/devframe/src/adapters/embedded.ts +++ b/packages/devframe/src/adapters/embedded.ts @@ -20,6 +20,6 @@ export async function createEmbedded(d: DevframeDefinition, options: CreateEmbed // `ctx.services.ready()` barrier (post-barrier registration installs // immediately). for (const input of d.services ?? []) - void options.ctx.services.install(input, { resolveFrom: d.packageName }) + void options.ctx.services.install(input, { resolveFrom: d.importMetaUrl }) await d.setup(options.ctx) } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index f8567c26..bdb88369 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -287,12 +287,13 @@ export function initDevframe( cwd: process.cwd(), mode: 'dev', host: hostImpl, + importMetaUrl: def.importMetaUrl, }) const setupInfo: DevframeSetupInfo = { flags: options.flags ?? {} } // Declarative services queue ahead of setup (their promises resolve at // the ready() barrier below), resolving against the plugin's own deps. for (const input of def.services ?? []) - void context.services.install(input, { resolveFrom: def.packageName }) + void context.services.install(input, { resolveFrom: def.importMetaUrl }) await def.setup(context, setupInfo) await context.services.ready() @@ -337,7 +338,7 @@ export function initDevframe( app.use(joinURL(base, DEVFRAME_CONNECTION_META_FILENAME), () => meta) if (distDir) { - const source = resolveStaticAssetsSource(distDir, context.host.getStorageDir('project')) + const source = resolveStaticAssetsSource(distDir, context.host.getStorageDir('project'), def.importMetaUrl) mountStaticHandler(app, base, typeof source === 'string' ? resolve(source) : source) } }, diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 073b1315..75544837 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -116,9 +116,10 @@ export async function createMcpServer( cwd: process.cwd(), mode: 'dev', host, + importMetaUrl: definition.importMetaUrl, }) for (const input of definition.services ?? []) - void ctx.services.install(input, { resolveFrom: definition.packageName }) + void ctx.services.install(input, { resolveFrom: definition.importMetaUrl }) await definition.setup(ctx) await ctx.services.ready() diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index 671e2b9f..b4058164 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -15,6 +15,15 @@ export interface CreateHostContextOptions { workspaceRoot?: string mode: 'dev' | 'build' host: DevframeHost + /** + * `import.meta.url` of the module that defines the devframe this context + * serves (from `DevframeDefinition.importMetaUrl`). Supplies the default + * `resolveFrom` base for remote {@link DevframeViewHost.hostStatic} sources + * that don't set one, so a locally installed copy of an assets package is + * served with zero network. An internal plumbing detail — it isn't part of + * the public {@link DevframeNodeContext} surface. + */ + importMetaUrl?: string /** * Built-in RPC declarations to register on the host. Framework * adapters (vite, rolldown, cli) can pass the ones they need; the @@ -32,7 +41,7 @@ export interface CreateHostContextOptions { * `commands` when mounted into Vite DevTools. */ export async function createHostContext(options: CreateHostContextOptions): Promise { - const { cwd, workspaceRoot = cwd, mode, host, builtinRpcDeclarations = [] } = options + const { cwd, workspaceRoot = cwd, mode, host, importMetaUrl, builtinRpcDeclarations = [] } = options const context: DevframeNodeContext = { cwd, @@ -49,7 +58,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom } as unknown as DevframeNodeContext const rpcHost = new RpcFunctionsHostImpl(context) - const viewsHost = new DevframeViewHost(context) + const viewsHost = new DevframeViewHost(context, importMetaUrl) const diagnosticsHost = new DevframeDiagnosticsHost(context, [devframeDiagnostics, rpcDiagnostics]) context.rpc = rpcHost context.views = viewsHost diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts index 81b31c15..d9fe3623 100644 --- a/packages/devframe/src/node/host-views.ts +++ b/packages/devframe/src/node/host-views.ts @@ -11,14 +11,21 @@ export class DevframeViewHost implements DevframeViewHostType { constructor( public readonly context: DevframeNodeContext, + /** + * `import.meta.url` of the declaring devframe — the default `resolveFrom` + * for a remote source that doesn't set one. Internal; supplied by + * `createHostContext` from `DevframeDefinition.importMetaUrl`. + * @internal + */ + private readonly importMetaUrl?: string, ) { } - hostStatic(baseUrl: string, source: StaticAssetsSource) { + hostStatic(baseUrl: string, source: StaticAssetsSource, defaultResolveFrom: string | null | undefined = this.importMetaUrl) { // Local directories must exist up front; remote declarations resolve to // a locally installed package when present, otherwise to a lazy CDN // back-proxy store — nothing to check on disk yet. - const resolved = resolveStaticAssetsSource(source, this.context.host.getStorageDir('project')) + const resolved = resolveStaticAssetsSource(source, this.context.host.getStorageDir('project'), defaultResolveFrom) if (typeof resolved === 'string' && !existsSync(resolved)) { throw diagnostics.DF0008({ distDir: resolved }) } diff --git a/packages/devframe/src/node/services-install.ts b/packages/devframe/src/node/services-install.ts index 00223556..d5ea6ed9 100644 --- a/packages/devframe/src/node/services-install.ts +++ b/packages/devframe/src/node/services-install.ts @@ -20,10 +20,10 @@ function toRequireBase(resolveFrom: string): string { /** * Normalize an `install()` `resolveFrom` into a resolution base. Paths and - * file URLs pass through; a bare npm package name (the common case: the - * declaring plugin's `packageName`) resolves to that package's location from - * `cwd`, so a service it declares resolves against the plugin's own - * dependencies. An unresolvable package name reads as no base (the caller's + * file URLs pass through (the common case: the declaring plugin's + * `importMetaUrl`, so a service it declares resolves against the plugin's own + * dependencies); a bare npm package name resolves to that package's location + * from `cwd`. An unresolvable package name reads as no base (the caller's * workspace fallbacks apply). */ export function expandResolveFrom(resolveFrom: string, cwd: string): string | undefined { diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 5ae5652a..2117c247 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -281,6 +281,29 @@ export interface DevframeDefinition { version: string /** npm package name the devframe ships in (e.g. `@scope/my-tool`). */ packageName: string + /** + * `import.meta.url` of the module that defines this devframe. **Always + * provide it** (`importMetaUrl: import.meta.url`): it is the resolution base + * for the tool's own dependency graph, which lets the host resolve + * everything against the plugin's own installed packages rather than the + * consuming app's. + * + * - **Remote assets** — becomes the default `resolveFrom` for any remote + * {@link StaticAssetsSource} the devframe hosts (its `cli.distDir`, and + * every `ctx.views.hostStatic` call) that doesn't set one explicitly, so a + * locally installed copy of the assets package is served with zero + * network. A per-asset `resolveFrom` still wins, and an explicit + * `resolveFrom: null` still opts out. + * - **Service dependencies** — becomes the base the host resolves declared + * {@link DevframeServiceInput | services} from, so a plugin can ship a + * service package as its own dependency instead of asking users to install + * it. + * + * Optional for backward compatibility; omitting it falls back to + * runtime-directory resolution and disables the zero-network installed-copy + * fast paths above. + */ + importMetaUrl?: string /** Project homepage or documentation URL. */ homepage: string /** One-line summary of what the tool does. */ diff --git a/packages/devframe/src/types/services.ts b/packages/devframe/src/types/services.ts index c089b916..2e009a51 100644 --- a/packages/devframe/src/types/services.ts +++ b/packages/devframe/src/types/services.ts @@ -221,10 +221,9 @@ export interface DevframeServicesHost { * emitted when the late install carried options, since they're ignored). * * `resolveFrom` is where a descriptor's package resolves **from**: a path - * or file URL (e.g. `import.meta.url`), or an npm package name — typically - * the declaring plugin's `packageName`, so its declared services resolve - * against the plugin's own dependencies. Falls back to the context's - * `workspaceRoot`. + * or file URL (e.g. the declaring devframe's `importMetaUrl`), or an npm + * package name, so its declared services resolve against the declarer's own + * dependencies. Falls back to the context's `workspaceRoot` then `cwd`. */ install: ( input: DevframeServiceInput, diff --git a/packages/devframe/src/types/views.ts b/packages/devframe/src/types/views.ts index 0a469503..dd5c0383 100644 --- a/packages/devframe/src/types/views.ts +++ b/packages/devframe/src/types/views.ts @@ -12,6 +12,13 @@ export interface DevframeViewHost { * * Accepts a local dist directory, or a {@link StaticAssetsSource} remote * declaration served through devframe's caching CDN back-proxy. + * + * `defaultResolveFrom` overrides, for this call only, the context's own + * `importMetaUrl` as the default `resolveFrom` for a remote source that + * doesn't set one. A shared host that mounts assets on behalf of another + * devframe (a hub installing a plugin) passes that plugin's `importMetaUrl` + * so the assets resolve against the plugin's dependency graph rather than + * the host's. */ - hostStatic: (baseUrl: string, source: StaticAssetsSource) => void + hostStatic: (baseUrl: string, source: StaticAssetsSource, defaultResolveFrom?: string | null) => void } diff --git a/packages/devframe/src/utils/remote-assets.test.ts b/packages/devframe/src/utils/remote-assets.test.ts index b359ebc8..5fcda185 100644 --- a/packages/devframe/src/utils/remote-assets.test.ts +++ b/packages/devframe/src/utils/remote-assets.test.ts @@ -247,6 +247,24 @@ describe('resolveStaticAssetsSource (installed package)', () => { const { resolveFrom } = install('1.2.3') expect(typeof resolveStaticAssetsSource({ package: '@scope/other', version: '1.2.3', resolveFrom }, makeTmp())).not.toBe('string') }) + + it('defaults resolveFrom from the third argument when the source omits it', () => { + const { resolveFrom, distDir } = install('1.2.3') + // No per-source `resolveFrom` — the definition-level default resolves it. + expect(norm(resolveStaticAssetsSource({ package: '@scope/demo-client', version: '1.2.3' }, makeTmp(), resolveFrom) as string)).toBe(norm(distDir)) + }) + + it('lets an explicit per-source resolveFrom win over the default', () => { + const { resolveFrom, distDir } = install('1.2.3') + // Default points nowhere useful; the explicit source value is used. + expect(norm(resolveStaticAssetsSource({ package: '@scope/demo-client', version: '1.2.3', resolveFrom }, makeTmp(), 'file:///nowhere/entry.mjs') as string)).toBe(norm(distDir)) + }) + + it('honors an explicit resolveFrom: null (opts out of the installed lookup) despite a default', () => { + const { resolveFrom } = install('1.2.3') + // `null` skips the installed-copy step entirely — falls back to a store. + expect(typeof resolveStaticAssetsSource({ package: '@scope/demo-client', version: '1.2.3', resolveFrom: null }, makeTmp(), resolveFrom)).not.toBe('string') + }) }) describe('resolveStaticAssetsSource (validation)', () => { diff --git a/packages/devframe/src/utils/remote-assets.ts b/packages/devframe/src/utils/remote-assets.ts index a84ae7ec..5852c926 100644 --- a/packages/devframe/src/utils/remote-assets.ts +++ b/packages/devframe/src/utils/remote-assets.ts @@ -395,14 +395,24 @@ function assertValidRemoteAssets(assets: RemoteAssets): void { * * A remote source's `package`/`version` are validated first (`DF0065`) — both * are interpolated into CDN URLs and the cache path. + * + * `defaultResolveFrom` (typically the declaring devframe's `importMetaUrl`) + * supplies a `resolveFrom` base for a remote source that doesn't set one: + * it is applied only when `source.resolveFrom` is `undefined`, so an explicit + * per-source string still wins and an explicit `null` still opts out of the + * installed-copy lookup. */ export function resolveStaticAssetsSource( source: StaticAssetsSource, projectStorageDir: string, + defaultResolveFrom?: string | null, ): string | RemoteAssetsStore { if (typeof source === 'string') return source assertValidRemoteAssets(source) - return resolveInstalled(source) - ?? createStore(source, join(projectStorageDir, '.remote-assets', `${source.package.replace(/\//g, '+')}@${source.version}`)) + const resolved: RemoteAssets = source.resolveFrom === undefined && defaultResolveFrom != null + ? { ...source, resolveFrom: defaultResolveFrom } + : source + return resolveInstalled(resolved) + ?? createStore(resolved, join(projectStorageDir, '.remote-assets', `${resolved.package.replace(/\//g, '+')}@${resolved.version}`)) } diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index dc092b68..a9478c76 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -91,7 +91,10 @@ export async function installDevframe( else diagnostics.DF8106({ id, name: d.name, base }) const distSource = d.cli.distDir - ctx.views.hostStatic(base, typeof distSource === 'string' ? resolve(distSource) : distSource) + // Resolve the plugin's assets against *its* dependency graph, not the + // hub's: pass the devframe's own `importMetaUrl` as the default + // `resolveFrom`. + ctx.views.hostStatic(base, typeof distSource === 'string' ? resolve(distSource) : distSource, d.importMetaUrl) } ctx.docks.register({ @@ -112,6 +115,6 @@ export async function installDevframe( // hub fires the `ctx.services.ready()` barrier once every devframe (and // the host's own configuration) has installed. for (const input of d.services ?? []) - void ctx.services.install(input, { resolveFrom: d.packageName }) + void ctx.services.install(input, { resolveFrom: d.importMetaUrl }) await d.setup(ctx) } diff --git a/packages/vite/src/single.ts b/packages/vite/src/single.ts index fb8c4cc6..9fe89556 100644 --- a/packages/vite/src/single.ts +++ b/packages/vite/src/single.ts @@ -72,7 +72,7 @@ export function devframeVitePlugin(d: DevframeDefinition, options: DevframeViteP // Remote-assets sources resolve to the locally installed assets // package when present, otherwise to a caching CDN back-proxy, under // the h3 host's `project` storage convention. - const source = resolveStaticAssetsSource(distDir, join(process.cwd(), 'node_modules', `.${d.id}`, 'devframe')) + const source = resolveStaticAssetsSource(distDir, join(process.cwd(), 'node_modules', `.${d.id}`, 'devframe'), d.importMetaUrl) server.middlewares.use(base, serveStaticNodeMiddleware(typeof source === 'string' ? resolve(source) : source)) }, } diff --git a/plugins/a11y/src/index.ts b/plugins/a11y/src/index.ts index be9443f1..f68b56d3 100644 --- a/plugins/a11y/src/index.ts +++ b/plugins/a11y/src/index.ts @@ -9,13 +9,13 @@ const DEFAULT_ID = 'devframes_plugin_a11y' const BASE_PATH = '/__devframes_plugin_a11y/' // The Solid panel SPA ships in the lockstep `@devframes/plugin-a11y--assets` -// package, served on demand through devframe's remote-assets back-proxy; -// `resolveFrom` serves a locally installed copy (a workspace link here) with -// zero network. The host-page agent bundle (`dist/inject`, below) stays here. +// package, served on demand through devframe's remote-assets back-proxy. The +// definition's `importMetaUrl` (below) supplies the default `resolveFrom`, so a +// locally installed copy (a workspace link here) is served with zero network. +// The host-page agent bundle (`dist/inject`, below) stays here. const distDir: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } /** @@ -83,6 +83,7 @@ export function createA11yDevframe(options: A11yDevframeOptions = {}): DevframeD name: options.name ?? 'A11y Inspector', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: options.icon ?? 'ph:person-simple-circle-duotone', diff --git a/plugins/a11y/tests/_utils.ts b/plugins/a11y/tests/_utils.ts index 756626be..39361bdb 100644 --- a/plugins/a11y/tests/_utils.ts +++ b/plugins/a11y/tests/_utils.ts @@ -18,7 +18,7 @@ const devframe = createA11yDevframe() /** Resolve the Solid panel SPA to a local dir — the workspace-linked `--assets` package in dev. */ function localSpaDir(): string { - const resolved = resolveStaticAssetsSource(devframe.cli!.distDir!, resolve(os.tmpdir(), 'devframes_plugin_a11y-test')) + const resolved = resolveStaticAssetsSource(devframe.cli!.distDir!, resolve(os.tmpdir(), 'devframes_plugin_a11y-test'), devframe.importMetaUrl) if (typeof resolved !== 'string') throw new TypeError('[devframes_plugin_a11y] client SPA missing — run `pnpm -C plugins/a11y run build` first.') return resolved diff --git a/plugins/assets/src/index.ts b/plugins/assets/src/index.ts index 648ad307..e15bcb55 100644 --- a/plugins/assets/src/index.ts +++ b/plugins/assets/src/index.ts @@ -10,12 +10,12 @@ export type { AssetImageMeta, AssetInfo, AssetType, CodeSnippet } from './types' export { DEFAULT_ALLOWED_UPLOAD_EXTENSIONS } from './types' // The SPA ships in the lockstep `@devframes/plugin-assets--assets` package, -// served on demand through devframe's remote-assets back-proxy; `resolveFrom` -// serves a locally installed copy (a workspace link here) with zero network. +// served on demand through devframe's remote-assets back-proxy. The definition's +// `importMetaUrl` (below) supplies the default `resolveFrom`, so a locally +// installed copy (a workspace link here) is served with zero network. const remoteAssets: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } const DEFAULT_ID = 'devframes_plugin_assets' @@ -109,6 +109,7 @@ export function createAssetsDevframe(options: AssetsDevframeOptions = {}): Devfr name: options.name ?? 'Assets', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: options.icon ?? 'ph:image-square-duotone', diff --git a/plugins/code-server/src/index.ts b/plugins/code-server/src/index.ts index 1330efb7..42317644 100644 --- a/plugins/code-server/src/index.ts +++ b/plugins/code-server/src/index.ts @@ -14,12 +14,12 @@ export { export type * from './types' // The SPA ships in the lockstep `@devframes/plugin-code-server--assets` package, -// served on demand through devframe's remote-assets back-proxy; `resolveFrom` -// serves a locally installed copy (a workspace link here) with zero network. +// served on demand through devframe's remote-assets back-proxy. The definition's +// `importMetaUrl` (below) supplies the default `resolveFrom`, so a locally +// installed copy (a workspace link here) is served with zero network. const remoteAssets: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } /** @@ -45,6 +45,7 @@ export function createCodeServerDevframe(options: CodeServerOptions = {}): Devfr name: 'Code Server', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: 'ph:code-duotone', diff --git a/plugins/data-inspector/src/index.ts b/plugins/data-inspector/src/index.ts index f2dd1e86..ba17b8bb 100644 --- a/plugins/data-inspector/src/index.ts +++ b/plugins/data-inspector/src/index.ts @@ -10,12 +10,12 @@ const DEFAULT_ID = 'devframes:plugin:data-inspector' const DEFAULT_PORT = 9014 // The SPA ships in the lockstep `@devframes/plugin-data-inspector--assets` package, -// served on demand through devframe's remote-assets back-proxy; `resolveFrom` -// serves a locally installed copy (a workspace link here) with zero network. +// served on demand through devframe's remote-assets back-proxy. The definition's +// `importMetaUrl` (below) supplies the default `resolveFrom`, so a locally +// installed copy (a workspace link here) is served with zero network. const remoteAssets: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } export interface DataInspectorDevframeOptions { @@ -68,6 +68,7 @@ export function createDataInspectorDevframe(options: DataInspectorDevframeOption name: options.name ?? 'Data Inspector', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: options.icon ?? 'ph:crosshair-duotone', diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index bac9e92a..7fe26038 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -16,12 +16,12 @@ export type { UnstageArgs } from './rpc/functions/unstage.ts' // The Next.js static-export SPA ships in the lockstep // `@devframes/plugin-git--assets` package, served on demand through devframe's -// remote-assets back-proxy; `resolveFrom` serves a locally installed copy (a -// workspace link here) with zero network. +// remote-assets back-proxy. The definition's `importMetaUrl` (below) supplies +// the default `resolveFrom`, so a locally installed copy (a workspace link +// here) is served with zero network. const remoteAssets: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } export interface GitDevframeOptions { @@ -64,6 +64,7 @@ export function createGitDevframe(options: GitDevframeOptions = {}): DevframeDef name: 'Git', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: 'ph:git-branch-duotone', diff --git a/plugins/git/test/_utils.ts b/plugins/git/test/_utils.ts index 867c2507..09e35996 100644 --- a/plugins/git/test/_utils.ts +++ b/plugins/git/test/_utils.ts @@ -49,7 +49,7 @@ export async function startDashboardServer( ): Promise { const devframe = createGitDevframe(options) // The client SPA ships in the workspace-linked `--assets` package in dev. - const distDir = resolveStaticAssetsSource(devframe.cli!.distDir!, resolve(tmpdir(), 'devframes_plugin_git-test')) + const distDir = resolveStaticAssetsSource(devframe.cli!.distDir!, resolve(tmpdir(), 'devframes_plugin_git-test'), devframe.importMetaUrl) if (typeof distDir !== 'string') throw new TypeError('these tests serve the local client SPA — build the plugin first') // The factory leaves basePath adapter-resolved; standalone defaults to '/'. diff --git a/plugins/inspect/src/index.ts b/plugins/inspect/src/index.ts index 5440c618..8e6d5748 100644 --- a/plugins/inspect/src/index.ts +++ b/plugins/inspect/src/index.ts @@ -7,14 +7,14 @@ import { setupInspect } from './node/index' const DEFAULT_ID = 'devframes_plugin_inspect' // The Vue SPA ships in the lockstep-versioned `@devframes/plugin-inspect--assets` -// package rather than inside this (slim) node package. `resolveFrom` lets a -// locally installed copy (a workspace link in this monorepo, or an explicit +// package rather than inside this (slim) node package. The definition's +// `importMetaUrl` (below) supplies the default `resolveFrom`, letting a locally +// installed copy (a workspace link in this monorepo, or an explicit // `npm install` for air-gapped setups) be served with zero network; otherwise // the assets stream on demand through devframe's caching CDN back-proxy. const distDir: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } export interface InspectDevframeOptions { @@ -55,6 +55,7 @@ export function createInspectDevframe(options: InspectDevframeOptions = {}): Dev name: options.name ?? 'Devframe Inspector', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: options.icon ?? 'ph:stethoscope-duotone', diff --git a/plugins/inspect/test/_utils.ts b/plugins/inspect/test/_utils.ts index a6153e7d..63b69198 100644 --- a/plugins/inspect/test/_utils.ts +++ b/plugins/inspect/test/_utils.ts @@ -27,7 +27,7 @@ const inspectDevframe = createInspectDevframe() * string) means that build hasn't run. */ function localSpaDir(): string { - const resolved = resolveStaticAssetsSource(inspectDevframe.cli!.distDir!, path.join(os.tmpdir(), 'devframes_plugin_inspect-test')) + const resolved = resolveStaticAssetsSource(inspectDevframe.cli!.distDir!, path.join(os.tmpdir(), 'devframes_plugin_inspect-test'), inspectDevframe.importMetaUrl) if (typeof resolved !== 'string') { throw new TypeError( '[devframes_plugin_inspect] client SPA missing — run `pnpm -C plugins/inspect run build` first.', diff --git a/plugins/messages/src/index.ts b/plugins/messages/src/index.ts index 7386cfd3..d6e81494 100644 --- a/plugins/messages/src/index.ts +++ b/plugins/messages/src/index.ts @@ -5,12 +5,12 @@ import { DEFAULT_PORT, PLUGIN_ID } from './constants' import { setupMessages } from './node/index' // The SPA ships in the lockstep `@devframes/plugin-messages--assets` package, -// served on demand through devframe's remote-assets back-proxy; `resolveFrom` -// serves a locally installed copy (a workspace link here) with zero network. +// served on demand through devframe's remote-assets back-proxy. The definition's +// `importMetaUrl` (below) supplies the default `resolveFrom`, so a locally +// installed copy (a workspace link here) is served with zero network. const remoteAssets: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } // The panel `clientScript` bundle (`dist/client`) stays in this node package. @@ -54,6 +54,7 @@ export function createMessagesDevframe(options: MessagesDevframeOptions = {}): D name: options.name ?? 'Messages', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: options.icon ?? 'ph:notification-duotone', diff --git a/plugins/messages/test/_utils.ts b/plugins/messages/test/_utils.ts index 4347de58..c0253d4d 100644 --- a/plugins/messages/test/_utils.ts +++ b/plugins/messages/test/_utils.ts @@ -23,7 +23,7 @@ const SPA_DIST = localDistDir() /** Resolve the SPA to a local dir — the workspace-linked `--assets` package in dev. */ function localDistDir(): string { - const resolved = resolveStaticAssetsSource(messagesDevframe.cli!.distDir!, path.join(os.tmpdir(), 'devframes_plugin_messages-test')) + const resolved = resolveStaticAssetsSource(messagesDevframe.cli!.distDir!, path.join(os.tmpdir(), 'devframes_plugin_messages-test'), messagesDevframe.importMetaUrl) if (typeof resolved !== 'string') throw new TypeError('these tests serve the local client SPA — build the plugin first') return resolved diff --git a/plugins/og/src/index.ts b/plugins/og/src/index.ts index 37ff3f54..ecf691ed 100644 --- a/plugins/og/src/index.ts +++ b/plugins/og/src/index.ts @@ -6,12 +6,12 @@ import { setupOg } from './node/index' const DEFAULT_ID = 'devframes_plugin_og' // The SPA ships in the lockstep `@devframes/plugin-og--assets` package, -// served on demand through devframe's remote-assets back-proxy; `resolveFrom` -// serves a locally installed copy (a workspace link here) with zero network. +// served on demand through devframe's remote-assets back-proxy. The definition's +// `importMetaUrl` (below) supplies the default `resolveFrom`, so a locally +// installed copy (a workspace link here) is served with zero network. const remoteAssets: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } export interface OgDevframeOptions { @@ -41,6 +41,7 @@ export function createOgDevframe(options: OgDevframeOptions = {}): DevframeDefin name: options.name ?? 'Open Graph', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: options.icon ?? 'ph:image-square-duotone', diff --git a/plugins/og/test/_utils.ts b/plugins/og/test/_utils.ts index 8720c50e..9b5fe414 100644 --- a/plugins/og/test/_utils.ts +++ b/plugins/og/test/_utils.ts @@ -30,7 +30,7 @@ const testDevframe = createOgDevframe({ fetch: testFetch }) /** Resolve the SPA to a local dir — the workspace-linked `--assets` package in dev. */ function localSpaDir(): string { - const resolved = resolveStaticAssetsSource(testDevframe.cli!.distDir!, path.join(os.tmpdir(), 'devframes_plugin_og-test')) + const resolved = resolveStaticAssetsSource(testDevframe.cli!.distDir!, path.join(os.tmpdir(), 'devframes_plugin_og-test'), testDevframe.importMetaUrl) if (typeof resolved !== 'string') throw new TypeError('Open Graph client SPA missing. Run the plugin build first.') return resolved diff --git a/plugins/terminals/src/index.ts b/plugins/terminals/src/index.ts index 93c2a575..bb38e140 100644 --- a/plugins/terminals/src/index.ts +++ b/plugins/terminals/src/index.ts @@ -38,13 +38,13 @@ export { * ``` */ // The SPA ships in the lockstep `@devframes/plugin-terminals--assets` package, -// served on demand through devframe's remote-assets back-proxy; `resolveFrom` -// serves a locally installed copy (a workspace link here) with zero network. The panel +// served on demand through devframe's remote-assets back-proxy. The definition's +// `importMetaUrl` (below) supplies the default `resolveFrom`, so a locally +// installed copy (a workspace link here) is served with zero network. The panel // `clientScript` bundle (`dist/client`) stays in this node package. const remoteAssets: RemoteAssets = { package: `${pkg.name}--assets`, version: pkg.version, - resolveFrom: import.meta.url, } export function createTerminalsDevframe(options: TerminalsOptions = {}): DevframeDefinition { @@ -55,6 +55,7 @@ export function createTerminalsDevframe(options: TerminalsOptions = {}): Devfram name: 'Terminals', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, icon: 'ph:terminal-window-duotone', diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index dae2c311..3e993db2 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -132,6 +132,7 @@ export interface DevframeDefinition { name: string; version: string; packageName: string; + importMetaUrl?: string; homepage: string; description: string; icon?: string | { @@ -357,7 +358,7 @@ export interface DevframeViewHost { baseUrl: string; source: StaticAssetsSource; }[]; - hostStatic: (_: string, _: StaticAssetsSource) => void; + hostStatic: (_: string, _: StaticAssetsSource, _?: string | null) => void; } export interface DevframeWsOptions { route?: string; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 3e3e13bb..f2acbe76 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -7,6 +7,7 @@ export interface CreateHostContextOptions { workspaceRoot?: string; mode: 'dev' | 'build'; host: DevframeHost; + importMetaUrl?: string; builtinRpcDeclarations?: readonly RpcFunctionDefinitionAny[]; } export interface CreateStorageOptions { diff --git a/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts index 1bab4a1c..9467686a 100644 --- a/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts @@ -2,5 +2,5 @@ * Generated by tsnapi — public API snapshot of `devframe/utils/remote-assets` */ // #region Functions -export declare function resolveStaticAssetsSource(_: StaticAssetsSource, _: string): string | RemoteAssetsStore; +export declare function resolveStaticAssetsSource(_: StaticAssetsSource, _: string, _?: string | null): string | RemoteAssetsStore; // #endregion \ No newline at end of file