diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e632cb4b96..b08b3dbcf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,11 +133,12 @@ jobs: if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} run: npm run typecheck:examples --workspace=@getpaseo/client + # The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04. typecheck: name: typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -395,7 +396,7 @@ jobs: name: app-tests needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -429,7 +430,7 @@ jobs: name: playwright (shard 1/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "1/8" PLAYWRIGHT_ARTIFACT: "1" @@ -485,7 +486,7 @@ jobs: name: playwright (shard 2/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "2/8" PLAYWRIGHT_ARTIFACT: "2" @@ -495,7 +496,7 @@ jobs: name: playwright (shard 3/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "3/8" PLAYWRIGHT_ARTIFACT: "3" @@ -505,7 +506,7 @@ jobs: name: playwright (shard 4/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "4/8" PLAYWRIGHT_ARTIFACT: "4" @@ -515,7 +516,7 @@ jobs: name: playwright (shard 5/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "5/8" PLAYWRIGHT_ARTIFACT: "5" @@ -525,7 +526,7 @@ jobs: name: playwright (shard 6/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "6/8" PLAYWRIGHT_ARTIFACT: "6" @@ -535,7 +536,7 @@ jobs: name: playwright (shard 7/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "7/8" PLAYWRIGHT_ARTIFACT: "7" @@ -545,7 +546,7 @@ jobs: name: playwright (shard 8/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "8/8" PLAYWRIGHT_ARTIFACT: "8" diff --git a/packages/server/src/server/plugins/compiler-build.ts b/packages/server/src/server/plugins/compiler-build.ts index 5abd0df6ef..51eb2d2549 100644 --- a/packages/server/src/server/plugins/compiler-build.ts +++ b/packages/server/src/server/plugins/compiler-build.ts @@ -257,6 +257,22 @@ function makeHermesInteropEager(code: string): string { return code.replaceAll("get: () => from[key]", "value: from[key]"); } +async function minifyClientBundle(code: string): Promise { + const { transform } = loadEsbuild(); + // Minify after the eager replace. Whitespace minify rewrites the getter this + // replace matches, and identifier minify can delete the helper entirely. + const result = await transform(code, { + minifyWhitespace: true, + minifySyntax: true, + minifyIdentifiers: false, + target: "es2020", + supported: { "async-await": false }, + charset: "ascii", + legalComments: "inline", + }); + return result.code; +} + function runtimeSpecifierError( specifier: string, target: PluginBuildTarget | "shared", @@ -368,7 +384,10 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom checkSharedDependencies(result.metafile.inputs, pluginDirectory); const output = result.outputFiles[0]?.text; if (!output) throw new Error(`Plugin ${target} compilation produced no output`); - return wrapCommonJsBundle(makeHermesInteropEager(output)); + const eager = makeHermesInteropEager(output); + let prepared = eager; + if (target === "client") prepared = await minifyClientBundle(eager); + return wrapCommonJsBundle(prepared); } export async function compilePlugin(entryPaths: { diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index 67ca96e596..69510cdfb0 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import * as zod from "zod"; import { ZodError } from "zod"; import { compilePlugin, @@ -569,6 +570,101 @@ export default function contribute(client) { expect(clientBundle).toContain("__async"); }); + it("reduces client bytes while preserving registration and leaving server output readable", async () => { + const entries = await createSplitPlugin(); + const source = `import { z } from "zod"; +const commandSchema = z.object({ id: z.string(), title: z.string() }); +export default function contribute(client) { + const command = commandSchema.parse({ id: "open", title: "Open – plugin" }); + client.addCommandCenterItem({ + ...command, + icon: "Scan", + context: "global", + onSelect() { return Promise.resolve(command.title); }, + }); + return () => undefined; +}`; + await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]); + const { clientBundle, serverBundle } = await compilePlugin(entries); + if (clientBundle === null || serverBundle === null) { + throw new Error("compilePlugin returned no bundle"); + } + + for (const bundle of [clientBundle, serverBundle]) { + const registered: { id: string; title: string; onSelect: () => Promise }[] = []; + const exports = new Function(`return ${bundle}`)()((specifier: string) => { + expect(specifier).toBe("zod"); + return zod; + }); + const cleanup = exports.default({ + addCommandCenterItem: (command: (typeof registered)[number]) => registered.push(command), + }); + expect(registered).toMatchObject([{ id: "open", title: "Open – plugin" }]); + await expect(registered[0]!.onSelect()).resolves.toBe("Open – plugin"); + expect(cleanup()).toBeUndefined(); + } + expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9); + expect(clientBundle.split("").every((char) => char.charCodeAt(0) <= 0x7f)).toBe(true); + expect(serverBundle).toContain('title: "Open'); + }); + + it("retains dependency license notices while minifying executable client code", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/licensed-dependency"); + const notice = "Copyright Example Authors. SPDX-License-Identifier: MIT"; + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "licensed-dependency", main: "index.js" }), + ); + await writeFile( + path.join(dependency, "index.js"), + `/*! ${notice} */ +exports.title = function title(input) { + const trimmed = input.trim(); + return "Licensed " + trimmed; +};`, + ); + const source = `import { title } from "licensed-dependency"; +export default function contribute() { return title(" plugin "); }`; + await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]); + const { clientBundle, serverBundle } = await compilePlugin(entries); + if (clientBundle === null || serverBundle === null) { + throw new Error("compilePlugin returned no bundle"); + } + + for (const bundle of [clientBundle, serverBundle]) { + expect(bundle).toContain(notice); + const exports = new Function(`return ${bundle}`)()(() => { + throw new Error("The dependency must be bundled"); + }); + expect(exports.default()).toBe("Licensed plugin"); + } + expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9); + }); + + it("keeps distinct host exports eager when minifying in the worker", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + `import * as host from "zod"; +export default function contribute() { return [host.first, host.second]; }`, + ); + const { clientBundle } = await compilePlugin({ client: entries.client, server: null }); + const host = { first: "first export", second: "second export" }; + const factory = new Function(`return ${clientBundle}`)() as ( + require: (specifier: string) => typeof host, + ) => { default: () => unknown }; + function requireHost(specifier: string): typeof host { + expect(specifier).toBe("zod"); + return host; + } + const exports = factory(requireHost); + host.first = "changed first export"; + host.second = "changed second export"; + expect(exports.default()).toEqual(["first export", "second export"]); + }); + it("rejects node imports from the client entry", async () => { const entries = await createSplitPlugin(); await writeFile(