Skip to content

feat(sites): experimental deploy functions folder - #212

Draft
jamie-at-bunny wants to merge 2 commits into
mainfrom
feat/sites-functions
Draft

feat(sites): experimental deploy functions folder#212
jamie-at-bunny wants to merge 2 commits into
mainfrom
feat/sites-functions

Conversation

@jamie-at-bunny

Copy link
Copy Markdown
Member

No description provided.

@bunnynet-devops

Copy link
Copy Markdown

@codex review

@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf9d87c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@bunny.net/cli Minor
@bunny.net/config Patch
@bunny.net/cli-darwin-arm64 Minor
@bunny.net/cli-darwin-x64 Minor
@bunny.net/cli-linux-arm64 Minor
@bunny.net/cli-linux-x64 Minor
@bunny.net/cli-windows-x64 Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-07T09:27:25.686573Z 4399f15 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@greptile-apps

greptile-apps Bot commented Sep 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds experimental site functions, framework-specific build variables, SPA and custom-404 handling, function cleanup, and supporting configuration, documentation, and tests.

  • Creates standalone Edge Scripts for entries in a configured functions directory.
  • Injects function URLs into site builds and publishes prepared function code after deploy validation.
  • Adds per-deploy SPA/404 behavior and updates storage-zone settings during promotion.
  • Extends site deletion, state merging, JSON output, documentation, and test coverage.
  • The latest revision separates function preparation from publication and excludes function source from static uploads, though symlink aliases can bypass that exclusion.

Confidence Score: 3/5

The PR is not yet safe to merge because function source can become publicly deployable through symlinked path aliases, and an existing function-resource leak remains unresolved.

The static uploader compares lexical paths, so deploying a project through a symlink can bypass the functions-directory exclusion and publish function source. The earlier “Created Scripts Can Leak” finding remains partially unfixed because prepareFunctions mutates state after each remote creation but the caller persists only after the entire batch succeeds; a later build or creation failure still leaves earlier scripts and linked pull zones unrecorded. The earlier JSON-output finding also remains because output is built only from locally prepared functions and omits state-only functions that continue serving. The publication-before-validation thread was manually resolved without explanation.

Files Needing Attention: packages/cli/src/commands/sites/uploader.ts, packages/cli/src/commands/sites/functions.ts, packages/cli/src/commands/sites/deploy.ts

Important Files Changed

Filename Overview
packages/cli/src/commands/sites/deploy.ts Coordinates function preparation and publication, static-file exclusion, SPA detection, deployment, and JSON output; existing review findings remain relevant to partial failures and incomplete function output.
packages/cli/src/commands/sites/functions.ts Discovers, builds, creates, and publishes site functions; multi-function partial failures can still leave created resources unrecorded.
packages/cli/src/commands/sites/uploader.ts Adds exclusion support for function source trees, but exact lexical path matching can be bypassed through a symlinked deploy path.
packages/cli/src/commands/sites/api.ts Merges function state, applies per-deploy not-found settings, and deletes function resources with sites.
packages/cli/src/commands/sites/constants.ts Adds function records, function URL/environment helpers, and per-deploy not-found modes.
packages/cli/src/commands/sites/ci/frameworks.ts Adds SPA defaults and browser-visible environment prefixes to framework presets.
packages/config/src/schema.ts Extends site configuration with SPA and functions-directory options.

Sequence Diagram

sequenceDiagram
  participant CLI
  participant Compute as Edge Scripts API
  participant Build
  participant Storage
  participant Site as Site Pull Zone

  CLI->>Compute: Create missing function resources
  Compute-->>CLI: Script IDs and hostnames
  CLI->>Storage: Persist function records
  CLI->>Build: "Run with BUNNY_FUNCTION_*_URL"
  CLI->>CLI: Validate deploy directory and target
  CLI->>Compute: Upload and publish function code
  CLI->>Storage: Upload static deploy files
  CLI->>Site: Promote deploy and configure SPA/404
  CLI->>Storage: Persist current deploy state
Loading

Fix all with Greploop Fix All in Claude Code

Reviews (3): Last reviewed commit: "feat(sites): experimental deploy functio..." | Re-trigger Greptile

Comment thread packages/cli/src/commands/sites/deploy.ts Outdated
Comment on lines +241 to +253
if (!record) {
step(`Creating function ${fn.name}...`);
record = await createFunctionScript(computeClient, state.name, fn);
}

const uploaded = force || record.codeHash !== codeHash;
if (uploaded) {
step(`Deploying function ${fn.name}...`);
await uploadScriptCode(computeClient, record.scriptId, code);
await publishScript(computeClient, record.scriptId);
record.codeHash = codeHash;
}
records[fn.name] = record;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Created Scripts Can Leak

A newly created script is not recorded until hostname lookup, upload, and publication all succeed, and it is persisted only after the entire function batch returns. If one of those steps or the state write fails, a retry sees no record and creates another script and linked pull zone. Concurrent deploys can also create the same missing function and retain only one record during state merging. Use idempotent creation or persist and clean up resources across these failure paths.

Fix in Claude Code

Comment thread packages/cli/src/commands/sites/config.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4399f1519a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +343 to +346
const functions = await discoverFunctions(
root,
siteConfig?.config.functions?.dir,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude function sources from static uploads

When sites.dir is absent, a simple static site deploys the config root/current directory, which also contains the default functions/ directory discovered here. The later collectFiles(dir) recursively includes that non-dot directory, so handler source, package files, and other server-side implementation details are published as static files under /functions/...; exclude the configured functions tree whenever it falls inside the static deploy root.

Useful? React with 👍 / 👎.

Comment on lines +350 to +360
functionResults = await withSpinner("Deploying functions...", (spin) =>
deployFunctions({
computeClient,
state,
functions,
force: args.force ?? false,
onStep: (message) => {
spin.text = message;
},
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Delay function publishing until deploy validation completes

When a custom deploy ID conflicts with existing content, this publishes the functions before the replacement confirmation later in the handler. If the user declines, the command prints Cancelled. but the new function code is already live alongside the old frontend; invalid or empty static deploy directories similarly mutate production before failing. Perform all directory, content, and confirmation checks first, then publish functions immediately before the site is promoted.

Useful? React with 👍 / 👎.

Comment on lines +241 to +244
if (!record) {
step(`Creating function ${fn.name}...`);
record = await createFunctionScript(computeClient, state.name, fn);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve newly created script IDs across failures

If a new script is created but its linked hostname is still provisioning, or if upload, publish, or a later function fails, this call rejects before the record reaches state.functions and before the caller writes remote state. The suggested retry therefore has no script ID to resume and attempts another create, leaving the original script and linked pull zone orphaned or hitting a duplicate-name error; persist a recoverable record immediately after creation or delete the resource on failure.

Useful? React with 👍 / 👎.

Comment on lines +419 to +422
configuredSpa = await confirm(
"This looks like a single-page app. Serve index.html for client-side routes so deep links survive a refresh?",
{ initial: true, optional: true },
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add flags for the SPA fallback prompt

When an undetected project looks like an SPA, this introduces an interactive choice without a corresponding --spa/--no-spa deploy option. Automated callers can only reproduce the choice by editing bunny.jsonc beforehand, contrary to the repository's requirement that every prompt have a flag equivalent; add paired flags and use them to suppress the prompt.

AGENTS.md reference: AGENTS.md:L273-L275

Useful? React with 👍 / 👎.

Comment on lines +44 to +46
export function functionScriptName(site: string, name: string): string {
return `sites-${site}-${name}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the generated Edge Script name length

Both component names can pass their local validators while the generated name exceeds the API limit: a 47-character site plus a 63-character function produces a 117-character name, but AddEdgeScriptModel.Name is capped at 100 characters in packages/openapi-client/specs/compute.json. Such otherwise-valid projects fail only at script creation, so validate the combined length or generate a bounded deterministic name.

Useful? React with 👍 / 👎.

...state.deploys,
...remote.deploys.filter((d) => !ours.has(d.id) && !removed.has(d.id)),
].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
state.functions = { ...remote.functions, ...state.functions };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve concurrent function hash conflicts

When two deploys update the same function concurrently, the last script publish and the last state write can occur in different orders. This merge always lets the local function record overwrite the concurrent record, so state can store hash A while hash B is actually live; a later deploy of A then sees a matching hash and skips the upload indefinitely. Detect same-name function conflicts or reconcile against the live deployment before retaining the local hash.

Useful? React with 👍 / 👎.

Comment thread skills/bunny-cli/references/sites.md Outdated
Comment on lines +97 to +106
const pkg = await readPackageJson(path);
const scripts = pkg?.scripts as Record<string, string> | undefined;
if (scripts?.build) {
const pm = await detectPackageManager(path);
functions.push({
name,
dir: path,
folder: true,
build: `${pm} run build`,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install function build dependencies in generated CI

When a static or non-JavaScript site contains a folder function whose package.json build script depends on local devDependencies, the generated sites ci init workflow emits no JavaScript setup or dependency-install step because it only considers the site's framework and sites.build. The deploy action then reaches this automatic npm run build on a fresh runner without the function package's dependencies, so deployments that work locally fail in CI; make workflow generation inspect function packages and install with their detected package manager.

Useful? React with 👍 / 👎.

Comment on lines +363 to +366
for (const name of staleFunctions(state, functions)) {
logger.warn(
`Function "${name}" is deployed but has no folder here; it keeps serving until its script (${state.functions?.[name]?.scriptId}) is deleted.`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provide a state-aware way to remove stale functions

When a function folder is removed, this warning tells the user to delete its script, but doing so does not remove state.functions[name]. Subsequent deploys therefore keep the dead origin rule because convergence still sees the record, and restoring the folder cannot recover because deployFunctions() tries to reuse the deleted script ID; remove the state entry and converge routing as part of a supported function-removal flow rather than directing users to delete only the script.

AGENTS.md reference: AGENTS.md:L471-L471

Useful? React with 👍 / 👎.

Comment on lines +239 to +243
let record = records[fn.name];
const created = !record;
if (!record) {
step(`Creating function ${fn.name}...`);
record = await createFunctionScript(computeClient, state.name, fn);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat constructor as an own function record only

The valid function name constructor collides with the inherited Object.prototype.constructor property on the ordinary {} used for state.functions. For that name, records[fn.name] is truthy even on a fresh site, so creation is skipped and the CLI attempts to upload to an undefined script ID; use an own-property check, a null-prototype object, or a Map for record lookup.

Useful? React with 👍 / 👎.

Comment on lines +380 to +387
const functionsJson = functionResults.map((r) => ({
name: r.name,
url: r.url,
env: functionEnvName(r.name),
scriptId: r.scriptId,
created: r.created,
uploaded: r.uploaded,
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 JSON Omits Live Functions

sites deploy --output json builds this array only from functions found in the current checkout. Removed local folders intentionally leave their remote functions serving, so the output omits those live functions and their URLs. Automation using this documented output can therefore receive an incomplete list.

Fix in Claude Code

Comment thread packages/cli/src/commands/sites/functions.ts
@jamie-at-bunny
jamie-at-bunny changed the base branch from main to feat/sites-spa September 7, 2026 12:10
for (const entry of readdirSync(abs, { withFileTypes: true })) {
if (shouldSkipEntry(entry.name)) continue;
const entryAbs = join(abs, entry.name);
if (exclude.has(entryAbs)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Symlinks Expose Function Sources

If the deploy directory is accessed through a symlink alias, the functions directory and the walked entry can have different path strings even though they refer to the same directory. This exact-path check then misses the exclusion and uploads server-side function source files as public site content. Canonicalize both paths before comparing them.

Fix in Claude Code

@jamie-at-bunny
jamie-at-bunny marked this pull request as draft September 7, 2026 12:58
Base automatically changed from feat/sites-spa to main September 7, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants