Archived
feat: build bundled plugins from TypeScript
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -30,6 +30,7 @@
|
||||
"src/**/*.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"extensions/**/*.ts"
|
||||
"extensions/**/*.ts",
|
||||
"pi-web-plugins/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["pi-web-plugins/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user