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
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export const alias = {
'@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'),
'@devframes/service-git': s('git/src/index.ts'),
'@devframes/service-open': s('open/src/index.ts'),
'@devframes/service-shiki': s('shiki/src/index.ts'),
}
Expand Down
1 change: 1 addition & 0 deletions docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default defineDevframe({
| `duplicationStrategy` | `'warn' \| 'silent' \| 'throw' \| 'duplicate'` | How a hub reacts when another devframe sharing this `id` is mounted onto the same hub. Defaults to `'warn'`. See [Hub](./hub). Hub adapters consult it; standalone adapters ignore it. |
| `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. A `boolean` applies to the runtime as a whole; an object enables individual features. |
| `services` | `DevframeServiceInput[]` | Wire services this devframe consumes — descriptors (`{ package, version?, required?, options? }`) the adapter imports against the plugin's own dependencies, or ready definitions. See [Cross-Plugin Services](./services#wire-services). |
| `rpc` | `{ snapshot?: (string \| { method, inputs })[] }` | RPC-level config. `rpc.snapshot` opts an RPC function this devframe doesn't own (e.g. a wire service's) into the static build's dump. A bare method id bakes the no-argument call; `{ method, inputs }` bakes one record per argument-tuple, where `inputs` is a list of tuples or an async `(ctx) => tuples` provider (so it can enumerate at build time via the service's node API). The first tuple's result becomes the fallback. |
| `setup` | `(ctx, info?) => void \| Promise<void>` | **Required.** Server-side entry point. Runs in every runtime. The optional second argument carries runtime metadata — most notably the parsed CLI `flags` when running under `createCac`. |
| `cli` | `DevframeCliOptions` | Defaults for the CLI adapter. See [CLI options](#cli-options) below. |

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ state.on('updated', render)

**`@devframes/service-open`** (`devframes:service:open`) opens files in the user's editor (`open-in-editor`, with optional `line`/`column`) or reveals them in the OS file explorer (`open-in-finder`). Paths may be absolute or relative to the workspace root (so a client with only a workspace-relative path — a message's file position, say — calls it directly); the service refuses anything outside the workspace root and the configured extra `roots` (`DS_OPEN_0002`), and gates editor commands to the `KNOWN_EDITORS` picklist. Options: `{ editor?, roots? }` — the preferred editor (later installer wins) and additional openable directories (merged as a union). It supersedes the per-plugin `devframe/recipes/common-rpc-functions` registrations, now deprecated.

**`@devframes/service-git`** (`devframes:service:git`) runs read/write git operations over RPC — `status`, `log`, `show`, `diff`, `branches`, `stage`, `unstage`, `commit` — with parsed, typed results, so a devframe (the git plugin, or any tool) consumes git without shelling out itself. It operates on a single repo fixed at install (`{ cwd? }`, defaulting to the context cwd; root discovered once). Write ops are always exposed — authorization is the host's connection-trust boundary. The service defines no `dump`/`snapshot`; a devframe bakes the read ops it wants into a static build via [`rpc.snapshot`](./devframe-definition). Client-supplied revisions are guarded against option injection.

**`@devframes/service-shiki`** (`devframes:service:shiki`) renders [Shiki](https://shiki.style) syntax highlighting on the server, so plugin bundles stop shipping grammars and themes. Three RPC queries — `highlight` (dual-theme HTML), `code-to-hast`, and `code-to-tokens` (for renderers that own their DOM, e.g. diff views) — all client-`cacheable` and LRU-cached server-side per `(code, lang, themes)`. Unknown languages degrade to plain text. Options: `{ themes?, langs? }` — the default light/dark pair (defaults `vitesse-light`/`vitesse-dark`, matching the design system; later installer wins) and languages to eagerly load (merged as a union).

## Services, RPC, or shared state?
Expand Down
57 changes: 56 additions & 1 deletion packages/devframe/src/adapters/__tests__/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineDevframe } from 'devframe'
import { s } from 'devframe/utils/simple-schema'
import { describe, expect, it } from 'vitest'
import { createBuild } from '../build'

Expand Down Expand Up @@ -60,4 +61,58 @@ describe('adapters/build', () => {
rmSync(outDir, { recursive: true, force: true })
}
})

it('bakes rpc.snapshot methods a devframe does not own into the dump', async () => {
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
// A dump-less query RPC (as a wire service would register) that the
// devframe opts into baking via `rpc.snapshot` — string (no-arg), static
// inputs, and an async provider.
const def = baseDevframe({
setup: (ctx) => {
ctx.rpc.register({ name: 'demo:ping', type: 'query', jsonSerializable: true, handler: () => 'pong' })
ctx.rpc.register({
name: 'demo:echo',
type: 'query',
jsonSerializable: true,
args: [s.object({ value: s.string() })],
returns: s.object({ value: s.string() }),
handler: (input: { value: string }) => input,
})
},
rpc: {
snapshot: [
'demo:ping',
{ method: 'demo:echo', inputs: [[{ value: 'a' }]] },
{ method: 'demo:echo', inputs: async () => [[{ value: 'b' }]] },
],
},
})
try {
await createBuild(def, { outDir })
const manifest = JSON.parse(readFileSync(join(outDir, '__rpc-dump/index.json'), 'utf-8'))
// `demo:ping` baked its no-arg call + fallback (string form).
expect(manifest['demo:ping']?.type).toBe('query')
expect(manifest['demo:ping'].fallback).toBeTruthy()
// `demo:echo` baked a record per provided tuple (static + provider merged
// — the last rpc.snapshot entry for a method wins).
expect(manifest['demo:echo']?.type).toBe('query')
expect(Object.keys(manifest['demo:echo'].records).length).toBeGreaterThanOrEqual(1)
}
finally {
rmSync(outDir, { recursive: true, force: true })
}
})

it('warns (DF0072) when rpc.snapshot names an unregistered method', async () => {
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
try {
// Does not throw — a missing target is a warning, the build proceeds.
await expect(
createBuild(baseDevframe({ rpc: { snapshot: ['does:not:exist'] } }), { outDir }),
).resolves.toBeUndefined()
}
finally {
rmSync(outDir, { recursive: true, force: true })
}
})
})
39 changes: 38 additions & 1 deletion packages/devframe/src/adapters/build.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import type { DevframeDefinition } from '../types/devframe'
import type { DevframeNodeContext } from '../types/context'
import type { DevframeDefinition, DevframeSnapshotRpcEntry } from '../types/devframe'
import type { StaticAssetsSource } from '../types/remote-assets'
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
Expand Down Expand Up @@ -95,6 +96,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
await ctx.services.ready()
await d.setup(ctx)

// Bake declared `rpc.snapshot` methods (typically a wire service's RPC the
// devframe doesn't own) into the static dump by attaching a `dump` to their
// registered definitions — the service itself defines none.
applySnapshotRpc(ctx, d.rpc?.snapshot)

await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true })

const jsonSerializableMethods: string[] = []
Expand Down Expand Up @@ -134,3 +140,34 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt

console.log(c.green`[devframe] built "${d.id}" -> ${outDir}`)
}

/**
* Attach a `dump` to each {@link DevframeRpcOptions.snapshot} target so the
* static collector bakes it, even though the (service-owned) definition
* declares no dump of its own. A bare method id becomes `snapshot: true`
* (bakes the no-arg call); `{ method, inputs }` bakes one record per resolved
* argument-tuple by running the target's own handler, with the first tuple's
* output as the fallback.
*/
export function applySnapshotRpc(ctx: DevframeNodeContext, entries: readonly DevframeSnapshotRpcEntry[] | undefined): void {
for (const entry of entries ?? []) {
const method = typeof entry === 'string' ? entry : entry.method
const def = ctx.rpc.definitions.get(method)
if (!def) {
diagnostics.DF0072({ method })
continue
}
if (typeof entry === 'string') {
def.snapshot = true
continue
}
const inputsSpec = entry.inputs
def.dump = async (dumpCtx: DevframeNodeContext, handler: (...args: any[]) => any) => {
const tuples = typeof inputsSpec === 'function' ? await inputsSpec(dumpCtx) : inputsSpec
const records = []
for (const input of tuples)
records.push({ inputs: [...input] as any[], output: await handler(...input) })
return { records, fallback: records[0]?.output }
}
}
}
5 changes: 5 additions & 0 deletions packages/devframe/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,5 +194,10 @@ export const diagnostics = defineDiagnostics({
`Invalid service "${p.package}": ${p.reason}`,
fix: 'A service package\'s default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.',
},
DF0072: {
why: (p: { method: string }) =>
`\`rpc.snapshot\` names "${p.method}", but no RPC function is registered under that id — nothing to bake into the static build.`,
fix: 'Check the method id, and ensure the service/plugin that registers it is installed (e.g. declared in `services`) before the build collects the dump.',
},
},
})
33 changes: 33 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,40 @@ export interface DevframeDefinition {
* `client.services.has(pkg)` and degrade.
*/
services?: DevframeServiceInput[]
/** RPC-level configuration for this devframe (see {@link DevframeRpcOptions}). */
rpc?: DevframeRpcOptions
/** Server-side setup — the primary entrypoint. Runs in every runtime. */
setup: (ctx: DevframeNodeContext, info?: DevframeSetupInfo) => void | Promise<void>
cli?: DevframeCliOptions
}

export interface DevframeRpcOptions {
/**
* Opt an RPC function into the static-build snapshot **without owning its
* definition** — the mechanism a devframe uses to bake a wire service's
* RPC (e.g. `@devframes/service-git`'s `status`/`log`/`show`) into its
* `build` export, since the service itself defines no `dump`/`snapshot`.
*
* Each entry is either a bare method id (bakes the no-argument call, like
* `snapshot: true`) or `{ method, inputs }` where `inputs` is the list of
* argument-tuples to bake — or an async provider given the node context
* (so it can enumerate at build time, e.g. read commit hashes via the
* service's node API). `createBuild` resolves these after setup and
* executes the target's own handler per tuple; the first tuple's result
* becomes the fallback so any call variant resolves to a baked value.
*/
snapshot?: DevframeSnapshotRpcEntry[]
}

/** Argument-tuples to bake for a {@link DevframeSnapshotRpcEntry}, or a provider that computes them at build time. */
export type DevframeSnapshotRpcInputs
= readonly (readonly unknown[])[]
| ((ctx: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise<readonly (readonly unknown[])[]>)

/**
* One {@link DevframeRpcOptions.snapshot} entry: a bare method id (bakes
* the no-argument call) or a method plus the argument-tuples to bake.
*/
export type DevframeSnapshotRpcEntry
= string
| { method: string, inputs: DevframeSnapshotRpcInputs }
44 changes: 23 additions & 21 deletions plugins/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ repository dashboard with a **Next.js App Router + shadcn/ui** SPA over
type-safe RPC. The host process shells out to `git` and exposes the repository;
the same bundle runs as a live dev server or a fully static deployment.

Status, a SourceTree-style **commit graph**, branches, and diffs are read-only;
staging, unstaging, and committing are available when write mode is enabled. The
UI follows the system **light/dark** preference with a manual toggle.
Status, a SourceTree-style **commit graph**, branches, and diffs, plus staging,
unstaging, and committing — all through the shared
[`@devframes/service-git`](../../services/git) wire service. The UI follows the
system **light/dark** preference with a manual toggle.

## Install

Expand All @@ -25,7 +26,6 @@ Run the dashboard against the current repository:

```sh
pnpx @devframes/plugin-git # dev server (live RPC over WebSocket)
pnpx @devframes/plugin-git --write # also enable staging / committing from the UI
pnpx @devframes/plugin-git build # static deploy → dist-static/
pnpx @devframes/plugin-git --port 4000
```
Expand All @@ -48,30 +48,32 @@ await createCac(createGitDevframe({ repoRoot: process.cwd() })).parse()
| `basePath` | adapter-resolved | Mount path (`/` standalone, `/__git/` hosted). |
| `distDir` | bundled SPA | Override the served SPA directory. |
| `port` | `9710` | Preferred dev-server port. |
| `write` | `false` | Enable staging, unstaging, and committing from the UI. |

## RPC surface

The read functions are each a `query` with `snapshot: true`: resolved live over
WebSocket in dev, and served from a snapshot baked at build time for static
deploys. Each degrades to an empty, `isRepo: false` result outside a git
repository.

- `devframes:plugin:git:status` — branch, upstream tracking (ahead/behind), staged / unstaged /
untracked files, parsed from `git status --porcelain=v2`. Reports `canWrite`.
- `devframes:plugin:git:log` — paginated commit history (`limit` / `skip`) including parent
All git work runs through the [`@devframes/service-git`](../../services/git)
wire service, which this devframe declares (`services`) and its SPA calls
directly over `devframes:service:git:*`. The read functions are `query`
functions that degrade to an empty, `isRepo: false` result outside a git
repository; the definition opts them into the static build via `rpc.snapshot`
(resolved live over WebSocket in dev, served from a build-time snapshot for
static deploys).

- `devframes:service:git:status` — branch, upstream tracking (ahead/behind), staged / unstaged /
untracked files, parsed from `git status --porcelain=v2`.
- `devframes:service:git:log` — paginated commit history (`limit` / `skip`) including parent
hashes, which drive the commit graph.
- `devframes:plugin:git:branches` — local branches with SHA, upstream, ahead/behind, tip subject.
- `devframes:plugin:git:diff` — per-file added/deleted counts for the working tree or index, plus
- `devframes:service:git:branches` — local branches with SHA, upstream, ahead/behind, tip subject.
- `devframes:service:git:diff` — per-file added/deleted counts for the working tree or index, plus
a unified patch for a selected file.

Write actions are `action` functions, registered only when write mode is enabled
(`createGitDevframe({ write: true })` or the `--write` flag) and gated behind
`status.canWrite` in the UI. Each returns fresh status (commit returns a result):
Write actions are `action` functions — always exposed by the service, with
write authorization governed by the host's connection-trust boundary. Each
returns fresh status (commit returns a result):

- `devframes:plugin:git:stage` — `git add` the given paths.
- `devframes:plugin:git:unstage` — `git restore --staged` the given paths.
- `devframes:plugin:git:commit` — commit the staged changes with a message.
- `devframes:service:git:stage` — `git add` the given paths.
- `devframes:service:git:unstage` — `git restore --staged` the given paths.
- `devframes:service:git:commit` — commit the staged changes with a message.

## Develop

Expand Down
1 change: 1 addition & 0 deletions plugins/git/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
}
},
"dependencies": {
"@devframes/service-git": "workspace:*",
"cac": "catalog:deps",
"devframe": "workspace:*",
"pathe": "catalog:deps"
Expand Down
4 changes: 2 additions & 2 deletions plugins/git/src/client/components/commit-details-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import type { CommitDetail } from '@devframes/service-git'
import type { DevframeRpcClient } from 'devframe/client'
import type { CommitDetail } from '../../index'
import { useCallback } from 'react'
import { useRpcResource } from './use-rpc-resource'
import { CommitDetailsView } from './views/commit-details-view'
Expand All @@ -13,7 +13,7 @@ export interface CommitDetailsPanelProps {

export function CommitDetailsPanel({ hash, onClose }: CommitDetailsPanelProps) {
const loader = useCallback(
(rpc: DevframeRpcClient): Promise<CommitDetail> => rpc.call('devframes:plugin:git:show', { hash }),
(rpc: DevframeRpcClient): Promise<CommitDetail> => rpc.call('devframes:service:git:show', { hash }),
[hash],
)
const { data, loading, error } = useRpcResource<CommitDetail>(loader)
Expand Down
4 changes: 2 additions & 2 deletions plugins/git/src/client/components/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client'

import type { GitBranches } from '@devframes/service-git'
import type { DevframeRpcClient } from 'devframe/client'
import type { PointerEvent as ReactPointerEvent } from 'react'
import type { GitBranches } from '../../index'
import { useCallback, useEffect, useRef, useState } from 'react'
import { connectionIndicator, nav as navBar, navBrand, tab as tabClass, tabsList } from '../lib/design'
import { CommitDetailsPanel } from './commit-details-panel'
Expand Down Expand Up @@ -154,7 +154,7 @@ function DashboardBody() {

const rightRail = useRailWidth('devframe-git:rail-right', 480, 340, 760, -1)

const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('devframes:plugin:git:branches'), [])
const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('devframes:service:git:branches'), [])
const {
data: branches,
loading: branchesLoading,
Expand Down
8 changes: 4 additions & 4 deletions plugins/git/src/client/components/log-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import type { Commit } from '@devframes/service-git'
import type { DevframeRpcClient } from 'devframe/client'
import type { Commit } from '../../index'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRpc } from './rpc-provider'
import { LogPanelView } from './views/log-panel-view'
Expand Down Expand Up @@ -40,7 +40,7 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps
setLoading(true)
setError(null)
try {
const page = await client.call('devframes:plugin:git:log', {
const page = await client.call('devframes:service:git:log', {
limit: PAGE,
skip: nextSkip,
ref: branch ?? undefined,
Expand Down Expand Up @@ -78,7 +78,7 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps

const loadStatus = useCallback(async (client: DevframeRpcClient) => {
try {
const status = await client.call('devframes:plugin:git:status')
const status = await client.call('devframes:service:git:status')
setCurrentBranch(status.branch)
setWorkingChanges(
status.staged.length + status.unstaged.length + status.untracked.length,
Expand Down Expand Up @@ -116,7 +116,7 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps
(hash: string) => {
if (!rpc)
return Promise.reject(new Error('rpc unavailable'))
return rpc.call('devframes:plugin:git:show', { hash, patch: false })
return rpc.call('devframes:service:git:show', { hash, patch: false })
},
[rpc],
)
Expand Down
Loading
Loading