diff --git a/.github/workflows/cloudflare-production.yml b/.github/workflows/cloudflare-production.yml index c121f6aa..5a9b78c8 100644 --- a/.github/workflows/cloudflare-production.yml +++ b/.github/workflows/cloudflare-production.yml @@ -120,6 +120,24 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} run: pnpm alchemy deploy --stage prod --yes + - name: Smoke Cloudflare production llms.txt + run: | + node --input-type=module <<'NODE' + import assert from "node:assert/strict"; + + const url = "https://schematics.run/llms.txt"; + const response = await fetch(url, { + headers: { Accept: "text/plain", "Cache-Control": "no-cache" }, + signal: AbortSignal.timeout(15_000), + }); + const contentType = response.headers.get("content-type") ?? ""; + console.log(`${url}: ${response.status}, ${contentType}, cf-cache-status=${response.headers.get("cf-cache-status")}`); + assert.equal(response.status, 200, "/llms.txt must return 200"); + assert.match(contentType, /^text\/plain(?:;|$)/i, "/llms.txt must be text/plain"); + assert((await response.text()).startsWith("# Schematics"), "/llms.txt must start with '# Schematics'"); + console.log("Production llms.txt smoke passed."); + NODE + - name: Resolve Cloudflare production API URL id: production-api env: diff --git a/alchemy.run.ts b/alchemy.run.ts index d0adf8b3..5283a30f 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -7,6 +7,7 @@ import { Stage } from "alchemy"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import ApiWorker from "./alchemy/schematics-api-worker.ts"; +import { playgroundMemo } from "./alchemy/playground-memo.ts"; import { PROD_API_BASE_URL, PROD_PLAYGROUND_HOSTNAME, @@ -62,19 +63,7 @@ export default Alchemy.Stack( htmlHandling: "auto-trailing-slash", notFoundHandling: "single-page-application", }, - memo: { - include: [ - "apps/playground/**", - "alchemy/**", - "packages/*/src/**", - "packages/*/package.json", - "package.json", - "pnpm-lock.yaml", - "pnpm-workspace.yaml", - "tsconfig.base.json", - "vitest.aliases.ts", - ], - }, + memo: playgroundMemo, }); if (shouldCommentOnPullRequest) { diff --git a/alchemy/playground-memo.ts b/alchemy/playground-memo.ts new file mode 100644 index 00000000..11d125f3 --- /dev/null +++ b/alchemy/playground-memo.ts @@ -0,0 +1,17 @@ +import type { MemoOptions } from "alchemy/Command/Memo"; + +// Alchemy evaluates these globs from the Vite root (apps/playground), not +// the repository root. Include public assets so edits trigger an upload. +export const playgroundMemo = { + lockfile: true, + include: [ + "**/*", + "../../alchemy/**", + "../../packages/*/src/**", + "../../packages/*/package.json", + "../../package.json", + "../../pnpm-workspace.yaml", + "../../tsconfig.base.json", + "../../vitest.aliases.ts", + ], +} satisfies MemoOptions; diff --git a/packages/server/test/playground-deploy.test.ts b/packages/server/test/playground-deploy.test.ts new file mode 100644 index 00000000..ea9be4cb --- /dev/null +++ b/packages/server/test/playground-deploy.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "@effect/vitest"; +import { NodeFileSystem, NodePath } from "@effect/platform-node"; +import { hashDirectory } from "alchemy/Command/Memo"; +import { readAssets } from "alchemy/Cloudflare"; +import { Effect } from "effect"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { playgroundMemo } from "../../../alchemy/playground-memo"; + +describe("playground deployment", () => { + it("invalidates Alchemy's memo when public assets or workspace inputs change", async () => { + const root = await mkdtemp(join(tmpdir(), "schematics-playground-memo-")); + const playground = join(root, "apps/playground"); + const inputs = [ + "apps/playground/public/llms.txt", + "apps/playground/public/_headers", + "apps/playground/src/main.tsx", + "alchemy/playground-memo.ts", + "packages/core/src/index.ts", + "packages/core/package.json", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.base.json", + "vitest.aliases.ts", + ]; + const hash = () => + Effect.runPromise( + hashDirectory({ cwd: playground, memo: playgroundMemo }).pipe( + Effect.provide([NodeFileSystem.layer, NodePath.layer]), + ), + ); + + try { + await mkdir(join(root, ".git")); + await writeFile(join(root, ".gitignore"), "dist/\nnode_modules/\n"); + for (const input of inputs) { + await mkdir(dirname(join(root, input)), { recursive: true }); + await writeFile(join(root, input), "original"); + } + let previous = await hash(); + for (const input of inputs) { + await writeFile(join(root, input), "changed"); + const current = await hash(); + expect(current, input).not.toBe(previous); + previous = current; + } + await rm(join(playground, "public/llms.txt")); + expect(await hash(), "removing llms.txt must also invalidate the memo").not.toBe(previous); + previous = await hash(); + await writeFile(join(playground, "public/llms.txt"), "new asset"); + expect(await hash(), "adding llms.txt must invalidate the memo").not.toBe(previous); + previous = await hash(); + await mkdir(join(playground, "dist")); + await writeFile(join(playground, "dist/llms.txt"), "build output"); + expect(await hash(), "ignored build output must not trigger a rebuild").toBe(previous); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("includes llms.txt and text headers in Alchemy's assets with SPA fallback enabled", async () => { + const directory = await mkdtemp(join(tmpdir(), "schematics-playground-assets-")); + const publicDir = new URL("../../../apps/playground/public/", import.meta.url); + const source = await readFile(new URL("llms.txt", publicDir), "utf8"); + const headers = await readFile(new URL("_headers", publicDir), "utf8"); + + try { + await writeFile(join(directory, "llms.txt"), source); + await writeFile(join(directory, "_headers"), headers); + await writeFile(join(directory, "index.html"), ""); + const assets = await Effect.runPromise( + readAssets({ directory, notFoundHandling: "single-page-application" }).pipe( + Effect.provide([NodeFileSystem.layer, NodePath.layer]), + ), + ); + expect(assets.manifest["/llms.txt"]?.size).toBe(Buffer.byteLength(source)); + expect(assets.manifest["/index.html"]).toBeDefined(); + expect(assets.manifest["/_headers"]).toBeUndefined(); + expect(assets.config?.headers).toBe(headers); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/turbo.json b/turbo.json index bc26c76e..6687bf85 100644 --- a/turbo.json +++ b/turbo.json @@ -20,6 +20,15 @@ "dependsOn": ["build"], "outputs": [] }, + "@schematics/server#test": { + "dependsOn": ["build"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/alchemy/playground-memo.ts", + "$TURBO_ROOT$/apps/playground/public/**" + ], + "outputs": [] + }, "typecheck": { "dependsOn": ["build"], "outputs": []