Skip to content

Commit f68e83e

Browse files
authored
[C3] Improve type support in Qwik template (#5052)
* Add helper script for developing codemods * Add a function for handling directories of snippets to be used in codemods * Improve out of the box support for types in qwik template * Add build-cf-types script to nuxt * Adding changeset * fixing typo * Add an env variable that overrides e2e project path for debug purposes * Fix e2e test * Update gitignore * Renaming codemode dev script * Update qwik vite codemod to be less destructive * Fix bug in codemod * Attempt to de-flake verifyDev with retry * Increase retry interval
1 parent 5693d07 commit f68e83e

File tree

14 files changed

+225
-32
lines changed

14 files changed

+225
-32
lines changed

.changeset/two-ants-work.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"create-cloudflare": patch
3+
---
4+
5+
feature: Add script to Qwik template for building Env type definitions.
6+
7+
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.

packages/create-cloudflare/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ templates/**/pnpm-lock.yaml
1212

1313
# the build step renames .gitignore files to __dot__gitignore
1414
templates/**/__dot__gitignore
15+
16+
scripts/snippets
17+
!scripts/snippets/.gitkeep

packages/create-cloudflare/e2e-tests/frameworks.test.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -442,8 +442,15 @@ const verifyDevScript = async (
442442
logStream
443443
);
444444

445-
// Wait an eternity for the dev server to spin up
446-
await sleep(12000);
445+
// Retry requesting the test route from the devserver
446+
await retry({ times: 10 }, async () => {
447+
await sleep(2000);
448+
const res = await fetch(`http://localhost:${TEST_PORT}${verifyDev.route}`);
449+
const body = await res.text();
450+
if (!body.match(verifyDev?.expectedText)) {
451+
throw new Error("Expected text not found in response from devserver.");
452+
}
453+
});
447454

448455
// Make a request to the specified test route
449456
const res = await fetch(`http://localhost:${TEST_PORT}${verifyDev.route}`);
@@ -472,8 +479,17 @@ const verifyBuildScript = async (
472479

473480
const { outputDir, script, route, expectedText } = verifyBuild;
474481

475-
// Run the build script
476482
const { name: pm, npx } = detectPackageManager();
483+
484+
// Run the `build-cf-types` script to generate types for bindings in fixture
485+
const buildTypesProc = spawnWithLogging(
486+
[pm, "run", "build-cf-types"],
487+
{ cwd: projectPath },
488+
logStream
489+
);
490+
await waitForExit(buildTypesProc);
491+
492+
// Run the build scripts
477493
const buildProc = spawnWithLogging(
478494
[pm, "run", script],
479495
{

packages/create-cloudflare/e2e-tests/helpers.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,9 @@ const normalizeTestName = (ctx: TaskContext) => {
233233
};
234234

235235
export const testProjectDir = (suite: string) => {
236-
const tmpDirPath = realpathSync(
237-
mkdtempSync(join(tmpdir(), `c3-tests-${suite}`))
238-
);
236+
const tmpDirPath =
237+
process.env.E2E_PROJECT_PATH ??
238+
realpathSync(mkdtempSync(join(tmpdir(), `c3-tests-${suite}`)));
239239

240240
const randomSuffix = crypto.randomBytes(4).toString("hex");
241241
const baseProjectName = `${C3_E2E_PREFIX}${randomSuffix}`;
@@ -244,6 +244,11 @@ export const testProjectDir = (suite: string) => {
244244
const getPath = (suffix: string) => join(tmpDirPath, getName(suffix));
245245
const clean = (suffix: string) => {
246246
try {
247+
if (process.env.E2E_PROJECT_PATH) {
248+
return;
249+
}
250+
251+
realpathSync(mkdtempSync(join(tmpdir(), `c3-tests-${suite}`)));
247252
const path = getPath(suffix);
248253
rmSync(path, {
249254
recursive: true,

packages/create-cloudflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
],
2828
"scripts": {
2929
"build": "node -r esbuild-register scripts/build.ts",
30+
"dev:codemod": "node -r esbuild-register scripts/codemodDev.ts",
3031
"check:lint": "eslint .",
3132
"check:type": "tsc",
3233
"lint": "eslint",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { join } from "path";
2+
import { parseFile, parseTs } from "helpers/codemod";
3+
import { writeFile } from "helpers/files";
4+
import * as recast from "recast";
5+
6+
/**
7+
* Writing code-mods often requires some trial and error. Since they are often
8+
* applied later on in a c3 run, manual testing can become a hassle. This script was meant
9+
* to help test and develop transforms in isolation without having to write a throw-away script.
10+
*
11+
* Replace your codemod below and run the script with `pnpm run dev:codemod`.
12+
*
13+
* Test files can be kept in the `./snippets` directory, where you will also find the output from
14+
* the last run.
15+
*
16+
*/
17+
18+
/**
19+
* This function mocks the `transformFile` API but outputs it to the console and writes it
20+
* to a dedicated output file for easier testing.
21+
*/
22+
export const testTransform = (
23+
filePath: string,
24+
methods: recast.types.Visitor
25+
) => {
26+
const ast = parseFile(join(__dirname, filePath));
27+
28+
if (ast) {
29+
recast.visit(ast, methods);
30+
const code = recast.print(ast).code;
31+
console.log(code);
32+
writeFile(join(__dirname, "snippets", "output"), code);
33+
}
34+
};
35+
36+
// Use this function to experiment with a codemod in isolation
37+
const testCodemod = () => {
38+
// const b = recast.types.builders;
39+
// const snippets = loadSnippets(join(__dirname, "snippets"));
40+
41+
testTransform("snippets/test.ts", {
42+
visitIdentifier(n) {
43+
n.node.name = "Potato";
44+
45+
return false;
46+
},
47+
});
48+
};
49+
testCodemod();
50+
51+
// This function can be used to inspect the AST of a particular snippet
52+
const printSnippet = () => {
53+
const snippet = `
54+
if(true) {
55+
console.log("potato");
56+
}
57+
`;
58+
59+
const program = parseTs(snippet).program;
60+
console.log(program.body[0].consequent);
61+
};
62+
// printSnippet();

packages/create-cloudflare/scripts/snippets/.gitkeep

Whitespace-only changes.

packages/create-cloudflare/src/helpers/codemod.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import path from "path";
1+
import { existsSync, lstatSync, readdirSync } from "fs";
2+
import path, { extname, join } from "path";
23
import { crash } from "@cloudflare/cli";
34
import * as recast from "recast";
45
import * as esprimaParser from "recast/parsers/esprima";
56
import * as typescriptParser from "recast/parsers/typescript";
7+
import { getTemplatePath } from "../templates";
68
import { readFile, writeFile } from "./files";
79
import type { Program } from "esprima";
10+
import type { C3Context } from "types";
811

912
/*
1013
CODEMOD TIPS & TRICKS
@@ -55,7 +58,7 @@ export const parseFile = (filePath: string) => {
5558
const fileContents = readFile(path.resolve(filePath));
5659

5760
if (fileContents) {
58-
return recast.parse(fileContents, { parser }) as Program;
61+
return recast.parse(fileContents, { parser }).program as Program;
5962
}
6063
} catch (error) {
6164
crash(`Error parsing file: ${filePath}`);
@@ -76,3 +79,37 @@ export const transformFile = (
7679
writeFile(filePath, recast.print(ast).code);
7780
}
7881
};
82+
83+
export const loadSnippets = (parentFolder: string) => {
84+
const snippetsPath = join(parentFolder, "snippets");
85+
86+
if (!existsSync(snippetsPath)) {
87+
return {};
88+
}
89+
90+
if (!lstatSync(snippetsPath).isDirectory) {
91+
return {};
92+
}
93+
94+
const files = readdirSync(snippetsPath);
95+
96+
return (
97+
files
98+
// don't try loading directories
99+
.filter((fileName) => lstatSync(join(snippetsPath, fileName)).isFile)
100+
// only load js or ts files
101+
.filter((fileName) => [".js", ".ts"].includes(extname(fileName)))
102+
.reduce((acc, snippetPath) => {
103+
const [file, ext] = snippetPath.split(".");
104+
const key = `${file}${ext === "js" ? "Js" : "Ts"}`;
105+
return {
106+
...acc,
107+
[key]: parseFile(join(snippetsPath, snippetPath))?.body,
108+
};
109+
}, {}) as Record<string, recast.types.ASTNode[]>
110+
);
111+
};
112+
113+
export const loadTemplateSnippets = (ctx: C3Context) => {
114+
return loadSnippets(getTemplatePath(ctx));
115+
};

packages/create-cloudflare/templates/nuxt/c3.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ const config: TemplateConfig = {
9393
scripts: {
9494
deploy: `${npm} run build && wrangler pages deploy ./dist`,
9595
preview: `${npm} run build && wrangler pages dev ./dist`,
96+
"build-cf-types": `wrangler types`,
9697
},
9798
}),
9899
};

packages/create-cloudflare/templates/qwik/c3.ts

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { endSection } from "@cloudflare/cli";
1+
import { crash, endSection } from "@cloudflare/cli";
22
import { brandColor } from "@cloudflare/cli/colors";
33
import { spinner } from "@cloudflare/cli/interactive";
4-
import { parseTs, transformFile } from "helpers/codemod";
4+
import { loadTemplateSnippets, transformFile } from "helpers/codemod";
55
import { runCommand, runFrameworkGenerator } from "helpers/command";
66
import { usesTypescript } from "helpers/files";
77
import { detectPackageManager } from "helpers/packages";
8+
import * as recast from "recast";
89
import { quoteShellArgs } from "../../src/common";
910
import type { TemplateConfig } from "../../src/templates";
10-
import type * as recast from "recast";
1111
import type { C3Context } from "types";
1212

1313
const { npm, npx } = detectPackageManager();
@@ -23,6 +23,7 @@ const configure = async (ctx: C3Context) => {
2323
await runCommand(cmd);
2424

2525
addBindingsProxy(ctx);
26+
populateCloudflareEnv();
2627
};
2728

2829
const addBindingsProxy = (ctx: C3Context) => {
@@ -35,43 +36,90 @@ const addBindingsProxy = (ctx: C3Context) => {
3536
const s = spinner();
3637
s.start("Updating `vite.config.ts`");
3738

38-
// Insert the env declaration after the last import (but before the rest of the body)
39-
const envDeclaration = `
40-
let env = {};
41-
42-
if(process.env.NODE_ENV === 'development') {
43-
const { getPlatformProxy } = await import('wrangler');
44-
const platformProxy = await getPlatformProxy();
45-
env = platformProxy.env;
46-
}
47-
`;
39+
const snippets = loadTemplateSnippets(ctx);
40+
const b = recast.types.builders;
4841

4942
transformFile("vite.config.ts", {
43+
// Insert the env declaration after the last import (but before the rest of the body)
5044
visitProgram: function (n) {
5145
const lastImportIndex = n.node.body.findLastIndex(
5246
(t) => t.type === "ImportDeclaration"
5347
);
54-
n.get("body").insertAt(lastImportIndex + 1, envDeclaration);
48+
const lastImport = n.get("body", lastImportIndex);
49+
lastImport.insertAfter(...snippets.getPlatformProxyTs);
50+
51+
return this.traverse(n);
52+
},
53+
// Pass the `platform` object from the declaration to the `qwikCity` plugin
54+
visitCallExpression: function (n) {
55+
const callee = n.node.callee as recast.types.namedTypes.Identifier;
56+
if (callee.name !== "qwikCity") {
57+
return this.traverse(n);
58+
}
59+
60+
// The config object passed to `qwikCity`
61+
const configArgument = n.node.arguments[0] as
62+
| recast.types.namedTypes.ObjectExpression
63+
| undefined;
64+
65+
const platformPropery = b.objectProperty.from({
66+
key: b.identifier("platform"),
67+
value: b.identifier("platform"),
68+
shorthand: true,
69+
});
70+
71+
if (!configArgument) {
72+
n.node.arguments = [b.objectExpression([platformPropery])];
73+
74+
return false;
75+
}
76+
77+
if (configArgument.type !== "ObjectExpression") {
78+
crash("Failed to update `vite.config.ts`");
79+
}
80+
81+
// Add the `platform` object to the object
82+
configArgument.properties.push(platformPropery);
5583

5684
return false;
5785
},
5886
});
5987

60-
// Populate the `qwikCity` plugin with the platform object containing the `env` defined above.
61-
const platformObject = parseTs(`{ platform: { env } }`);
88+
s.stop(`${brandColor("updated")} \`vite.config.ts\``);
89+
};
6290

63-
transformFile("vite.config.ts", {
64-
visitCallExpression: function (n) {
65-
const callee = n.node.callee as recast.types.namedTypes.Identifier;
66-
if (callee.name === "qwikCity") {
67-
n.node.arguments = [platformObject];
91+
const populateCloudflareEnv = () => {
92+
const entrypointPath = "src/entry.cloudflare-pages.tsx";
93+
94+
const s = spinner();
95+
s.start(`Updating \`${entrypointPath}\``);
96+
97+
transformFile(entrypointPath, {
98+
visitTSInterfaceDeclaration: function (n) {
99+
const b = recast.types.builders;
100+
const id = n.node.id as recast.types.namedTypes.Identifier;
101+
if (id.name !== "QwikCityPlatform") {
102+
this.traverse(n);
68103
}
69104

70-
this.traverse(n);
105+
const newBody = [
106+
["env", "Env"],
107+
// Qwik doesn't supply `cf` to the platform object. Should they do so, uncomment this
108+
// ["cf", "CfProperties"],
109+
].map(([varName, type]) =>
110+
b.tsPropertySignature(
111+
b.identifier(varName),
112+
b.tsTypeAnnotation(b.tsTypeReference(b.identifier(type)))
113+
)
114+
);
115+
116+
n.node.body.body = newBody;
117+
118+
return false;
71119
},
72120
});
73121

74-
s.stop(`${brandColor("updated")} \`vite.config.ts\``);
122+
s.stop(`${brandColor("updated")} \`${entrypointPath}\``);
75123
};
76124

77125
const config: TemplateConfig = {
@@ -89,6 +137,8 @@ const config: TemplateConfig = {
89137
transformPackageJson: async () => ({
90138
scripts: {
91139
deploy: `${npm} run build && wrangler pages deploy ./dist`,
140+
preview: `${npm} run build && wrangler pages dev ./dist`,
141+
"build-cf-types": `wrangler types`,
92142
},
93143
}),
94144
};

0 commit comments

Comments
 (0)