Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,10 @@ workers/pluggable_worker/*
# FE Plugins
frontend/src/plugins/*
frontend/public/llm-whisperer/
# v1 plugins come from the cloud overlay; only the placeholder and the loader are OSS.
frontend/src/v1/plugins/*
!frontend/src/v1/plugins/.gitkeep
!frontend/src/v1/plugins/index.ts


# TODO: Ensure its made generic to abstract subfolder and file names
Expand Down
6 changes: 6 additions & 0 deletions docker/dockerfiles/frontend.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ RUN bun install --frozen-lockfile --ignore-scripts
# Copy the rest of the application files
COPY ${BUILD_CONTEXT_PATH}/ .

# Which v1 plugins the build offers (oss, cloud or onprem); the cloud
# workflows set it. Declared after install so a different value keeps the
# dependency layer cached. See frontend/src/v1/README.md.
ARG VITE_DISTRIBUTION=oss
ENV VITE_DISTRIBUTION=${VITE_DISTRIBUTION}

# Build with Vite
RUN bun run build

Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"typecheck": "tsc --noEmit",
"typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json",
"lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code",
"test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs"
"test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs scripts/v1-plugins.fixtures.mjs"
},
"browserslist": {
"production": [
Expand Down
156 changes: 156 additions & 0 deletions frontend/scripts/v1-plugins.fixtures.mjs
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"]);
});
142 changes: 142 additions & 0 deletions frontend/scripts/v1-plugins.mjs
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);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
} else {
process.stdout.write(json);
}
})
.catch((err) => {

Check warning on line 138 in frontend/scripts/v1-plugins.mjs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer top-level await over using a promise chain.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGBOzVyg9nkodCSYDuD&open=AaGBOzVyg9nkodCSYDuD&pullRequest=2394
console.error(`v1-plugins: ${err.message}`);
process.exit(1);
});
}
13 changes: 13 additions & 0 deletions frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ Every folder may import from itself. Test files (`*.test.ts(x)`,
| `plugins/` | Cloud overlay target; only a placeholder in the public repository | everything |
| `test/` | Render helpers, fixtures, network mocks | everything |

## Plugins

`lib/plugins/` is the extension-point contract. Pages and the shell import `Slot` from `@v1/lib/plugins`; they never import a plugin. `plugins/index.ts` builds the registry from the manifests and exports `PluginSlot` and `pluginRoutes()`; `app/` passes `PluginSlot` to `PluginProvider` and the routes to the router. With no provider, every slot shows its fallback.

- `types.ts` names every slot and its props. Adding a slot is one line there.
- A plugin is `plugins/<id>/manifest.ts` exporting `manifest` (`satisfies PluginManifest`), with `distributions` (`cloud`, `onprem`) and lazy `slots` and `routes`. The folder name is the id. A manifest may only `import type`: the build check loads it without bundling.
- `<Slot id props fallback />` shows the fallback when no plugin fills the slot, silently. A filler that fails to load or render shows the fallback and is reported; each slot has its own error boundary.
- `VITE_DISTRIBUTION` (`oss`, the default, `cloud` or `onprem`) picks the plugins the build offers. The frontend Dockerfile takes it as a build arg.
- Every build runs `scripts/v1-plugins.mjs`. It fails on a slot filled twice or a route defined twice in any distribution, or a plugin with no distribution, and writes `plugin-matrix.json` to the build output. `node scripts/v1-plugins.mjs` prints the matrix.
- `plugin-matrix.json` lists, for `oss`, `cloud` and `onprem`, the plugin ids each build offers, and for every plugin its distributions, slots and routes. Release tooling reads it to see which plugins ship where.
- The check also fails on a slot id or distribution the contract does not define, and on a `plugins/` folder with no `manifest.ts`.
- v0's optional-plugin stub skips `src/v1`, so a missing module in v1 fails the build.

## Scripts

| Script | Checks |
Expand Down
Loading
Loading