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. +

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`

${label} ${command} - +
`; } -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; diff --git a/scripts/build-plugins.mjs b/scripts/build-plugins.mjs new file mode 100644 index 0000000..1a626b6 --- /dev/null +++ b/scripts/build-plugins.mjs @@ -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; +} diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index f9c72c7..8c2c228 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -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 { if (!existsSync(root.path)) return []; const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []); diff --git a/tsconfig.json b/tsconfig.json index 750b19a..8c6c695 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ "src/**/*.ts", "vite.config.ts", "vitest.config.ts", - "extensions/**/*.ts" + "extensions/**/*.ts", + "pi-web-plugins/**/*.ts" ] } diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json new file mode 100644 index 0000000..220207b --- /dev/null +++ b/tsconfig.plugins.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["pi-web-plugins/**/*.ts"] +}