Skip to content
Merged
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
15 changes: 14 additions & 1 deletion apps/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { copyToClipboard } from "../lib/clipboard";
import { loadConfig, saveConfig } from "../lib/config";
import { RegistryClient } from "../lib/registry";

/** True for an http(s) URL, so we never hand a non-web scheme to the OS browser opener. */
function isWebUrl(value: string): boolean {
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}

export const login = defineCommand({
name: "login",
description: "Authorize this machine (GitHub device flow)",
Expand All @@ -28,7 +38,10 @@ export const login = defineCommand({
// code in the URL so the page can pre-fill it; the note above is the manual
// fallback when the browser cannot be opened (CI, SSH, `--no-browser`).
const approvalUrl = device.verification_uri_complete ?? device.verification_uri;
const opened = values.noBrowser ? false : await openBrowser(approvalUrl);
// Only hand an http(s) URL to the OS opener; a hostile/MITM'd registry could otherwise point it
// at a local file or app scheme. Non-web URLs fall through to the printed-code path below.
const opened =
values.noBrowser || !isWebUrl(approvalUrl) ? false : await openBrowser(approvalUrl);
if (opened) {
p.log.info("Opened your browser to finish login.");
} else {
Expand Down
20 changes: 15 additions & 5 deletions apps/cli/src/commands/prepare.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { type Packed, packDirectory } from "../lib/tarball";
import { printSummary } from "./summary";
import { assertLocalesValid, assertPublishable } from "./validate";
import {
assertCapabilitiesBacked,
assertCompiles,
assertFilesPresent,
assertLocalesValid,
assertPublishable,
} from "./validate";

/**
* Pack a plugin directory, validate it against the publish contract, and print
* the tarball summary. Shared by `pack` and `publish`; throws a CliError when
* the manifest is not a publishable Brika plugin or a bundled locale file does
* not match the store-metadata schema.
* Pack a plugin directory, validate it against the publish contract, confirm every declared
* capability is backed by code and that it compiles, and print the tarball summary. Shared by `pack`
* and `publish`; throws a CliError when the manifest is not a publishable Brika plugin, references
* files missing from the tarball, declares a capability it never ships, has invalid localized store
* metadata, or does not compile (a fake/broken plugin caught before upload).
*/
export async function prepare(dir: string): Promise<Packed> {
const packed = await packDirectory(dir);
assertPublishable(packed);
assertFilesPresent(packed);
assertCapabilitiesBacked(packed);
assertLocalesValid(packed);
await assertCompiles(dir, packed);
printSummary(packed);
return packed;
}
44 changes: 44 additions & 0 deletions apps/cli/src/commands/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { packDirectory } from "../lib/tarball";
import { assertCompiles } from "./validate";

let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "brika-compile-test-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

async function pkg(manifest: Record<string, unknown>, main: string) {
await writeFile(
join(dir, "package.json"),
JSON.stringify({ name: "@acme/x", version: "1.0.0", main: "./index.ts", ...manifest }),
);
await writeFile(join(dir, "index.ts"), main);
}

describe("assertCompiles", () => {
test("passes for a plugin whose entrypoint compiles", async () => {
await pkg({}, "export default function plugin() {\n return 1;\n}\n");
await expect(assertCompiles(dir, await packDirectory(dir))).resolves.toBeUndefined();
});

test("throws for a plugin with a syntax error (a fake/broken plugin)", async () => {
await pkg({}, "export default function plugin( {\n"); // unbalanced paren
await expect(assertCompiles(dir, await packDirectory(dir))).rejects.toThrow(
/does not compile|failed to compile/,
);
});

test("treats declared dependencies as external (uninstalled deps don't fail the build)", async () => {
await pkg(
{ dependencies: { "some-uninstalled-dep": "1.0.0" } },
'import dep from "some-uninstalled-dep";\nexport default dep;\n',
);
await expect(assertCompiles(dir, await packDirectory(dir))).resolves.toBeUndefined();
});
});
65 changes: 64 additions & 1 deletion apps/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { join } from "node:path";
import { CliError } from "@brika/cli-kit";
import { RegistryPublishSchema, validateStoreLocales } from "@brika/schema/store";
import {
RegistryPublishSchema,
validateManifestCapabilities,
validateManifestFiles,
validateStoreLocales,
} from "@brika/schema/store";
import type { Packed } from "../lib/tarball";

/** Reject anything that is not a valid, listable Brika plugin before uploading. */
Expand All @@ -15,6 +21,63 @@ export function assertPublishable(packed: Packed): void {
throw new CliError(`${packed.name} is not a publishable Brika plugin:\n${detail}`);
}

/** Reject a manifest that references files (main, icon, screenshots) missing from the packed tarball. */
export function assertFilesPresent(packed: Packed): void {
const result = RegistryPublishSchema.safeParse(packed.manifest);
if (!result.success) return; // assertPublishable already reported the schema failure
const issues = validateManifestFiles(result.data, packed.files);
if (issues.length === 0) return;
const detail = issues.map((issue) => ` - ${issue}`).join("\n");
throw new CliError(`${packed.name} references files missing from the tarball:\n${detail}`);
}

/** Reject a manifest that declares a capability (brick/block/spark/page/tool) with no backing source file. */
export function assertCapabilitiesBacked(packed: Packed): void {
const result = RegistryPublishSchema.safeParse(packed.manifest);
if (!result.success) return; // assertPublishable already reported the schema failure
const issues = validateManifestCapabilities(result.data, packed.files);
if (issues.length === 0) return;
const detail = issues.map((issue) => ` - ${issue}`).join("\n");
throw new CliError(`${packed.name} declares capabilities with no backing code:\n${detail}`);
}

/** Declared dependency names (deps + peer + dev), treated as external during the compile check. */
function declaredDeps(manifest: Record<string, unknown>): string[] {
const names = new Set<string>();
for (const key of ["dependencies", "peerDependencies", "devDependencies"]) {
const record = manifest[key];
if (record !== null && typeof record === "object") {
for (const name of Object.keys(record)) names.add(name);
}
}
return [...names];
}

/**
* Compile the plugin's entrypoint with `Bun.build` (the same bundler the Brika hub compiles plugins
* with) to prove it is a real, buildable plugin rather than a stub or a broken source tree. Declared
* dependencies are marked external, so this checks the plugin's own code, not its installed deps.
* Throws a CliError on any build error.
*/
export async function assertCompiles(dir: string, packed: Packed): Promise<void> {
const main = packed.manifest.main;
if (typeof main !== "string") return; // assertPublishable already requires a `main`
let result: Awaited<ReturnType<typeof Bun.build>>;
try {
result = await Bun.build({
entrypoints: [join(dir, main)],
target: "browser",
external: declaredDeps(packed.manifest),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new CliError(`${packed.name} failed to compile (from ${main}):\n - ${message}`);
}
if (result.success) return;
const detail = result.logs.map((log) => ` - ${String(log)}`).join("\n");
throw new CliError(`${packed.name} does not compile (from ${main}):\n${detail}`);
}

/** Reject bundled `locales/<lang>/store.json` files that don't match the schema. */
export function assertLocalesValid(packed: Packed): void {
const screenshots = packed.manifest.screenshots;
Expand Down
117 changes: 117 additions & 0 deletions apps/registry/bench/compile.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Compile-speed benchmark: our TS compile (sucrase, the Worker publish gate) vs Bun native.
*
* NOT an equivalence check - the three paths do DIFFERENT jobs and emit DIFFERENT output:
* - sucrase.transform per-file transpile, N files -> N JS chunks. Classic JSX. Worker-safe.
* - Bun.Transpiler per-file transpile, N files -> N JS chunks. Automatic JSX runtime.
* - Bun.build resolve graph + tree-shake + BUNDLE, N files -> 1 bundle. Can process CSS.
*
* Only sucrase vs Bun.Transpiler is a fair head-to-head (same per-file transpile job). Bun.build is
* reported as a whole-corpus total, not per-file: it's what the CLI's `assertCompiles` actually runs,
* but it's a bundle, not a transpile, so per-file numbers would mislead.
*
* Run: `bun apps/registry/bench/compile.bench.ts [fileCount] [iterations]`
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { transform } from "sucrase";

const FILE_COUNT = Number(process.argv[2] ?? 40);
const ITERATIONS = Number(process.argv[3] ?? 50);

// A realistic-ish plugin file: types, a React-ish component (JSX), an external import, some logic.
function makeSource(i: number): string {
return `import { helper } from "some-dep";
import { local${(i + 1) % FILE_COUNT} } from "./mod${(i + 1) % FILE_COUNT}";

export interface Props${i} { id: string; count: number; tags: readonly string[]; }

export const local${i} = (p: Props${i}): number =>
p.count + p.tags.length + helper(p.id).length + local${(i + 1) % FILE_COUNT};

export const View${i} = (p: Props${i}) => (
<div className="card" data-id={p.id}>
<h2>{p.id}</h2>
<span>{local${i}(p)}</span>
{p.tags.map((t) => <em key={t}>{t}</em>)}
</div>
);
`;
}

const dir = mkdtempSync(join(tmpdir(), "brika-bench-"));
const enc = new TextEncoder();
const entries: { path: string; data: Uint8Array; src: string }[] = [];
for (let i = 0; i < FILE_COUNT; i++) {
const src = makeSource(i);
const path = `mod${i}.tsx`;
writeFileSync(join(dir, path), src);
entries.push({ path, data: enc.encode(src), src });
}
const entrypoint = join(dir, "mod0.tsx"); // graph is a ring, so this reaches every file

// --- contenders (each transpiles/builds the whole corpus once) ---------------

function runSucrase(): void {
const decoder = new TextDecoder();
for (const e of entries) {
transform(decoder.decode(e.data), { transforms: ["typescript", "jsx"] });
}
}

const transpiler = new Bun.Transpiler({ loader: "tsx" });
function runBunTranspiler(): void {
for (const e of entries) transpiler.transformSync(e.src);
}

// external = the plugin's declared deps (as `assertCompiles` does). Relative `./modN` imports stay
// internal so the whole ring is bundled; `["*"]` would externalize those too and build only mod0.
const BUILD_EXTERNAL = ["some-dep", "react", "react/jsx-runtime", "react/jsx-dev-runtime"];
async function runBunBuild(): Promise<void> {
const r = await Bun.build({
entrypoints: [entrypoint],
target: "browser",
external: BUILD_EXTERNAL,
});
if (!r.success) throw new Error(`bench build failed: ${r.logs.map(String).join(", ")}`);
}

// --- timing harness ----------------------------------------------------------

function median(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}

async function bench(
name: string,
fn: () => void | Promise<void>,
perFile: boolean,
): Promise<void> {
for (let i = 0; i < 5; i++) await fn(); // warmup
const times: number[] = [];
for (let i = 0; i < ITERATIONS; i++) {
const t0 = Bun.nanoseconds();
await fn();
times.push((Bun.nanoseconds() - t0) / 1e6); // ms
}
const med = median(times);
// per-file is meaningful only for the per-file transpilers; Bun.build is a whole-corpus bundle.
const rate = perFile
? `${((med / FILE_COUNT) * 1000).toFixed(1).padStart(7)} us/file`
: " (bundle)";
console.log(` ${name.padEnd(16)} ${med.toFixed(3).padStart(9)} ms ${rate}`);
}

console.log(`\ncompile bench - ${FILE_COUNT} .tsx files, median of ${ITERATIONS} runs`);
console.log(" (per-file: transpile job | (bundle): whole-corpus, different job)\n");
try {
await bench("sucrase", runSucrase, true);
await bench("Bun.Transpiler", runBunTranspiler, true);
await bench("Bun.build", runBunBuild, false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
console.log("");
2 changes: 2 additions & 0 deletions apps/registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"cf-typegen": "wrangler types"
},
"dependencies": {
"@brika/compiler": "0.4.0-00e5f35c9ec7",
"@brika/di": "workspace:*",
"@brika/env": "workspace:*",
"@brika/registry-core": "workspace:*",
Expand All @@ -32,6 +33,7 @@
"@brika/tx": "workspace:*",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.25",
"sucrase": "^3.35.1",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
40 changes: 40 additions & 0 deletions apps/registry/src/adapters/compile-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { checkPluginCompiles } from "./compile-check";

const enc = new TextEncoder();
const file = (path: string, text: string) => ({ path, data: enc.encode(text) });

describe("checkPluginCompiles", () => {
test("accepts valid TS + TSX sources", () => {
expect(
checkPluginCompiles([
file("src/index.ts", "const n: number = 1;\nexport default n;\n"),
file("src/view.tsx", 'export const V = () => <div className="a">hi</div>;\n'),
]),
).toEqual({ ok: true });
});

test("rejects a source file with a syntax error, naming it", () => {
const result = checkPluginCompiles([file("src/index.ts", "export default function( {\n")]);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.message).toContain("src/index.ts");
});

test("ignores non-source files (json, svg, css, .d.ts)", () => {
expect(
checkPluginCompiles([
file("package.json", "{not json but not transpiled}"),
file("icon.svg", "<svg/>"),
file("styles.css", ".a { color: red }"),
file("types.d.ts", "export type X = number"),
]),
).toEqual({ ok: true });
});

test("does not treat a TS type-cast as JSX in a .ts file", () => {
// `<string>x` is a cast in .ts; the JSX transform must not be applied to .ts.
expect(
checkPluginCompiles([file("src/a.ts", "export const s = <string>(1 as unknown);\n")]),
).toEqual({ ok: true });
});
});
Loading
Loading