feat: build bundled plugins from TypeScript

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 12:50:42 +02:00
parent b637add6ec
commit fb9e524e5b
12 changed files with 266 additions and 32 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Build bundled Pi Web plugins from TypeScript during development and release packaging while shipping browser-loadable JavaScript modules.
+4 -4
View File
@@ -101,7 +101,7 @@ Pi Web keeps its own state intentionally small:
Pi Web production installs can load trusted local UI plugins without rebuilding Pi Web. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata. They do not run in the session daemon and are not sandboxed.
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` plugin is the canonical minimal real example, and `pi-web-plugins/pi-web` demonstrates a dynamic status panel.
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, and `pi-web-plugins/pi-web` demonstrates a dynamic status panel.
A useful prompt for AI agents:
@@ -211,7 +211,7 @@ npm run dev:web
npm run dev:client
```
You can restart `dev:web` or `dev:client` without stopping active Pi sessions.
`dev:web` also watches bundled plugin TypeScript and rebuilds the browser-loaded plugin JavaScript under `dist/pi-web-plugins/`. You can restart `dev:web` or `dev:client` without stopping active Pi sessions.
## Production-style run from a checkout
@@ -229,7 +229,7 @@ npm run pack:dry
npm publish --access public
```
`prepack` builds `dist/` before npm creates the tarball, and `prepublishOnly` runs verification before publishing. Releases can also be published by the GitHub Actions npm workflow when a GitHub release is published.
`prepack` builds `dist/` and bundled plugin JavaScript before npm creates the tarball, and `prepublishOnly` runs verification before publishing. Releases can also be published by the GitHub Actions npm workflow when a GitHub release is published.
Pi Web uses a single-line CalVer-inspired npm version: `MAJOR.YYYYMM.SEQUENCE`, for example `1.202605.1`. The major number signals breaking-change eras; the middle number is the release month; the final number increments for additional releases in that month. Older major eras may be deprecated rather than maintained in parallel.
@@ -260,7 +260,7 @@ Environment variables:
A practical local or server setup is two user services:
- `pi-web-sessiond.service` runs `npm run start:sessiond` without autoreload.
- `pi-web-ui-dev.service` runs `npm run dev:web` and `npm run dev:client` for API reloads and Vite HMR.
- `pi-web-ui-dev.service` runs `npm run dev:web` and `npm run dev:client` for API reloads, bundled plugin rebuilds, and Vite HMR.
Example units:
+8 -1
View File
@@ -143,9 +143,16 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
Pi Web ships a real bundled <strong>Info</strong> plugin. It is intentionally small while still using all
core contribution types: one action, one workspace label, and one workspace panel.
</p>
<p>
Bundled Pi Web plugins are developed as TypeScript in the repository, while their package metadata
points at the built JavaScript ES modules that the browser loads. <code>npm run dev:web</code> watches and
rebuilds bundled plugin TS into <code>dist/pi-web-plugins/</code> during development, and <code>npm run build</code>
emits JS before release packaging.
</p>
<ul>
<li><code>pi-web-plugins/info/package.json</code> shows the required metadata shape.</li>
<li><code>pi-web-plugins/info/pi-web-plugin.js</code> shows the browser module shape.</li>
<li><code>pi-web-plugins/info/pi-web-plugin.ts</code> shows the TypeScript source shape.</li>
<li><code>dist/pi-web-plugins/info/pi-web-plugin.js</code> is the built browser module in a checkout.</li>
</ul>
<p>
Read it on GitHub:
+11 -3
View File
@@ -65,11 +65,19 @@ After editing, check the manifest endpoint and browser-console failure cases.
Pi Web ships a real bundled `info` plugin. Use it as the reference example because it is intentionally small while still exercising all core contribution types: an action, a workspace label, and a workspace panel.
Files:
Bundled Pi Web plugins are developed as TypeScript in the repository, but their `package.json` metadata still points at built JavaScript because plugins are loaded by the browser as JS ES modules. `npm run dev:web` watches and rebuilds bundled plugin TS into `dist/pi-web-plugins/` during development, and `npm run build` emits the JS before packaging a release.
Source files:
```text
pi-web-plugins/info/package.json
pi-web-plugins/info/pi-web-plugin.js
pi-web-plugins/info/pi-web-plugin.ts
```
Built module:
```text
dist/pi-web-plugins/info/pi-web-plugin.js
```
Package metadata:
@@ -538,7 +546,7 @@ Pi Web does not provide a plugin cache/invalidation framework. Keep host callbac
If you are an AI agent building or editing a Pi Web plugin, follow this checklist:
1. Create or update a plugin folder with `package.json` and `pi-web-plugin.js`.
1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-web-plugin.js`.
2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module }` entries.
3. Default-export `{ apiVersion: 1, name, activate }` from the module.
4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`.
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig([
ignores: ["dist/**", "node_modules/**"],
},
{
files: ["src/**/*.ts", "extensions/**/*.ts", "vite.config.ts", "vitest.config.ts"],
files: ["src/**/*.ts", "extensions/**/*.ts", "pi-web-plugins/**/*.ts", "vite.config.ts", "vitest.config.ts"],
extends: [
js.configs.recommended,
tseslint.configs.strictTypeChecked,
+5 -4
View File
@@ -16,19 +16,20 @@
"README.md",
"LICENSE",
"extensions",
"pi-web-plugins",
"docs/plugins.md",
"docs/assets"
],
"scripts": {
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'",
"dev:sessiond": "tsx watch src/server/sessiond.ts",
"dev:web": "tsx watch src/server/index.ts",
"dev:web": "bash -c 'set -e; npm run build:plugins; trap \"kill 0\" EXIT; npm run dev:plugins & tsx watch src/server/index.ts & wait'",
"dev:server": "npm run dev:web",
"dev:client": "vite --host 0.0.0.0",
"build": "tsc -p tsconfig.build.json && vite build",
"dev:plugins": "node scripts/build-plugins.mjs --watch",
"build": "tsc -p tsconfig.build.json && npm run build:plugins && vite build",
"build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" vite.config.ts vitest.config.ts",
"lint": "eslint \"src/**/*.ts\" \"extensions/**/*.ts\" \"pi-web-plugins/**/*.ts\" vite.config.ts vitest.config.ts",
"test": "vitest run --config vitest.config.ts",
"verify": "npm run typecheck && npm run lint && npm test",
"start": "tsx src/server/index.ts",
@@ -1,4 +1,6 @@
export default {
import type { PiWebPlugin } from "../../src/client/src/plugins/types";
const plugin: PiWebPlugin = {
apiVersion: 1,
name: "Info Plugin",
activate: ({ html }) => ({
@@ -40,3 +42,5 @@ export default {
},
}),
};
export default plugin;
@@ -1,20 +1,25 @@
function messagesFor(state) {
return state?.piWebStatus?.messages ?? [];
import type { TemplateResult } from "lit";
import type { AppState } from "../../src/client/src/appState";
import type { HtmlTemplateTag, PiWebPlugin } from "../../src/client/src/plugins/types";
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse } from "../../src/shared/apiTypes";
function messagesFor(state: AppState): PiWebStatusMessage[] {
return state.piWebStatus?.messages ?? [];
}
function statusFor(state) {
return state?.piWebStatus;
function statusFor(state: AppState): PiWebStatusResponse | undefined {
return state.piWebStatus;
}
function messageCount(state) {
function messageCount(state: AppState): number {
return messagesFor(state).length;
}
function isLocalOrUnknownInstallation(installation) {
function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | undefined): boolean {
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
}
function shouldShowStatusPanel(state) {
function shouldShowStatusPanel(state: AppState): boolean {
const status = statusFor(state);
if (messageCount(state) > 0) return true;
if (status === undefined) return false;
@@ -22,15 +27,15 @@ function shouldShowStatusPanel(state) {
|| isLocalOrUnknownInstallation(status.components.sessiond.installation);
}
function formatVersion(version) {
function formatVersion(version: string | undefined): string {
return version === undefined || version === "" ? "unknown" : version;
}
function installationLabel(installation) {
function installationLabel(installation: PiWebInstallationInfo | undefined): string {
if (installation === undefined) return "installation unknown";
if (installation.kind === "pi-package") {
const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
const source = installation.source === undefined ? "Pi package" : installation.source;
const source = installation.source ?? "Pi package";
return `${source}${scope}`;
}
if (installation.kind === "npm-global") return "global npm package";
@@ -38,8 +43,8 @@ function installationLabel(installation) {
return "installation unknown";
}
function renderComponent(html, component) {
const status = component.available === false
function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus): TemplateResult {
const status = !component.available
? "unavailable"
: component.stale
? "restart needed"
@@ -54,17 +59,17 @@ function renderComponent(html, component) {
`;
}
function renderCommand(html, label, command) {
function renderCommand(html: HtmlTemplateTag, label: string, command: string): TemplateResult {
return html`
<div class="pi-web-command">
<span>${label}</span>
<code>${command}</code>
<button @click=${() => { void navigator.clipboard?.writeText(command); }}>Copy</button>
<button @click=${() => { void navigator.clipboard.writeText(command); }}>Copy</button>
</div>
`;
}
function renderStatusPanel(html, state) {
function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResult {
const status = statusFor(state);
if (status === undefined) {
return html`
@@ -125,7 +130,7 @@ function renderStatusPanel(html, state) {
`;
}
export default {
const plugin: PiWebPlugin = {
apiVersion: 1,
name: "Pi Web Status",
activate: ({ html }) => ({
@@ -146,3 +151,5 @@ export default {
},
}),
};
export default plugin;
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
import { watch } from "node:fs";
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import ts from "typescript";
const rootDir = resolve("pi-web-plugins");
const outDir = resolve("dist/pi-web-plugins");
const watchMode = process.argv.includes("--watch");
const cwd = process.cwd();
if (watchMode) {
await watchAndBuild();
} else {
await buildAll();
}
async function buildAll() {
await rm(outDir, { recursive: true, force: true });
const result = await buildDirectory(rootDir, outDir);
const suffix = result.transpiled === 1 ? "file" : "files";
console.log(`[plugins] built ${String(result.transpiled)} TypeScript plugin ${suffix} into ${relative(cwd, outDir)}`);
}
async function buildDirectory(sourceDir, targetDir) {
const entries = await readDirectory(sourceDir);
let copied = 0;
let transpiled = 0;
for (const entry of entries) {
const sourcePath = resolve(sourceDir, entry.name);
const targetPath = resolve(targetDir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") continue;
const result = await buildDirectory(sourcePath, targetPath);
copied += result.copied;
transpiled += result.transpiled;
continue;
}
if (!entry.isFile()) continue;
if (entry.name.endsWith(".d.ts")) continue;
if (isPluginSource(entry.name)) {
await buildFile(sourcePath, targetPath.replace(/\.ts$/u, ".js"));
transpiled += 1;
continue;
}
if (entry.name.endsWith(".js") && await hasTypeScriptSource(sourcePath)) continue;
await mkdir(dirname(targetPath), { recursive: true });
await copyFile(sourcePath, targetPath);
copied += 1;
}
return { copied, transpiled };
}
async function buildFile(file, outputPath) {
const source = await readFile(file, "utf8");
const transpiled = ts.transpileModule(source, {
fileName: file,
reportDiagnostics: true,
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
verbatimModuleSyntax: true,
sourceMap: false,
inlineSourceMap: false,
},
});
const errors = (transpiled.diagnostics ?? []).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
if (errors.length > 0) throw new Error(formatDiagnostics(errors));
const output = `// Generated from ${relative(cwd, file)}. Do not edit directly.\n${transpiled.outputText}`;
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, output);
}
async function findPluginDirs(dir) {
const entries = await readDirectory(dir);
const dirs = [dir];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "node_modules") continue;
dirs.push(...await findPluginDirs(resolve(dir, entry.name)));
}
return dirs.sort((left, right) => left.localeCompare(right));
}
function isPluginSource(fileName) {
return fileName.endsWith(".ts") && !fileName.endsWith(".d.ts");
}
async function hasTypeScriptSource(javaScriptPath) {
const typeScriptPath = javaScriptPath.replace(/\.js$/u, ".ts");
try {
await readFile(typeScriptPath, "utf8");
return true;
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return false;
throw error;
}
}
async function readDirectory(dir) {
try {
return await readdir(dir, { withFileTypes: true });
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return [];
throw error;
}
}
async function watchAndBuild() {
let watchers = [];
let timer;
let building = false;
let pending = false;
const closeWatchers = () => {
for (const watcher of watchers) watcher.close();
watchers = [];
};
const refreshWatchers = async () => {
closeWatchers();
const dirs = await findPluginDirs(rootDir);
watchers = dirs.map((dir) => watch(dir, () => scheduleBuild()));
};
const runBuild = async () => {
if (building) {
pending = true;
return;
}
building = true;
try {
do {
pending = false;
await refreshWatchers();
await buildAll();
} while (pending);
} catch (error) {
console.error(`[plugins] ${formatUnknownError(error)}`);
} finally {
building = false;
}
};
const scheduleBuild = () => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void runBuild();
}, 100);
};
const stop = () => {
if (timer !== undefined) clearTimeout(timer);
closeWatchers();
process.exit(0);
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
await runBuild();
console.log(`[plugins] watching ${relative(cwd, rootDir)}`);
await new Promise(() => undefined);
}
function formatDiagnostics(diagnostics) {
return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: () => cwd,
getNewLine: () => "\n",
});
}
function formatUnknownError(error) {
return error instanceof Error ? error.message : String(error);
}
function isNodeError(error) {
return error instanceof Error && "code" in error;
}
+6 -1
View File
@@ -150,12 +150,17 @@ export class PiWebPluginService {
function defaultPluginRoots(): LocalPluginRoot[] {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(moduleDir, "..", "..");
return [
{ path: join(moduleDir, "..", "..", "pi-web-plugins"), source: "bundled", scope: "bundled" },
{ path: bundledPluginRoot(packageRoot), source: "bundled", scope: "bundled" },
{ path: join(piWebDataDir(), "plugins"), source: "local", scope: "local" },
];
}
function bundledPluginRoot(packageRoot: string): string {
return join(packageRoot, "dist", "pi-web-plugins");
}
async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]> {
if (!existsSync(root.path)) return [];
const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
+2 -1
View File
@@ -30,6 +30,7 @@
"src/**/*.ts",
"vite.config.ts",
"vitest.config.ts",
"extensions/**/*.ts"
"extensions/**/*.ts",
"pi-web-plugins/**/*.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["pi-web-plugins/**/*.ts"]
}