From fb9e524e5b419b71153bc3754851453430a7c445 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
Date: Tue, 19 May 2026 12:50:42 +0200
Subject: [PATCH] feat: build bundled plugins from TypeScript
---
.changeset/plugin-typescript-build.md | 5 +
README.md | 8 +-
docs/plugins.html | 9 +-
docs/plugins.md | 14 +-
eslint.config.js | 2 +-
package.json | 9 +-
.../{pi-web-plugin.js => pi-web-plugin.ts} | 6 +-
.../{pi-web-plugin.js => pi-web-plugin.ts} | 39 ++--
scripts/build-plugins.mjs | 189 ++++++++++++++++++
src/server/piWebPluginService.ts | 7 +-
tsconfig.json | 3 +-
tsconfig.plugins.json | 7 +
12 files changed, 266 insertions(+), 32 deletions(-)
create mode 100644 .changeset/plugin-typescript-build.md
rename pi-web-plugins/info/{pi-web-plugin.js => pi-web-plugin.ts} (90%)
rename pi-web-plugins/pi-web/{pi-web-plugin.js => pi-web-plugin.ts} (79%)
create mode 100644 scripts/build-plugins.mjs
create mode 100644 tsconfig.plugins.json
diff --git a/.changeset/plugin-typescript-build.md b/.changeset/plugin-typescript-build.md
new file mode 100644
index 0000000..dfa2aae
--- /dev/null
+++ b/.changeset/plugin-typescript-build.md
@@ -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.
diff --git a/README.md b/README.md
index 297e0fa..8fe12f2 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/docs/plugins.html b/docs/plugins.html
index dfe3f66..9c54d13 100644
--- a/docs/plugins.html
+++ b/docs/plugins.html
@@ -143,9 +143,16 @@ After editing, check the manifest endpoint and browser-console failure cases.Info plugin. It is intentionally small while still using all
core contribution types: one action, one workspace label, and one workspace panel.
+
+ 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. npm run dev:web watches and
+ rebuilds bundled plugin TS into dist/pi-web-plugins/ during development, and npm run build
+ emits JS before release packaging.
+
pi-web-plugins/info/package.json shows the required metadata shape.
-
pi-web-plugins/info/pi-web-plugin.js shows the browser module shape.
+
pi-web-plugins/info/pi-web-plugin.ts shows the TypeScript source shape.
+
dist/pi-web-plugins/info/pi-web-plugin.js is the built browser module in a checkout.
Read it on GitHub:
diff --git a/docs/plugins.md b/docs/plugins.md
index aeca357..609fbbc 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -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()`.
diff --git a/eslint.config.js b/eslint.config.js
index 75e5487..ae08e23 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -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,
diff --git a/package.json b/package.json
index 04e37bb..318eec6 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pi-web-plugins/info/pi-web-plugin.js b/pi-web-plugins/info/pi-web-plugin.ts
similarity index 90%
rename from pi-web-plugins/info/pi-web-plugin.js
rename to pi-web-plugins/info/pi-web-plugin.ts
index ec24cc2..52dc709 100644
--- a/pi-web-plugins/info/pi-web-plugin.js
+++ b/pi-web-plugins/info/pi-web-plugin.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;
diff --git a/pi-web-plugins/pi-web/pi-web-plugin.js b/pi-web-plugins/pi-web/pi-web-plugin.ts
similarity index 79%
rename from pi-web-plugins/pi-web/pi-web-plugin.js
rename to pi-web-plugins/pi-web/pi-web-plugin.ts
index ac6d31a..aa48e8e 100644
--- a/pi-web-plugins/pi-web/pi-web-plugin.js
+++ b/pi-web-plugins/pi-web/pi-web-plugin.ts
@@ -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`