diff --git a/.changeset/two-ants-work.md b/.changeset/two-ants-work.md new file mode 100644 index 000000000000..55103d966ef5 --- /dev/null +++ b/.changeset/two-ants-work.md @@ -0,0 +1,7 @@ +--- +"create-cloudflare": patch +--- + +feature: Add script to Qwik template for building Env type definitions. + +When creating a project with the Qwik template, the `QwikCityPlatform` type will be updated to contain a definition for the `env` property. These types can be re-generated with a newly added `build-cf-types` script. diff --git a/packages/create-cloudflare/.gitignore b/packages/create-cloudflare/.gitignore index d25b4d3b5aa8..e2d32d703707 100644 --- a/packages/create-cloudflare/.gitignore +++ b/packages/create-cloudflare/.gitignore @@ -12,3 +12,6 @@ templates/**/pnpm-lock.yaml # the build step renames .gitignore files to __dot__gitignore templates/**/__dot__gitignore + +scripts/snippets +!scripts/snippets/.gitkeep \ No newline at end of file diff --git a/packages/create-cloudflare/e2e-tests/frameworks.test.ts b/packages/create-cloudflare/e2e-tests/frameworks.test.ts index 1c61ecfe02da..3ebab5fd22d9 100644 --- a/packages/create-cloudflare/e2e-tests/frameworks.test.ts +++ b/packages/create-cloudflare/e2e-tests/frameworks.test.ts @@ -442,8 +442,15 @@ const verifyDevScript = async ( logStream ); - // Wait an eternity for the dev server to spin up - await sleep(12000); + // Retry requesting the test route from the devserver + await retry({ times: 10 }, async () => { + await sleep(2000); + const res = await fetch(`http://localhost:${TEST_PORT}${verifyDev.route}`); + const body = await res.text(); + if (!body.match(verifyDev?.expectedText)) { + throw new Error("Expected text not found in response from devserver."); + } + }); // Make a request to the specified test route const res = await fetch(`http://localhost:${TEST_PORT}${verifyDev.route}`); @@ -472,8 +479,17 @@ const verifyBuildScript = async ( const { outputDir, script, route, expectedText } = verifyBuild; - // Run the build script const { name: pm, npx } = detectPackageManager(); + + // Run the `build-cf-types` script to generate types for bindings in fixture + const buildTypesProc = spawnWithLogging( + [pm, "run", "build-cf-types"], + { cwd: projectPath }, + logStream + ); + await waitForExit(buildTypesProc); + + // Run the build scripts const buildProc = spawnWithLogging( [pm, "run", script], { diff --git a/packages/create-cloudflare/e2e-tests/helpers.ts b/packages/create-cloudflare/e2e-tests/helpers.ts index 93576c4d979a..57fceff155b2 100644 --- a/packages/create-cloudflare/e2e-tests/helpers.ts +++ b/packages/create-cloudflare/e2e-tests/helpers.ts @@ -233,9 +233,9 @@ const normalizeTestName = (ctx: TaskContext) => { }; export const testProjectDir = (suite: string) => { - const tmpDirPath = realpathSync( - mkdtempSync(join(tmpdir(), `c3-tests-${suite}`)) - ); + const tmpDirPath = + process.env.E2E_PROJECT_PATH ?? + realpathSync(mkdtempSync(join(tmpdir(), `c3-tests-${suite}`))); const randomSuffix = crypto.randomBytes(4).toString("hex"); const baseProjectName = `${C3_E2E_PREFIX}${randomSuffix}`; @@ -244,6 +244,11 @@ export const testProjectDir = (suite: string) => { const getPath = (suffix: string) => join(tmpDirPath, getName(suffix)); const clean = (suffix: string) => { try { + if (process.env.E2E_PROJECT_PATH) { + return; + } + + realpathSync(mkdtempSync(join(tmpdir(), `c3-tests-${suite}`))); const path = getPath(suffix); rmSync(path, { recursive: true, diff --git a/packages/create-cloudflare/package.json b/packages/create-cloudflare/package.json index 7b3e564a01f7..2e87e7d35d51 100644 --- a/packages/create-cloudflare/package.json +++ b/packages/create-cloudflare/package.json @@ -27,6 +27,7 @@ ], "scripts": { "build": "node -r esbuild-register scripts/build.ts", + "dev:codemod": "node -r esbuild-register scripts/codemodDev.ts", "check:lint": "eslint .", "check:type": "tsc", "lint": "eslint", diff --git a/packages/create-cloudflare/scripts/codemodDev.ts b/packages/create-cloudflare/scripts/codemodDev.ts new file mode 100644 index 000000000000..fa902fc84043 --- /dev/null +++ b/packages/create-cloudflare/scripts/codemodDev.ts @@ -0,0 +1,62 @@ +import { join } from "path"; +import { parseFile, parseTs } from "helpers/codemod"; +import { writeFile } from "helpers/files"; +import * as recast from "recast"; + +/** + * Writing code-mods often requires some trial and error. Since they are often + * applied later on in a c3 run, manual testing can become a hassle. This script was meant + * to help test and develop transforms in isolation without having to write a throw-away script. + * + * Replace your codemod below and run the script with `pnpm run dev:codemod`. + * + * Test files can be kept in the `./snippets` directory, where you will also find the output from + * the last run. + * + */ + +/** + * This function mocks the `transformFile` API but outputs it to the console and writes it + * to a dedicated output file for easier testing. + */ +export const testTransform = ( + filePath: string, + methods: recast.types.Visitor +) => { + const ast = parseFile(join(__dirname, filePath)); + + if (ast) { + recast.visit(ast, methods); + const code = recast.print(ast).code; + console.log(code); + writeFile(join(__dirname, "snippets", "output"), code); + } +}; + +// Use this function to experiment with a codemod in isolation +const testCodemod = () => { + // const b = recast.types.builders; + // const snippets = loadSnippets(join(__dirname, "snippets")); + + testTransform("snippets/test.ts", { + visitIdentifier(n) { + n.node.name = "Potato"; + + return false; + }, + }); +}; +testCodemod(); + +// This function can be used to inspect the AST of a particular snippet +const printSnippet = () => { + const snippet = ` + if(true) { + console.log("potato"); + } + `; + + const program = parseTs(snippet).program; + console.log(program.body[0].consequent); +}; +// printSnippet(); diff --git a/packages/create-cloudflare/scripts/snippets/.gitkeep b/packages/create-cloudflare/scripts/snippets/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/create-cloudflare/src/helpers/codemod.ts b/packages/create-cloudflare/src/helpers/codemod.ts index d1e924e1afcd..df700cfc2654 100644 --- a/packages/create-cloudflare/src/helpers/codemod.ts +++ b/packages/create-cloudflare/src/helpers/codemod.ts @@ -1,10 +1,13 @@ -import path from "path"; +import { existsSync, lstatSync, readdirSync } from "fs"; +import path, { extname, join } from "path"; import { crash } from "@cloudflare/cli"; import * as recast from "recast"; import * as esprimaParser from "recast/parsers/esprima"; import * as typescriptParser from "recast/parsers/typescript"; +import { getTemplatePath } from "../templates"; import { readFile, writeFile } from "./files"; import type { Program } from "esprima"; +import type { C3Context } from "types"; /* CODEMOD TIPS & TRICKS @@ -55,7 +58,7 @@ export const parseFile = (filePath: string) => { const fileContents = readFile(path.resolve(filePath)); if (fileContents) { - return recast.parse(fileContents, { parser }) as Program; + return recast.parse(fileContents, { parser }).program as Program; } } catch (error) { crash(`Error parsing file: ${filePath}`); @@ -76,3 +79,37 @@ export const transformFile = ( writeFile(filePath, recast.print(ast).code); } }; + +export const loadSnippets = (parentFolder: string) => { + const snippetsPath = join(parentFolder, "snippets"); + + if (!existsSync(snippetsPath)) { + return {}; + } + + if (!lstatSync(snippetsPath).isDirectory) { + return {}; + } + + const files = readdirSync(snippetsPath); + + return ( + files + // don't try loading directories + .filter((fileName) => lstatSync(join(snippetsPath, fileName)).isFile) + // only load js or ts files + .filter((fileName) => [".js", ".ts"].includes(extname(fileName))) + .reduce((acc, snippetPath) => { + const [file, ext] = snippetPath.split("."); + const key = `${file}${ext === "js" ? "Js" : "Ts"}`; + return { + ...acc, + [key]: parseFile(join(snippetsPath, snippetPath))?.body, + }; + }, {}) as Record + ); +}; + +export const loadTemplateSnippets = (ctx: C3Context) => { + return loadSnippets(getTemplatePath(ctx)); +}; diff --git a/packages/create-cloudflare/templates/nuxt/c3.ts b/packages/create-cloudflare/templates/nuxt/c3.ts index ab38c5fced5b..bd2663536d0f 100644 --- a/packages/create-cloudflare/templates/nuxt/c3.ts +++ b/packages/create-cloudflare/templates/nuxt/c3.ts @@ -93,6 +93,7 @@ const config: TemplateConfig = { scripts: { deploy: `${npm} run build && wrangler pages deploy ./dist`, preview: `${npm} run build && wrangler pages dev ./dist`, + "build-cf-types": `wrangler types`, }, }), }; diff --git a/packages/create-cloudflare/templates/qwik/c3.ts b/packages/create-cloudflare/templates/qwik/c3.ts index dec025afa085..493e9c3983c4 100644 --- a/packages/create-cloudflare/templates/qwik/c3.ts +++ b/packages/create-cloudflare/templates/qwik/c3.ts @@ -1,13 +1,13 @@ -import { endSection } from "@cloudflare/cli"; +import { crash, endSection } from "@cloudflare/cli"; import { brandColor } from "@cloudflare/cli/colors"; import { spinner } from "@cloudflare/cli/interactive"; -import { parseTs, transformFile } from "helpers/codemod"; +import { loadTemplateSnippets, transformFile } from "helpers/codemod"; import { runCommand, runFrameworkGenerator } from "helpers/command"; import { usesTypescript } from "helpers/files"; import { detectPackageManager } from "helpers/packages"; +import * as recast from "recast"; import { quoteShellArgs } from "../../src/common"; import type { TemplateConfig } from "../../src/templates"; -import type * as recast from "recast"; import type { C3Context } from "types"; const { npm, npx } = detectPackageManager(); @@ -23,6 +23,7 @@ const configure = async (ctx: C3Context) => { await runCommand(cmd); addBindingsProxy(ctx); + populateCloudflareEnv(); }; const addBindingsProxy = (ctx: C3Context) => { @@ -35,43 +36,90 @@ const addBindingsProxy = (ctx: C3Context) => { const s = spinner(); s.start("Updating `vite.config.ts`"); - // Insert the env declaration after the last import (but before the rest of the body) - const envDeclaration = ` -let env = {}; - -if(process.env.NODE_ENV === 'development') { - const { getPlatformProxy } = await import('wrangler'); - const platformProxy = await getPlatformProxy(); - env = platformProxy.env; -} -`; + const snippets = loadTemplateSnippets(ctx); + const b = recast.types.builders; transformFile("vite.config.ts", { + // Insert the env declaration after the last import (but before the rest of the body) visitProgram: function (n) { const lastImportIndex = n.node.body.findLastIndex( (t) => t.type === "ImportDeclaration" ); - n.get("body").insertAt(lastImportIndex + 1, envDeclaration); + const lastImport = n.get("body", lastImportIndex); + lastImport.insertAfter(...snippets.getPlatformProxyTs); + + return this.traverse(n); + }, + // Pass the `platform` object from the declaration to the `qwikCity` plugin + visitCallExpression: function (n) { + const callee = n.node.callee as recast.types.namedTypes.Identifier; + if (callee.name !== "qwikCity") { + return this.traverse(n); + } + + // The config object passed to `qwikCity` + const configArgument = n.node.arguments[0] as + | recast.types.namedTypes.ObjectExpression + | undefined; + + const platformPropery = b.objectProperty.from({ + key: b.identifier("platform"), + value: b.identifier("platform"), + shorthand: true, + }); + + if (!configArgument) { + n.node.arguments = [b.objectExpression([platformPropery])]; + + return false; + } + + if (configArgument.type !== "ObjectExpression") { + crash("Failed to update `vite.config.ts`"); + } + + // Add the `platform` object to the object + configArgument.properties.push(platformPropery); return false; }, }); - // Populate the `qwikCity` plugin with the platform object containing the `env` defined above. - const platformObject = parseTs(`{ platform: { env } }`); + s.stop(`${brandColor("updated")} \`vite.config.ts\``); +}; - transformFile("vite.config.ts", { - visitCallExpression: function (n) { - const callee = n.node.callee as recast.types.namedTypes.Identifier; - if (callee.name === "qwikCity") { - n.node.arguments = [platformObject]; +const populateCloudflareEnv = () => { + const entrypointPath = "src/entry.cloudflare-pages.tsx"; + + const s = spinner(); + s.start(`Updating \`${entrypointPath}\``); + + transformFile(entrypointPath, { + visitTSInterfaceDeclaration: function (n) { + const b = recast.types.builders; + const id = n.node.id as recast.types.namedTypes.Identifier; + if (id.name !== "QwikCityPlatform") { + this.traverse(n); } - this.traverse(n); + const newBody = [ + ["env", "Env"], + // Qwik doesn't supply `cf` to the platform object. Should they do so, uncomment this + // ["cf", "CfProperties"], + ].map(([varName, type]) => + b.tsPropertySignature( + b.identifier(varName), + b.tsTypeAnnotation(b.tsTypeReference(b.identifier(type))) + ) + ); + + n.node.body.body = newBody; + + return false; }, }); - s.stop(`${brandColor("updated")} \`vite.config.ts\``); + s.stop(`${brandColor("updated")} \`${entrypointPath}\``); }; const config: TemplateConfig = { @@ -89,6 +137,8 @@ const config: TemplateConfig = { transformPackageJson: async () => ({ scripts: { deploy: `${npm} run build && wrangler pages deploy ./dist`, + preview: `${npm} run build && wrangler pages dev ./dist`, + "build-cf-types": `wrangler types`, }, }), }; diff --git a/packages/create-cloudflare/templates/qwik/snippets/getPlatformProxy.ts b/packages/create-cloudflare/templates/qwik/snippets/getPlatformProxy.ts new file mode 100644 index 000000000000..80789f159a76 --- /dev/null +++ b/packages/create-cloudflare/templates/qwik/snippets/getPlatformProxy.ts @@ -0,0 +1,6 @@ +let platform = {}; + +if(process.env.NODE_ENV === 'development') { + const { getPlatformProxy } = await import('wrangler'); + platform = await getPlatformProxy(); +} diff --git a/packages/create-cloudflare/templates/qwik/templates/worker-configuration.d.ts b/packages/create-cloudflare/templates/qwik/templates/worker-configuration.d.ts new file mode 100644 index 000000000000..cc2db3f0d7e5 --- /dev/null +++ b/packages/create-cloudflare/templates/qwik/templates/worker-configuration.d.ts @@ -0,0 +1,3 @@ +// Generated by Wrangler on Fri Feb 16 2024 15:52:18 GMT-0600 (Central Standard Time) +// After adding bindings to `wrangler.toml`, regenerate this interface via `npm build-cf-types` +interface Env {} diff --git a/packages/create-cloudflare/tsconfig.json b/packages/create-cloudflare/tsconfig.json index 0168b9a96931..97d8825ae6b4 100644 --- a/packages/create-cloudflare/tsconfig.json +++ b/packages/create-cloudflare/tsconfig.json @@ -5,6 +5,7 @@ "exclude": [ "node_modules", "dist", + "scripts/snippets/*", "e2e-tests/fixtures/*", // exclude all template files other than the top level ones so // that we can catch `c3.ts`. For example, any top level files in diff --git a/packages/create-cloudflare/turbo.json b/packages/create-cloudflare/turbo.json index 53898ea8d815..1261d7fc8895 100644 --- a/packages/create-cloudflare/turbo.json +++ b/packages/create-cloudflare/turbo.json @@ -12,7 +12,8 @@ "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "FRAMEWORK_CLI_TO_TEST", - "E2E_QUARANTINE" + "E2E_QUARANTINE", + "E2E_PROJECT_PATH" ] } }