Repository navigation
UN-4286 [FEAT] Add the v1 plugin manifest, slots and build check #2394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
athul-rs
wants to merge
11
commits into
UN-4270-v1-scaffold
Choose a base branch
from
UN-4286-plugin-manifest
base: UN-4270-v1-scaffold
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
7e66cc5
UN-4286 [FEAT] Add the v1 plugin manifest, slots and build check
athul-rs 1f426f9
UN-4286 [FEAT] Pass VITE_DISTRIBUTION through the frontend Docker build
athul-rs 7ae6de5
UN-4286 [FEAT] Keep overlaid v1 plugins out of OSS commits
athul-rs cbfd332
UN-4286 [FEAT] Build the plugin registry in plugins/ and hand Slot to…
athul-rs 1babd39
UN-4286 [FIX] Let v1 tests typecheck until the scaffold settles the c…
athul-rs 861c7b6
UN-4286 [FIX] Retry a slot's import after a failed load
athul-rs 6c31314
UN-4286 [FIX] Reject unknown slots and distributions and load manifes…
athul-rs 927f4e0
UN-4286 [FIX] Load esbuild only under Node, so the cloud overlay runs…
athul-rs 8ede7ec
UN-4286 [FIX] Write the plugin matrix to a file given without --dir, …
athul-rs 5312a6b
Merge remote-tracking branch 'origin/UN-4270-v1-scaffold' into UN-428…
athul-rs 6b2df4d
UN-4286 [FIX] Give the missing-import test a template the v1 guard ca…
athul-rs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| // Fixtures for v1-plugins.mjs. Run with `node --test` (bun run test:v1). | ||
| import assert from "node:assert/strict"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { test } from "node:test"; | ||
| import { check } from "./v1-plugins.mjs"; | ||
|
|
||
| function plugins(manifests) { | ||
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), "v1-plugins-")); | ||
| for (const [id, body] of Object.entries(manifests)) { | ||
| fs.mkdirSync(path.join(dir, id)); | ||
| fs.writeFileSync(path.join(dir, id, "manifest.ts"), body); | ||
| } | ||
| return dir; | ||
| } | ||
|
|
||
| const manifest = (id, distributions, extra = "") => ` | ||
| import type { PluginManifest } from "@v1/lib/plugins/types"; | ||
| export const manifest = { | ||
| id: "${id}", | ||
| distributions: ${JSON.stringify(distributions)}, | ||
| ${extra} | ||
| } satisfies PluginManifest; | ||
| `; | ||
|
|
||
| test("the matrix lists each plugin per distribution", async () => { | ||
| const dir = plugins({ | ||
| billing: manifest( | ||
| "billing", | ||
| ["cloud"], | ||
| `slots: { "account-menu.plan-billing": () => import("./Billing") }, | ||
| routes: [{ path: "/billing", load: () => import("./Billing") }],`, | ||
| ), | ||
| switcher: manifest("switcher", ["cloud", "onprem"]), | ||
| }); | ||
| assert.deepEqual(await check(dir), { | ||
| distributions: { | ||
| oss: [], | ||
| cloud: ["billing", "switcher"], | ||
| onprem: ["switcher"], | ||
| }, | ||
| plugins: [ | ||
| { | ||
| id: "billing", | ||
| distributions: ["cloud"], | ||
| slots: ["account-menu.plan-billing"], | ||
| routes: ["/billing"], | ||
| }, | ||
| { | ||
| id: "switcher", | ||
| distributions: ["cloud", "onprem"], | ||
| slots: [], | ||
| routes: [], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| test("no plugins directory gives an empty matrix", async () => { | ||
| assert.deepEqual(await check(path.join(os.tmpdir(), "v1-plugins-none")), { | ||
| distributions: { oss: [], cloud: [], onprem: [] }, | ||
| plugins: [], | ||
| }); | ||
| }); | ||
|
|
||
| test("a slot filled twice fails, naming both plugins", async () => { | ||
| const slot = `slots: { "shell.product-switcher": () => import("./X") },`; | ||
| const dir = plugins({ | ||
| a: manifest("a", ["cloud"], slot), | ||
| b: manifest("b", ["cloud"], slot), | ||
| }); | ||
| await assert.rejects(check(dir), /shell\.product-switcher.*\ba\b.*\bb\b/); | ||
| }); | ||
|
|
||
| test("a manifest whose id differs from its folder fails", async () => { | ||
| const dir = plugins({ billing: manifest("payments", ["cloud"]) }); | ||
| await assert.rejects( | ||
| check(dir), | ||
| /billing\/manifest\.ts declares id payments/, | ||
| ); | ||
| }); | ||
|
|
||
| test("a manifest with a runtime import fails with a clear message", async () => { | ||
| const dir = plugins({ | ||
| bad: `import { x } from "@v1/lib/plugins/registry";\nexport const manifest = x;\n`, | ||
| }); | ||
| await assert.rejects(check(dir), /bad\/manifest\.ts.*import type/); | ||
| }); | ||
|
|
||
| test("a slot id the contract does not define fails, naming the plugin", async () => { | ||
| const dir = plugins({ | ||
| typo: manifest( | ||
| "typo", | ||
| ["cloud"], | ||
| `slots: { "shell.product-swicher": () => import("./X") },`, | ||
| ), | ||
| }); | ||
| await assert.rejects(check(dir), /typo.*shell\.product-swicher/); | ||
| }); | ||
|
|
||
| test("a distribution no build offers fails, naming the plugin", async () => { | ||
| const dir = plugins({ stray: manifest("stray", ["cloud", "onprem-x"]) }); | ||
| await assert.rejects(check(dir), /stray.*onprem-x/); | ||
| }); | ||
|
|
||
| test("a folder without a manifest fails, but dotfiles are ignored", async () => { | ||
| const dir = plugins({ billing: manifest("billing", ["cloud"]) }); | ||
| fs.mkdirSync(path.join(dir, ".cache")); | ||
| fs.writeFileSync(path.join(dir, "index.ts"), "export {};\n"); | ||
| assert.deepEqual((await check(dir)).distributions.cloud, ["billing"]); | ||
| fs.mkdirSync(path.join(dir, "empty")); | ||
| await assert.rejects(check(dir), /empty\/ has no manifest\.ts/); | ||
| }); | ||
|
|
||
| const script = path.join(import.meta.dirname, "v1-plugins.mjs"); | ||
| const cli = (dir) => | ||
| spawnSync(process.execPath, [script, "--dir", dir], { encoding: "utf8" }); | ||
|
|
||
| test("the CLI exits 1 on a conflict, naming both plugins", () => { | ||
| const slot = `slots: { "shell.product-switcher": () => import("./X") },`; | ||
| const result = cli( | ||
| plugins({ | ||
| a: manifest("a", ["cloud"], slot), | ||
| b: manifest("b", ["cloud"], slot), | ||
| }), | ||
| ); | ||
| assert.equal(result.status, 1); | ||
| assert.match(result.stderr, /shell\.product-switcher.*\ba\b.*\bb\b/); | ||
| }); | ||
|
|
||
| test("the CLI exits 0 and prints the matrix on a clean directory", () => { | ||
| const result = cli(plugins({ billing: manifest("billing", ["cloud"]) })); | ||
| assert.equal(result.status, 0); | ||
| assert.deepEqual(JSON.parse(result.stdout).distributions.cloud, ["billing"]); | ||
| }); | ||
|
|
||
| test("the CLI writes the matrix to the file argument, with or without --dir", () => { | ||
| const out = path.join( | ||
| fs.mkdtempSync(path.join(os.tmpdir(), "v1-out-")), | ||
| "m.json", | ||
| ); | ||
| const bare = spawnSync(process.execPath, [script, out], { encoding: "utf8" }); | ||
| assert.equal(bare.status, 0, bare.stderr); | ||
| assert.equal(bare.stdout, ""); | ||
| assert.ok(JSON.parse(fs.readFileSync(out, "utf8")).distributions); | ||
|
|
||
| const dir = plugins({ billing: manifest("billing", ["cloud"]) }); | ||
| const before = spawnSync(process.execPath, [script, out, "--dir", dir], { | ||
| encoding: "utf8", | ||
| }); | ||
| assert.equal(before.status, 0, before.stderr); | ||
| const matrix = JSON.parse(fs.readFileSync(out, "utf8")); | ||
| assert.deepEqual(matrix.distributions.cloud, ["billing"]); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| #!/usr/bin/env node | ||
| /* | ||
| * Build check for v1 plugins (src/v1/plugins/<id>/manifest.ts, copied in by | ||
| * the cloud overlay). Under Bun it imports the TypeScript directly; under Node | ||
| * it transpiles with esbuild, loaded only then, because the cloud overlay runs | ||
| * this with Bun before any dependency is installed. Runs the registry's | ||
| * conflict checks for every distribution and returns the distribution matrix. vite.config.js runs it in every build and publishes | ||
| * the matrix as plugin-matrix.json. | ||
| * | ||
| * node scripts/v1-plugins.mjs [out.json] [--dir plugins] check, print or write the matrix | ||
| */ | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { pathToFileURL } from "node:url"; | ||
|
|
||
| const DEFAULT_DIR = path.join(import.meta.dirname, "../src/v1/plugins"); | ||
| const REGISTRY = path.join( | ||
| import.meta.dirname, | ||
| "../src/v1/lib/plugins/registry.ts", | ||
| ); | ||
|
|
||
| const dataUrl = (code) => | ||
| `data:text/javascript;base64,${Buffer.from(code).toString("base64")}`; | ||
|
|
||
| const BUN = Boolean(process.versions.bun); | ||
|
|
||
| let registry; | ||
| function loadRegistry() { | ||
| if (BUN) { | ||
| registry ??= import(pathToFileURL(REGISTRY).href); | ||
| return registry; | ||
| } | ||
| registry ??= import("esbuild") | ||
| .then(({ build }) => | ||
| build({ | ||
| entryPoints: [REGISTRY], | ||
| bundle: true, | ||
| write: false, | ||
| format: "esm", | ||
| platform: "node", | ||
| }), | ||
| ) | ||
| .then((r) => import(dataUrl(r.outputFiles[0].text))); | ||
| return registry; | ||
| } | ||
|
|
||
| async function importManifest(file) { | ||
| if (BUN) { | ||
| return import(pathToFileURL(file).href); | ||
| } | ||
| const { transform } = await import("esbuild"); | ||
| const { code } = await transform(fs.readFileSync(file, "utf8"), { | ||
| loader: "ts", | ||
| format: "esm", | ||
| sourcefile: file, | ||
| }); | ||
| return import(dataUrl(code)); | ||
| } | ||
|
|
||
| async function load(dir, id) { | ||
| const file = path.join(dir, id, "manifest.ts"); | ||
| const rel = `${id}/manifest.ts`; | ||
| let mod; | ||
| try { | ||
| mod = await importManifest(file); | ||
| } catch (err) { | ||
| throw new Error( | ||
| `${rel} could not be loaded (${err.message}). A manifest may only ` + | ||
| '`import type`; load components with `() => import("./X")`.', | ||
| ); | ||
| } | ||
| const { manifest } = mod; | ||
| if (!manifest) { | ||
| throw new Error(`${rel} does not export \`manifest\``); | ||
| } | ||
| if (manifest.id !== id) { | ||
| throw new Error( | ||
| `${rel} declares id ${manifest.id}; it must match its folder`, | ||
| ); | ||
| } | ||
| return manifest; | ||
| } | ||
|
|
||
| export async function check(dir = DEFAULT_DIR) { | ||
| const ids = fs.existsSync(dir) | ||
| ? fs | ||
| .readdirSync(dir, { withFileTypes: true }) | ||
| .filter((e) => e.isDirectory() && !e.name.startsWith(".")) | ||
| .map((e) => e.name) | ||
| .sort() | ||
| : []; | ||
| for (const id of ids) { | ||
| if (!fs.existsSync(path.join(dir, id, "manifest.ts"))) { | ||
| throw new Error(`${id}/ has no manifest.ts`); | ||
| } | ||
| } | ||
| const manifests = await Promise.all(ids.map((id) => load(dir, id))); | ||
| const { buildRegistry } = await loadRegistry(); | ||
| // Throws on any conflict in any distribution. | ||
| const offered = (d) => [...buildRegistry(manifests, d).plugins]; | ||
| return { | ||
| distributions: { | ||
| oss: offered("oss"), | ||
| cloud: offered("cloud"), | ||
| onprem: offered("onprem"), | ||
| }, | ||
| plugins: manifests.map((m) => ({ | ||
| id: m.id, | ||
| distributions: [...m.distributions], | ||
| slots: Object.keys(m.slots ?? {}), | ||
| routes: (m.routes ?? []).map((r) => r.path), | ||
| })), | ||
| }; | ||
| } | ||
|
|
||
| // No top-level await: vite.config.js imports this file and Vite bundles the | ||
| // config as CommonJS. | ||
| const isMain = | ||
| process.argv[1] !== undefined && | ||
| import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href; | ||
|
|
||
| if (isMain) { | ||
| const args = process.argv.slice(2); | ||
| const flag = args.indexOf("--dir"); | ||
| const dir = flag === -1 ? DEFAULT_DIR : path.resolve(args[flag + 1]); | ||
| const out = args.find( | ||
| (a, i) => !a.startsWith("--") && (flag === -1 || i !== flag + 1), | ||
| ); | ||
| check(dir) | ||
| .then((matrix) => { | ||
| const json = `${JSON.stringify(matrix, null, 2)}\n`; | ||
| if (out) { | ||
| fs.writeFileSync(out, json); | ||
| } else { | ||
| process.stdout.write(json); | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
|
Check warning on line 138 in frontend/scripts/v1-plugins.mjs
|
||
| console.error(`v1-plugins: ${err.message}`); | ||
| process.exit(1); | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.