Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/guide/client-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
// …
Expand All @@ -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.

Expand All @@ -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. |
Expand Down Expand Up @@ -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}`,
Expand All @@ -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
{
Expand Down
33 changes: 33 additions & 0 deletions docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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. |
Expand All @@ -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) { /* … */ },
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,15 @@ 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)
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'] } },
Expand Down
1 change: 1 addition & 0 deletions examples/files-inspector/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions examples/hub-next/src/client/devframe/demo-devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions examples/hub-next/src/client/devframe/tabbed-devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions examples/hub-vite/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions examples/hub-vite/src/tabbed-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions examples/next-runtime-snapshot/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions examples/streaming-chat/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions packages/devframe/src/adapters/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/adapters/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
5 changes: 3 additions & 2 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
}
},
Expand Down
3 changes: 2 additions & 1 deletion packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
13 changes: 11 additions & 2 deletions packages/devframe/src/node/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +41,7 @@ export interface CreateHostContextOptions {
* `commands` when mounted into Vite DevTools.
*/
export async function createHostContext(options: CreateHostContextOptions): Promise<DevframeNodeContext> {
const { cwd, workspaceRoot = cwd, mode, host, builtinRpcDeclarations = [] } = options
const { cwd, workspaceRoot = cwd, mode, host, importMetaUrl, builtinRpcDeclarations = [] } = options

const context: DevframeNodeContext = {
cwd,
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions packages/devframe/src/node/host-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down
8 changes: 4 additions & 4 deletions packages/devframe/src/node/services-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 3 additions & 4 deletions packages/devframe/src/types/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <API = unknown, Options = any>(
input: DevframeServiceInput<API, Options>,
Expand Down
9 changes: 8 additions & 1 deletion packages/devframe/src/types/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading