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
12 changes: 12 additions & 0 deletions .github/file-filters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ registry_sync:
# own tsconfig. Both regressions it has caught (the CLI entry point, and an
# unlisted `vue-demi` build script that broke every scaffold's first install) came
# from packages/registry changes, not from the templates.
# Worker size budget. Listed separately from `packages` so the gate cannot be
# switched off by editing the gate: a PR touching only the checker, the
# committed baseline, or the job itself still runs it.
worker_size:
- "scripts/check-worker-size.js"
- "worker-size.json"
- ".github/workflows/test.yml"
# This file too, or the hole reopens one level up: a PR that edits the
# filter itself would not match it, the gate would skip, and
# `test-required-check` passes on skipped jobs.
- ".github/file-filters.yml"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
templates:
- "templates/**"
- "registry/**"
Expand Down
47 changes: 46 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"e2e": "${{ steps.changes.outputs.e2e }}"
"codecov": "${{ steps.changes.outputs.codecov }}"
"templates": "${{ steps.changes.outputs.templates }}"
"worker_size": "${{ steps.changes.outputs.worker_size }}"
"steps":
- "name": "Harden Runner"
"uses": "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411" # v2.19.4
Expand Down Expand Up @@ -307,11 +308,55 @@
"retention-days": 7
"if-no-files-found": "ignore"

# Weighs the Worker a `templates/standalone` app deploys against the committed
# ceiling in `worker-size.json`. Nothing else in CI measures bytes, and the
# cost of noticing late is a user's deploy rejected by Cloudflare for a
# dependency this repo added weeks earlier.
#
# Its own job, NOT the root `postinstall`: a failing postinstall gate turns
# every job red in its setup step and the cause is invisible in the job that
# reports it.
"worker-size":
"name": "Worker size budget"
"if": "needs.files-changed.outputs.packages == 'true' || needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.worker_size == 'true'"
"needs": "files-changed"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"runs-on": "ubuntu-latest"
"timeout-minutes": 30
"steps":
- "name": "Harden Runner"
"uses": "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411" # v2.19.4
"with":
"egress-policy": "audit"

- "name": "Git checkout"
"uses": "anolilab/workflows/step/checkout-with-retry@6c16895f4c3b5273b373656988505d1a8264e78b" # main

- "name": "Setup resources and environment"
"uses": "anolilab/workflows/step/node@acba5fb15ac98ad23dec1ad233e40e1bb79c1dae" # v19.0.4
"with":
"node-version": "22.15"
"cache-prefix": "worker-size"
"install-node-gyp": "true"
"skip-playwright": "true"
"enable-nx-cache": "false"
"run-npm-audit": "false"
"run-signatures-audit": "false"

# `build:packages:prod`, not `build:packages`: the reference app
# bundles the workspace `dist/` directories, and users install the
# production build. The development build measures ~25% heavier — a
# number nobody deploys, and a baseline nobody could reproduce.
- "name": "Build packages (production artifacts)"
"run": "pnpm run build:packages:prod"

- "name": "Weigh the reference Worker"
"run": "pnpm run worker-size:check"

# Single required check: green when every dependent job passed or was
# skipped (so a no-package-change PR with all jobs skipped still passes).
"test-required-check":
"name": "Check Test Run"
"needs": ["files-changed", "test", "test-workerd", "e2e", "templates"]
"needs": ["files-changed", "test", "test-workerd", "e2e", "templates", "worker-size"]
"if": "always()"
"runs-on": "ubuntu-latest"
"timeout-minutes": 5
Expand Down
22 changes: 22 additions & 0 deletions api-snapshots/cli.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ interface DeployCommandOptions {
env?: string;
fetchImpl?: FetchLike;
format?: string;
healthCheck?: boolean;
healthFetch?: HealthFetch;
healthSleep?: (ms: number) => Promise<void>;
interactive?: boolean;
logger: Logger;
migrate?: boolean;
Expand All @@ -140,8 +143,14 @@ interface DeployCommandOptions {
```ts
interface DeployCommandResult {
code: number;
deployment?: DeployedIdentity;
descriptor: SpawnDescriptor | undefined;
error?: string;
healthCheck?: {
error?: string;
ok: boolean;
url: string;
};
mintedSecretsFile?: string;
schemaDrift?: {
blocked: boolean;
Expand All @@ -154,6 +163,19 @@ interface DeployCommandResult {
}
```

### `DeployedIdentity` (interface)

```ts
interface DeployedIdentity {
deployedAt: string;
dryRun: boolean;
env?: string;
preview: boolean;
url?: string;
workerName?: string;
}
```

### `DevCommandOptions` (interface)

```ts
Expand Down
14 changes: 12 additions & 2 deletions api-snapshots/mcp.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ interface McpTool {
const NO_DEPLOYMENT_MESSAGE = "no Lunora dev server is running for this project — start one with `lunora dev`, then call this tool again (call lunora_dev_status to check).";
```

### `OBSERVABILITY_TOOL_DEFINITIONS` (const)

```ts
const OBSERVABILITY_TOOL_DEFINITIONS: ReadonlyArray<ToolDefinition>;
```

### `PaidMcpChargeConfig` (type)

```ts
Expand Down Expand Up @@ -186,6 +192,7 @@ interface ToolDefinition {
description: string;
inputSchema: ToolInputSchema;
name: string;
outputSchema?: ToolInputSchema;
}
```

Expand Down Expand Up @@ -214,6 +221,7 @@ interface ToolResult {
type: "text";
}[];
isError?: boolean;
structuredContent?: Record<string, unknown>;
}
```

Expand All @@ -238,7 +246,7 @@ const callAgentTool: (client: LunoraClient, name: string, input: Record<string,
### `callTool` (const)

```ts
const callTool: (client: LunoraClient, name: string, input: Record<string, unknown>, allowWrites?: boolean) => Promise<ToolResult>;
const callTool: (client: LunoraClient, name: string, input: Record<string, unknown>, allowWrites?: boolean, hasAdminToken?: boolean) => Promise<ToolResult>;
```

### `connectLocalStdio` (const)
Expand Down Expand Up @@ -304,7 +312,7 @@ const serveStateless: (server: Server, request: Request, options?: HandleRequest
### `toolDefinitions` (const)

```ts
const toolDefinitions: (allowWrites: boolean) => ReadonlyArray<ToolDefinition>;
const toolDefinitions: (allowWrites: boolean, hasAdminToken?: boolean) => ReadonlyArray<ToolDefinition>;
```

## `@lunora/mcp/docs`
Expand Down Expand Up @@ -470,6 +478,7 @@ interface ToolDefinition {
description: string;
inputSchema: ToolInputSchema;
name: string;
outputSchema?: ToolInputSchema;
}
```

Expand All @@ -492,6 +501,7 @@ interface ToolResult {
type: "text";
}[];
isError?: boolean;
structuredContent?: Record<string, unknown>;
}
```

Expand Down
37 changes: 37 additions & 0 deletions apps/docs/src/content/docs/deployment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,43 @@ CI should run `lunora verify` (or `lunora prepare`) first so codegen drift and a
stale `_generated/` are a build failure, not a deploy that silently uses old
types.

## Worker size

Cloudflare caps a Worker script
[in two places](https://developers.cloudflare.com/workers/platform/limits/):
**3 MB on Workers Free and 10 MB on Workers Paid after gzip compression**, and
**64 MB before compression** on both plans. Both are enforced at upload, so an
over-budget bundle is a rejected deploy rather than a slow one — and a bundle
that compresses unusually well (a large generated table, say) can sit under the
gzip limit while breaching the raw one. Check both numbers.

`lunora build` weighs what it wrote:

```bash
pnpm lunora build
# … bundle: 1684.9 KiB raw, 412.9 KiB gzipped across 1 file(s)

pnpm lunora build --format json | jq .bundle
# { "files": 1, "gzipBytes": 422840, "rawBytes": 1725313 }
```

Only the uploaded files are counted — the sourcemap and the esbuild metafile
sitting in the same out-dir are not part of the script, and counting them would
roughly triple the number. Compare `gzipBytes` against your plan's compressed
limit and `rawBytes` against the 64 MB one; together they match what
`wrangler deploy` reports as `Total Upload: … / gzip: …`.

A starter app is around **410 KiB gzipped**, so most projects have a lot of
room. If yours is approaching the limit:

- **Drop add-ons you no longer import.** Every `@lunora/*` add-on your Worker
entry reaches is bundled, whether or not a request ever uses it.
- **Check for a dev-only import reaching the Worker entry.** A seed script, a
test helper, or a Node-only utility imported from `lunora/` pulls its whole
dependency tree into the deployed bundle.
- **Look at what is actually heavy.** `lunora analyze` bundles the Worker and
prints the largest modules, which is usually enough to name the culprit.

## Streaming logs

Tail a deployed Worker's live logs with:
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
"test:affected:coverage": "vis affected test:coverage --query \"project!=lunora-e2e&&project!=studio&&project!=lunora-playground\"",
"test:clean-machine": "./scripts/clean-machine-smoke.sh",
"test:coverage": "vis run test:coverage --query \"project!=lunora-e2e&&project!=studio&&project!=lunora-playground\"",
"test:templates": "./scripts/template-build-smoke.sh"
"test:templates": "./scripts/template-build-smoke.sh",
"worker-size:check": "node scripts/check-worker-size.js",
"worker-size:update": "node scripts/check-worker-size.js --update"
},
"devDependencies": {
"@anolilab/commitlint-config": "catalog:prod",
Expand Down
100 changes: 96 additions & 4 deletions packages/cli/__tests__/commands/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { BuildCommandResult } from "../../src/commands/build/handler";
import { runBuildCommand } from "../../src/commands/build/handler";
import type { Logger } from "../../src/util/logger";
import type { Spawner } from "../../src/util/spawn";
import { createRecordingSpawner } from "../../src/util/spawn";

const here = dirname(fileURLToPath(import.meta.url));
Expand All @@ -24,22 +27,46 @@ const VALID_WRANGLER = `{
}
`;

const silentLogger = (): { logger: Logger; successes: string[] } => {
const silentLogger = (): { logger: Logger; successes: string[]; warnings: string[] } => {
const successes: string[] = [];
const warnings: string[] = [];

return {
logger: {
error: () => {},
info: () => {},
success: (message) => successes.push(message),
warn: () => {},
warn: (message) => warnings.push(message),
},
successes,
warnings,
};
};

/** Worker script the fake wrangler "bundles" — big enough that gzip is a real number. */
const SCRIPT = `export default { fetch() { return new Response(${JSON.stringify("ok".repeat(4096))}); } };\n`;

let workdir: string;

/**
* A spawner that writes what `wrangler deploy --outdir` writes: the script, its
* sourcemap, the esbuild metafile, and wrangler's explanatory README — so the
* measurement is exercised against the layout it actually has to filter.
*/
const bundlingSpawner =
(outDirectory: string): Spawner =>
async (descriptor) => {
const directory = join(workdir, outDirectory);

mkdirSync(directory, { recursive: true });
writeFileSync(join(directory, "server.js"), SCRIPT, "utf8");
writeFileSync(join(directory, "server.js.map"), "x".repeat(50_000), "utf8");
writeFileSync(join(directory, "bundle-meta.json"), "y".repeat(50_000), "utf8");
writeFileSync(join(directory, "README.md"), "wrangler wrote this\n", "utf8");

return { code: 0, descriptor, stderr: "", stdout: "" };
};

describe("lunora build", () => {
beforeEach(() => {
workdir = mkdtempSync(join(tmpdir(), "lunora-build-"));
Expand Down Expand Up @@ -104,6 +131,71 @@ describe("lunora build", () => {
expect(successes.join("\n")).toContain("binding manifest written to");
});

it("weighs the bundle it wrote, counting only what Cloudflare uploads", async () => {
expect.assertions(4);

const { logger } = silentLogger();

const result = await runBuildCommand({ cwd: workdir, logger, outDir: "dist-worker", spawner: bundlingSpawner("dist-worker") });

// The sourcemap, the metafile and wrangler's README are all in the
// out-dir and none of them ship — counting them would report a bundle
// roughly three times its real weight.
expect(result.bundle?.files).toBe(1);
expect(result.bundle?.rawBytes).toBe(Buffer.byteLength(SCRIPT));
expect(result.bundle?.gzipBytes).toBe(gzipSync(Buffer.from(SCRIPT)).byteLength);
expect(result.bundle?.gzipBytes).toBeGreaterThan(0);
});

it("reports the size in the --format json document without failing on it", async () => {
expect.assertions(3);

const { logger } = silentLogger();
const written: string[] = [];
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
written.push(String(chunk));

return true;
});

let result: BuildCommandResult;

try {
result = await runBuildCommand({
cwd: workdir,
format: "json",
logger,
outDir: "dist-worker",
spawner: bundlingSpawner("dist-worker"),
});
} finally {
spy.mockRestore();
}

// Measuring is reporting: a size never changes the exit code.
expect(result.code).toBe(0);

const document = JSON.parse(written.join("")) as BuildCommandResult;

expect(written).toHaveLength(1);
expect(document.bundle?.gzipBytes).toBeGreaterThan(0);
});

it("says so rather than reporting zero when there is nothing to weigh", async () => {
expect.assertions(2);

const { logger, warnings } = silentLogger();

// The recording spawner writes no out-dir — which is what a changed
// wrangler layout would also look like. A 0-byte bundle would read as
// the healthiest possible result, so it must not be reported at all.
const { spawner } = createRecordingSpawner();
const result = await runBuildCommand({ cwd: workdir, logger, spawner });

expect(result.bundle).toBeUndefined();
expect(warnings.join("\n")).toContain("could not weigh the bundle");
});

it("--emit-bindings fails rather than describing a Worker that needs nothing", async () => {
expect.assertions(2);

Expand Down
Loading
Loading