diff --git a/.changeset/twelve-moons-fly.md b/.changeset/twelve-moons-fly.md new file mode 100644 index 000000000000..721afdc36da3 --- /dev/null +++ b/.changeset/twelve-moons-fly.md @@ -0,0 +1,11 @@ +--- +"create-cloudflare": minor +--- + +feature: Improve bindings support in Svelte template. + +C3 will now create Svelte projects with a hook that uses `getPlatformProxy` to proxy bindings in development mode. A `wrangler.toml` file will also be added where bindings can be added to be used in conjunction with `getPlatformProxy`. + +Along with this change, projects will use the default vite-based dev command from `create-svelte` instead of using `wrangler pages dev` on build output. + +When Typescript is used, the `app.d.ts` will be updated to add type definitions for `cf` and `ctx` to the `Platform` interface from the `@cloudflare/workers-types` package. Types for bindings on `platform.env` can be re-generated with a newly added `build-cf-types` script. diff --git a/packages/create-cloudflare/e2e-tests/fixtures/svelte/src/routes/test/+server.ts b/packages/create-cloudflare/e2e-tests/fixtures/svelte/src/routes/test/+server.ts new file mode 100644 index 000000000000..fb6e01b5940e --- /dev/null +++ b/packages/create-cloudflare/e2e-tests/fixtures/svelte/src/routes/test/+server.ts @@ -0,0 +1,9 @@ +export async function GET({ platform }) { + const test = platform?.env.TEST; + + return new Response(JSON.stringify({ test }), { + headers: { + "Content-Type": "application/json", + }, + }); +} diff --git a/packages/create-cloudflare/e2e-tests/fixtures/svelte/wrangler.toml b/packages/create-cloudflare/e2e-tests/fixtures/svelte/wrangler.toml new file mode 100644 index 000000000000..4679b8cbbddd --- /dev/null +++ b/packages/create-cloudflare/e2e-tests/fixtures/svelte/wrangler.toml @@ -0,0 +1,2 @@ +[vars] +TEST = "C3_TEST" diff --git a/packages/create-cloudflare/e2e-tests/frameworks.test.ts b/packages/create-cloudflare/e2e-tests/frameworks.test.ts index c6ea30c8f4bb..1c61ecfe02da 100644 --- a/packages/create-cloudflare/e2e-tests/frameworks.test.ts +++ b/packages/create-cloudflare/e2e-tests/frameworks.test.ts @@ -222,6 +222,16 @@ const frameworkTests: Record = { route: "/", expectedText: "SvelteKit app", }, + verifyDev: { + route: "/test", + expectedText: "C3_TEST", + }, + verifyBuild: { + outputDir: ".svelte-kit/cloudflare", + script: "build", + route: "/test", + expectedText: "C3_TEST", + }, }, vue: { testCommitMessage: true, @@ -468,6 +478,9 @@ const verifyBuildScript = async ( [pm, "run", script], { cwd: projectPath, + env: { + NODE_ENV: "production", + }, }, logStream ); diff --git a/packages/create-cloudflare/e2e-tests/helpers.ts b/packages/create-cloudflare/e2e-tests/helpers.ts index 306ebffbb829..93576c4d979a 100644 --- a/packages/create-cloudflare/e2e-tests/helpers.ts +++ b/packages/create-cloudflare/e2e-tests/helpers.ts @@ -115,6 +115,8 @@ export const spawnWithLogging = ( ) => { const [cmd, ...argv] = args; + logStream.write(`\nRunning command: ${[cmd, ...argv].join(" ")}\n\n`); + const proc = spawn(cmd, argv, { ...opts, env: { @@ -123,8 +125,6 @@ export const spawnWithLogging = ( }, }); - logStream.write(`\nRunning command: ${[cmd, ...argv].join(" ")}\n\n`); - proc.stdout.on("data", (data) => { const lines: string[] = data.toString().split("\n"); diff --git a/packages/create-cloudflare/scripts/build.ts b/packages/create-cloudflare/scripts/build.ts index 014294e0106f..063f72524561 100644 --- a/packages/create-cloudflare/scripts/build.ts +++ b/packages/create-cloudflare/scripts/build.ts @@ -11,6 +11,8 @@ const run = async () => { bundle: true, outdir: "./dist", platform: "node", + // This is required to support jsonc-parser. See https://github.com/microsoft/node-jsonc-parser/issues/57 + mainFields: ["module", "main"], format: "cjs", }; diff --git a/packages/create-cloudflare/src/__tests__/workers.test.ts b/packages/create-cloudflare/src/__tests__/workers.test.ts index ab77ec376b6f..48af88fe038e 100644 --- a/packages/create-cloudflare/src/__tests__/workers.test.ts +++ b/packages/create-cloudflare/src/__tests__/workers.test.ts @@ -90,7 +90,7 @@ describe("addWorkersTypesToTsConfig", () => { // Mock the read of tsconfig.json vi.mocked(readFile).mockImplementation( - () => `{types: ["@cloudflare/workers-types"]}` + () => `{ "compilerOptions": { "types": ["@cloudflare/workers-types"]} }` ); }); @@ -101,8 +101,10 @@ describe("addWorkersTypesToTsConfig", () => { test("happy path", async () => { await addWorkersTypesToTsConfig(ctx); - expect(vi.mocked(writeFile).mock.calls[0][1]).toEqual( - `{types: ["@cloudflare/workers-types/2023-07-01"]}` + expect(writeFile).toHaveBeenCalled(); + + expect(vi.mocked(writeFile).mock.calls[0][1]).toContain( + `"@cloudflare/workers-types/2023-07-01"` ); }); @@ -123,12 +125,13 @@ describe("addWorkersTypesToTsConfig", () => { test("don't clobber existing entrypoints", async () => { vi.mocked(readFile).mockImplementation( - () => `{types: ["@cloudflare/workers-types/2021-03-20"]}` + () => + `{ "compilerOptions": { "types" : ["@cloudflare/workers-types/2021-03-20"]} }` ); await addWorkersTypesToTsConfig(ctx); - expect(vi.mocked(writeFile).mock.calls[0][1]).toEqual( - `{types: ["@cloudflare/workers-types/2021-03-20"]}` + expect(vi.mocked(writeFile).mock.calls[0][1]).toContain( + `"@cloudflare/workers-types/2021-03-20"` ); }); }); diff --git a/packages/create-cloudflare/src/templates.ts b/packages/create-cloudflare/src/templates.ts index 2a19dc1800f1..29054c60498f 100644 --- a/packages/create-cloudflare/src/templates.ts +++ b/packages/create-cloudflare/src/templates.ts @@ -11,7 +11,7 @@ import degit from "degit"; import { C3_DEFAULTS } from "helpers/cli"; import { readJSON, usesTypescript, writeJSON } from "helpers/files"; import { validateTemplateUrl } from "./validators"; -import type { C3Args, C3Context } from "types"; +import type { C3Args, C3Context, PackageJson } from "types"; export type TemplateConfig = { /** @@ -67,9 +67,14 @@ export type TemplateConfig = { */ configure?: (ctx: C3Context) => Promise; - /** A transformer that is run on the project's `package.json` during the creation step */ + /** + * A transformer that is run on the project's `package.json` during the creation step. + * + * The object returned from this function will be deep merged with the original. + * */ transformPackageJson?: ( - pkgJson: Record + pkgJson: PackageJson, + ctx: C3Context ) => Promise>; /** An array of flags that will be added to the call to the framework cli during tests.*/ @@ -428,7 +433,7 @@ export const updatePackageScripts = async (ctx: C3Context) => { let pkgJson = readJSON(pkgJsonPath); // Run any transformers defined by the template - const transformed = await ctx.template.transformPackageJson(pkgJson); + const transformed = await ctx.template.transformPackageJson(pkgJson, ctx); pkgJson = deepmerge(pkgJson, transformed); writeJSON(pkgJsonPath, pkgJson); diff --git a/packages/create-cloudflare/src/types.ts b/packages/create-cloudflare/src/types.ts index 8879ad4de2bb..9fdb70db6a64 100644 --- a/packages/create-cloudflare/src/types.ts +++ b/packages/create-cloudflare/src/types.ts @@ -38,3 +38,11 @@ export type C3Context = { type DeploymentInfo = { url?: string; }; + +export type PackageJson = Record & { + name: string; + version: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; +}; diff --git a/packages/create-cloudflare/src/workers.ts b/packages/create-cloudflare/src/workers.ts index 5bd01edab09d..cdbfd33f3b5b 100644 --- a/packages/create-cloudflare/src/workers.ts +++ b/packages/create-cloudflare/src/workers.ts @@ -6,6 +6,7 @@ import { spinner } from "@cloudflare/cli/interactive"; import { getWorkerdCompatibilityDate, installPackages } from "helpers/command"; import { readFile, usesTypescript, writeFile } from "helpers/files"; import { detectPackageManager } from "helpers/packages"; +import * as jsonc from "jsonc-parser"; import MagicString from "magic-string"; import type { C3Context } from "types"; @@ -105,32 +106,46 @@ export async function addWorkersTypesToTsConfig(ctx: C3Context) { const typesEntrypoint = `@cloudflare/workers-types/${entrypointVersion}`; - let updated = tsconfig; + try { + const config = jsonc.parse(tsconfig); + const currentTypes = config.compilerOptions?.types ?? []; - if (tsconfig.match(`@cloudflare/workers-types`)) { - // Don't update existing instances if they contain an explicit entrypoint - if (tsconfig.match(`"@cloudflare/workers-types"`)) { - updated = tsconfig.replace("@cloudflare/workers-types", typesEntrypoint); - } - } else { - try { - // Note: this simple implementation doesn't handle tsconfigs containing comments - // (as it is not needed for the existing use cases) - - const tsConfigJson = JSON.parse(tsconfig); - tsConfigJson.compilerOptions ??= {}; - tsConfigJson.compilerOptions.types = [ - ...(tsConfigJson.compilerOptions.types ?? []), - typesEntrypoint, - ]; - updated = JSON.stringify(tsConfigJson, null, 2); - } catch { - warn("Could not parse tsconfig.json file"); - updated = tsconfig; - } + const explicitEntrypoint = (currentTypes as string[]).some((t) => + t.match(/@cloudflare\/workers-types\/\d{4}-\d{2}-\d{2}/) + ); + + // If a type declaration with an explicit entrypoint exists, leave the types as is + // Otherwise, add the latest entrypoint + const newTypes = explicitEntrypoint + ? [...currentTypes] + : [ + ...currentTypes.filter( + (t: string) => t !== "@cloudflare/workers-types" + ), + typesEntrypoint, + ]; + + // If we detect any tabs, use tabs, otherwise use spaces. + // We need to pass an explicit value here in order to preserve formatting properly. + const useSpaces = !tsconfig.match(/\t/g); + + // Calculate required edits and apply them to file + const edits = jsonc.modify( + tsconfig, + ["compilerOptions", "types"], + newTypes, + { + formattingOptions: { insertSpaces: useSpaces }, + } + ); + const updated = jsonc.applyEdits(tsconfig, edits); + writeFile(tsconfigPath, updated); + } catch (error) { + warn( + "Failed to update `tsconfig.json` with latest `@cloudflare/workers-types` entrypoint." + ); } - writeFile(tsconfigPath, updated); s.stop(`${brandColor("added")} ${dim(typesEntrypoint)}`); } diff --git a/packages/create-cloudflare/templates/svelte/c3.ts b/packages/create-cloudflare/templates/svelte/c3.ts index ee7df86e4c21..c693c5c7bd29 100644 --- a/packages/create-cloudflare/templates/svelte/c3.ts +++ b/packages/create-cloudflare/templates/svelte/c3.ts @@ -1,13 +1,13 @@ +import { platform } from "node:os"; import { logRaw, updateStatus } from "@cloudflare/cli"; import { blue, brandColor, dim } from "@cloudflare/cli/colors"; -import { parseTs, transformFile } from "helpers/codemod"; +import { transformFile } from "helpers/codemod"; import { installPackages, runFrameworkGenerator } from "helpers/command"; -import { compatDateFlag, usesTypescript } from "helpers/files"; +import { usesTypescript } from "helpers/files"; import { detectPackageManager } from "helpers/packages"; -import { platformInterface } from "./templates"; +import * as recast from "recast"; import type { TemplateConfig } from "../../src/templates"; -import type * as recast from "recast"; -import type { C3Context } from "types"; +import type { C3Context, PackageJson } from "types"; const { npm } = detectPackageManager(); @@ -26,7 +26,14 @@ const configure = async (ctx: C3Context) => { doneText: `${brandColor(`installed`)} ${dim(pkg)}`, }); - // Change the import statement in svelte.config.js + updateSvelteConfig(); + updateTypeDefinitions(ctx); +}; + +const updateSvelteConfig = () => { + // All we need to do is change the import statement in svelte.config.js + updateStatus(`Changing adapter in ${blue("svelte.config.js")}`); + transformFile("svelte.config.js", { visitImportDeclaration: function (n) { // importSource is the `x` in `import y from "x"` @@ -39,23 +46,51 @@ const configure = async (ctx: C3Context) => { return false; }, }); - updateStatus(`Changing adapter in ${blue("svelte.config.js")}`); +}; - // If using typescript, add the platform interface to the `App` interface - if (usesTypescript(ctx)) { - transformFile("src/app.d.ts", { - visitTSModuleDeclaration(n) { - if (n.value.id.name === "App") { - const patchAst = parseTs(platformInterface); - const body = n.node.body as recast.types.namedTypes.TSModuleBlock; - body.body.push(patchAst.program.body[0]); - } - - this.traverse(n); - }, - }); - updateStatus(`Updating global type definitions in ${blue("app.d.ts")}`); +const updateTypeDefinitions = (ctx: C3Context) => { + if (!usesTypescript(ctx)) { + return; } + + updateStatus(`Updating global type definitions in ${blue("app.d.ts")}`); + + const b = recast.types.builders; + + transformFile("src/app.d.ts", { + visitTSModuleDeclaration(n) { + if (n.value.id.name === "App" && n.node.body) { + const moduleBlock = n.node + .body as recast.types.namedTypes.TSModuleBlock; + + const platformInterface = b.tsInterfaceDeclaration( + b.identifier("Platform"), + b.tsInterfaceBody([ + b.tsPropertySignature( + b.identifier("env"), + b.tsTypeAnnotation(b.tsTypeReference(b.identifier("Env"))) + ), + b.tsPropertySignature( + b.identifier("cf"), + b.tsTypeAnnotation( + b.tsTypeReference(b.identifier("CfProperties")) + ) + ), + b.tsPropertySignature( + b.identifier("ctx"), + b.tsTypeAnnotation( + b.tsTypeReference(b.identifier("ExecutionContext")) + ) + ), + ]) + ); + + moduleBlock.body.unshift(platformInterface); + } + + this.traverse(n); + }, + }); }; const config: TemplateConfig = { @@ -63,15 +98,32 @@ const config: TemplateConfig = { id: "svelte", displayName: "Svelte", platform: "pages", + copyFiles: { + variants: { + js: { path: "./js" }, + ts: { path: "./ts" }, + }, + }, generate, configure, - transformPackageJson: async () => ({ - scripts: { - "pages:preview": `${npm} run build && wrangler pages dev ${await compatDateFlag()} .svelte-kit/cloudflare`, - "pages:deploy": `${npm} run build && wrangler pages deploy .svelte-kit/cloudflare`, - }, - }), + transformPackageJson: async (original: PackageJson, ctx: C3Context) => { + let scripts: Record = { + preview: `${npm} run build && wrangler pages dev .svelte-kit/cloudflare`, + deploy: `${npm} run build && wrangler pages deploy .svelte-kit/cloudflare`, + }; + + if (usesTypescript(ctx)) { + const mv = platform() === "win32" ? "move" : "mv"; + scripts = { + ...scripts, + "build-cf-types": `wrangler types && ${mv} worker-configuration.d.ts src/`, + }; + } + + return { scripts }; + }, devScript: "dev", - previewScript: "pages:preview", + deployScript: "deploy", + previewScript: "preview", }; export default config; diff --git a/packages/create-cloudflare/templates/svelte/js/src/hooks.server.js b/packages/create-cloudflare/templates/svelte/js/src/hooks.server.js new file mode 100644 index 000000000000..f807b65a1213 --- /dev/null +++ b/packages/create-cloudflare/templates/svelte/js/src/hooks.server.js @@ -0,0 +1,25 @@ +import { dev } from '$app/environment'; + +/* + When developing, this hook will add proxy objects to the `platform` object which + will emulate any bindings defined in `wrangler.toml`. +*/ + +let platform; + +if (dev) { + const { getPlatformProxy } = await import('wrangler'); + platform = await getPlatformProxy(); +} + +export const handle = async ({ event, resolve }) => { + if (platform) { + event.platform = { + ...event.platform, + ...platform + }; + } + + return resolve(event); +}; + diff --git a/packages/create-cloudflare/templates/svelte/js/wrangler.toml b/packages/create-cloudflare/templates/svelte/js/wrangler.toml new file mode 100644 index 000000000000..08652e52729b --- /dev/null +++ b/packages/create-cloudflare/templates/svelte/js/wrangler.toml @@ -0,0 +1,50 @@ +name = "" +compatibility_date = "" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Note: Use secrets to store sensitive data. +# Docs: https://developers.cloudflare.com/workers/platform/environment-variables +# [vars] +# MY_VARIABLE = "production_value" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/runtime-apis/kv +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/queues/get-started +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/queues/get-started +# [[queues.consumers]] +# queue = "my-queue" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/platform/services +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects +# [[durable_objects.bindings]] +# name = "MY_DURABLE_OBJECT" +# class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/learning/using-durable-objects#configure-durable-object-classes-with-migrations +# [[migrations]] +# tag = "v1" +# new_classes = ["MyDurableObject"] diff --git a/packages/create-cloudflare/templates/svelte/templates.ts b/packages/create-cloudflare/templates/svelte/templates.ts deleted file mode 100644 index cfa05593ceb5..000000000000 --- a/packages/create-cloudflare/templates/svelte/templates.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const platformInterface = ` -interface Platform { - env: { - COUNTER: DurableObjectNamespace; - }; - context: { - waitUntil(promise: Promise): void; - }; - caches: CacheStorage & { default: Cache } -} -`; - -// interface["Platform"] #context #waitUntil:nth(2) diff --git a/packages/create-cloudflare/templates/svelte/ts/src/hooks.server.ts b/packages/create-cloudflare/templates/svelte/ts/src/hooks.server.ts new file mode 100644 index 000000000000..599c82191c3a --- /dev/null +++ b/packages/create-cloudflare/templates/svelte/ts/src/hooks.server.ts @@ -0,0 +1,24 @@ +import { dev } from '$app/environment'; + +/* + When developing, this hook will add proxy objects to the `platform` object which + will emulate any bindings defined in `wrangler.toml`. +*/ + +let platform: App.Platform; + +if (dev) { + const { getPlatformProxy } = await import('wrangler'); + platform = await getPlatformProxy(); +} + +export const handle = async ({ event, resolve }) => { + if (platform) { + event.platform = { + ...event.platform, + ...platform + }; + } + + return resolve(event); +}; diff --git a/packages/create-cloudflare/templates/svelte/ts/wrangler.toml b/packages/create-cloudflare/templates/svelte/ts/wrangler.toml new file mode 100644 index 000000000000..08652e52729b --- /dev/null +++ b/packages/create-cloudflare/templates/svelte/ts/wrangler.toml @@ -0,0 +1,50 @@ +name = "" +compatibility_date = "" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Note: Use secrets to store sensitive data. +# Docs: https://developers.cloudflare.com/workers/platform/environment-variables +# [vars] +# MY_VARIABLE = "production_value" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/runtime-apis/kv +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/queues/get-started +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/queues/get-started +# [[queues.consumers]] +# queue = "my-queue" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/platform/services +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects +# [[durable_objects.bindings]] +# name = "MY_DURABLE_OBJECT" +# class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/learning/using-durable-objects#configure-durable-object-classes-with-migrations +# [[migrations]] +# tag = "v1" +# new_classes = ["MyDurableObject"]