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. 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: A useful prompt for AI agents:
@@ -211,7 +211,7 @@ npm run dev:web
npm run dev:client 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 ## Production-style run from a checkout
@@ -229,7 +229,7 @@ npm run pack:dry
npm publish --access public 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. 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: A practical local or server setup is two user services:
- `pi-web-sessiond.service` runs `npm run start:sessiond` without autoreload. - `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: 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 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. core contribution types: one action, one workspace label, and one workspace panel.
</p> </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> <ul>
<li><code>pi-web-plugins/info/package.json</code> shows the required metadata shape.</li> <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> </ul>
<p> <p>
Read it on GitHub: 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. 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 ```text
pi-web-plugins/info/package.json 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: 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: 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. 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. 3. Default-export `{ apiVersion: 1, name, activate }` from the module.
4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`. 4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`.
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig([
ignores: ["dist/**", "node_modules/**"], 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: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.strictTypeChecked, tseslint.configs.strictTypeChecked,
+5 -4
View File
@@ -16,19 +16,20 @@
"README.md", "README.md",
"LICENSE", "LICENSE",
"extensions", "extensions",
"pi-web-plugins",
"docs/plugins.md", "docs/plugins.md",
"docs/assets" "docs/assets"
], ],
"scripts": { "scripts": {
"dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:sessiond & npm run dev:web & npm run dev:client & wait'", "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: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:server": "npm run dev:web",
"dev:client": "vite --host 0.0.0.0", "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", "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", "test": "vitest run --config vitest.config.ts",
"verify": "npm run typecheck && npm run lint && npm test", "verify": "npm run typecheck && npm run lint && npm test",
"start": "tsx src/server/index.ts", "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, apiVersion: 1,
name: "Info Plugin", name: "Info Plugin",
activate: ({ html }) => ({ activate: ({ html }) => ({
@@ -40,3 +42,5 @@ export default {
}, },
}), }),
}; };
export default plugin;
@@ -1,20 +1,25 @@
function messagesFor(state) { import type { TemplateResult } from "lit";
return state?.piWebStatus?.messages ?? []; 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) { function statusFor(state: AppState): PiWebStatusResponse | undefined {
return state?.piWebStatus; return state.piWebStatus;
} }
function messageCount(state) { function messageCount(state: AppState): number {
return messagesFor(state).length; return messagesFor(state).length;
} }
function isLocalOrUnknownInstallation(installation) { function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | undefined): boolean {
return installation === undefined || installation.kind === "local" || installation.kind === "unknown"; return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
} }
function shouldShowStatusPanel(state) { function shouldShowStatusPanel(state: AppState): boolean {
const status = statusFor(state); const status = statusFor(state);
if (messageCount(state) > 0) return true; if (messageCount(state) > 0) return true;
if (status === undefined) return false; if (status === undefined) return false;
@@ -22,15 +27,15 @@ function shouldShowStatusPanel(state) {
|| isLocalOrUnknownInstallation(status.components.sessiond.installation); || isLocalOrUnknownInstallation(status.components.sessiond.installation);
} }
function formatVersion(version) { function formatVersion(version: string | undefined): string {
return version === undefined || version === "" ? "unknown" : version; return version === undefined || version === "" ? "unknown" : version;
} }
function installationLabel(installation) { function installationLabel(installation: PiWebInstallationInfo | undefined): string {
if (installation === undefined) return "installation unknown"; if (installation === undefined) return "installation unknown";
if (installation.kind === "pi-package") { if (installation.kind === "pi-package") {
const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`; 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}`; return `${source}${scope}`;
} }
if (installation.kind === "npm-global") return "global npm package"; if (installation.kind === "npm-global") return "global npm package";
@@ -38,8 +43,8 @@ function installationLabel(installation) {
return "installation unknown"; return "installation unknown";
} }
function renderComponent(html, component) { function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus): TemplateResult {
const status = component.available === false const status = !component.available
? "unavailable" ? "unavailable"
: component.stale : component.stale
? "restart needed" ? "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` return html`
<div class="pi-web-command"> <div class="pi-web-command">
<span>${label}</span> <span>${label}</span>
<code>${command}</code> <code>${command}</code>
<button @click=${() => { void navigator.clipboard?.writeText(command); }}>Copy</button> <button @click=${() => { void navigator.clipboard.writeText(command); }}>Copy</button>
</div> </div>
`; `;
} }
function renderStatusPanel(html, state) { function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResult {
const status = statusFor(state); const status = statusFor(state);
if (status === undefined) { if (status === undefined) {
return html` return html`
@@ -125,7 +130,7 @@ function renderStatusPanel(html, state) {
`; `;
} }
export default { const plugin: PiWebPlugin = {
apiVersion: 1, apiVersion: 1,
name: "Pi Web Status", name: "Pi Web Status",
activate: ({ html }) => ({ 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[] { function defaultPluginRoots(): LocalPluginRoot[] {
const moduleDir = dirname(fileURLToPath(import.meta.url)); const moduleDir = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(moduleDir, "..", "..");
return [ 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" }, { 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[]> { async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]> {
if (!existsSync(root.path)) return []; if (!existsSync(root.path)) return [];
const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []); const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
+2 -1
View File
@@ -30,6 +30,7 @@
"src/**/*.ts", "src/**/*.ts",
"vite.config.ts", "vite.config.ts",
"vitest.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"]
}