diff --git a/packages/esbuild-plugins/package.json b/packages/esbuild-plugins/package.json index ed78ab2..a005361 100644 --- a/packages/esbuild-plugins/package.json +++ b/packages/esbuild-plugins/package.json @@ -9,7 +9,8 @@ "dev": "npm run build -- --watch" }, "dependencies": { - "esbuild": "^0.19.3" + "esbuild": "^0.19.3", + "typescript": "^5.2.2" }, "devDependencies": { "@types/common-tags": "^1.8.2", diff --git a/packages/esbuild-plugins/src/index.ts b/packages/esbuild-plugins/src/index.ts index 9d1678e..2af6bb7 100644 --- a/packages/esbuild-plugins/src/index.ts +++ b/packages/esbuild-plugins/src/index.ts @@ -1 +1,2 @@ -export * from "./function-plugin"; +export * from "./plugins/function-infra-plugin"; +export * from "./plugins/function-runtime-plugin"; diff --git a/packages/esbuild-plugins/src/parsers/read-config-export.ts b/packages/esbuild-plugins/src/parsers/read-config-export.ts new file mode 100644 index 0000000..77545d2 --- /dev/null +++ b/packages/esbuild-plugins/src/parsers/read-config-export.ts @@ -0,0 +1,65 @@ +import * as ts from "typescript"; + +export function readConfigExport(input: string) { + const sourceFile = ts.createSourceFile( + "file.ts", + input, + ts.ScriptTarget.ESNext, + true, + ); + + const configExport = sourceFile.statements.find( + (stmt) => + ts.isVariableStatement(stmt) && + stmt.declarationList.declarations.some( + (decl) => decl.name.getText() === "config", + ), + ) as ts.VariableStatement | undefined; + + if (!configExport) { + throw new Error("No named export 'config' found."); + } + + const configDeclaration = configExport.declarationList.declarations.find( + (decl) => decl.name.getText() === "config", + ) as ts.VariableDeclaration; + + if ( + !configDeclaration || + !ts.isObjectLiteralExpression(configDeclaration.initializer!) + ) { + throw new Error("'config' is not an object literal."); + } + + let configObjectStr = "{ "; + + configDeclaration.initializer.properties.forEach((prop, index, array) => { + if (ts.isPropertyAssignment(prop)) { + const key = prop.name.getText(); + const valueNode = prop.initializer; + if ( + ts.isStringLiteral(valueNode) || + ts.isNumericLiteral(valueNode) || + valueNode.kind === ts.SyntaxKind.TrueKeyword || + valueNode.kind === ts.SyntaxKind.FalseKeyword + ) { + const value = valueNode.getText(); + configObjectStr += `${key}: ${value}${ + index < array.length - 1 ? ", " : " " + }`; + } else { + throw new Error( + `Invalid value type for key '${key}': only numbers, strings, and booleans are allowed.`, + ); + } + } else { + throw new Error( + `Invalid property assignment in 'config': ${prop.getText()}`, + ); + } + }); + + configObjectStr += "}"; + + return configObjectStr; +} diff --git a/packages/esbuild-plugins/src/parsers/remove-config-export.ts b/packages/esbuild-plugins/src/parsers/remove-config-export.ts new file mode 100644 index 0000000..1fbed45 --- /dev/null +++ b/packages/esbuild-plugins/src/parsers/remove-config-export.ts @@ -0,0 +1,40 @@ +import * as ts from "typescript"; + +export function removeConfigExport(sourceText: string): string { + const sourceFile = ts.createSourceFile( + "file.ts", + sourceText, + ts.ScriptTarget.ES2015, + true, + ); + + const printer = ts.createPrinter(); + const resultFile = ts.transform(sourceFile, [removeConfigExportTransformer]) + .transformed[0]; + + const result = printer.printNode( + ts.EmitHint.Unspecified, + resultFile, + sourceFile, + ); + + return result; +} + +function removeConfigExportTransformer(context: ts.TransformationContext) { + return (node: ts.Node): ts.Node => { + if ( + ts.isVariableStatement(node) && + node.declarationList.declarations.some( + (decl) => decl.name.getText() === "config", + ) + ) { + return ts.factory.createNotEmittedStatement(node); + } + return ts.visitEachChild( + node, + (child) => removeConfigExportTransformer(context)(child), + context, + ); + }; +} diff --git a/packages/esbuild-plugins/src/function-plugin.ts b/packages/esbuild-plugins/src/plugins/function-infra-plugin.ts similarity index 64% rename from packages/esbuild-plugins/src/function-plugin.ts rename to packages/esbuild-plugins/src/plugins/function-infra-plugin.ts index 08ffa4f..af7d353 100644 --- a/packages/esbuild-plugins/src/function-plugin.ts +++ b/packages/esbuild-plugins/src/plugins/function-infra-plugin.ts @@ -1,21 +1,14 @@ import esbuild, { Plugin } from "esbuild"; +import { readConfigExport } from "src/parsers/read-config-export"; -/** - * compiles cloud function modules - * runs separate esbuild process to get exports metadata - */ -export function functionPlugin(opts: { - fileIsFunction: (filePath: string) => boolean; +export function functionInfraPlugin(opts: { getFile: (filePath: string) => string | Promise; esbuildInstance?: typeof esbuild; }): Plugin { return { name: "function", setup(build) { - build.onLoad({ filter: /.*$/ }, async (args) => { - const isFunction = await opts.fileIsFunction(args.path); - if (!isFunction) return; - + build.onLoad({ filter: /.\.fn*/ }, async (args) => { const esbuild = opts.esbuildInstance || (await import("esbuild")); const fileContent = await opts.getFile(args.path); @@ -35,11 +28,18 @@ export function functionPlugin(opts: { const reservedExports = ["preload", "config"]; const userExports = exports.filter((e) => !reservedExports.includes(e)); - let infraCode = `import { createWorkflowNode } from "@notation/sdk"`; + let infraCode = `import { fn } from "@notation/sdk"`; + + if (exports.includes("config")) { + const config = readConfigExport(fileContent); + infraCode = infraCode.concat(`\nconst config = ${config};`); + } else { + infraCode = infraCode.concat(`\nconst config = {};`); + } for (const handlerName of userExports) { infraCode = infraCode.concat( - `\nexport const ${handlerName} = createWorkflowNode("${handlerName}");`, + `\nexport const ${handlerName} = fn({ handler: "${handlerName}", ...config });`, ); } diff --git a/packages/esbuild-plugins/src/plugins/function-runtime-plugin.ts b/packages/esbuild-plugins/src/plugins/function-runtime-plugin.ts new file mode 100644 index 0000000..d66a864 --- /dev/null +++ b/packages/esbuild-plugins/src/plugins/function-runtime-plugin.ts @@ -0,0 +1,21 @@ +import esbuild, { Plugin } from "esbuild"; +import { removeConfigExport } from "src/parsers/remove-config-export"; + +export function functionRuntimePlugin(opts: { + getFile: (filePath: string) => string | Promise; + esbuildInstance?: typeof esbuild; +}): Plugin { + return { + name: "function", + setup(build) { + build.onLoad({ filter: /.\.fn*/ }, async (args) => { + const fileContent = await opts.getFile(args.path); + const runtimeCode = removeConfigExport(fileContent); + return { + loader: "ts", + contents: runtimeCode, + }; + }); + }, + }; +} diff --git a/packages/esbuild-plugins/test/esbuild-test-utils.ts b/packages/esbuild-plugins/test/esbuild-test-utils.ts index e93c5e0..ceb9a11 100644 --- a/packages/esbuild-plugins/test/esbuild-test-utils.ts +++ b/packages/esbuild-plugins/test/esbuild-test-utils.ts @@ -3,11 +3,15 @@ import esbuild, { BuildOptions, Plugin } from "esbuild"; const virtualFilePlugin = (input: any): Plugin => ({ name: "virtual-file", setup(build) { - build.onResolve({ filter: /.*/ }, (args) => { + build.onResolve({ filter: /\..*/ }, (args) => { return { path: args.path, namespace: "stdin" }; }); - build.onLoad({ filter: /.*/, namespace: "stdin" }, (args) => { - return { contents: input, loader: "ts", resolveDir: args.path }; + build.onLoad({ filter: /\..*/, namespace: "stdin" }, (args) => { + return { + contents: input, + loader: "ts", + resolveDir: args.path, + }; }); }, }); @@ -19,7 +23,7 @@ export function createBuilder( const buildOptions = getBuildOptions(input); const result = await esbuild.build({ - entryPoints: ["entry.ts"], + entryPoints: [".entry.fn.ts"], write: false, ...buildOptions, plugins: [...(buildOptions.plugins || []), virtualFilePlugin(input)], diff --git a/packages/esbuild-plugins/test/function-plugin.test.ts b/packages/esbuild-plugins/test/function-plugin.test.ts deleted file mode 100644 index d2d3bfd..0000000 --- a/packages/esbuild-plugins/test/function-plugin.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { stripIndent } from "common-tags"; -import { functionPlugin } from "../src/function-plugin"; -import { createBuilder } from "./esbuild-test-utils"; -import { expect, it } from "bun:test"; - -const build = createBuilder((input) => ({ - plugins: [ - functionPlugin({ fileIsFunction: () => true, getFile: () => input }), - ], -})); - -it("remaps exports", async () => { - const input = ` - export function myFunction() { /* ... */ } - `; - - const expected = stripIndent` - import { createWorkflowNode } from "@notation/sdk"; - export const myFunction = createWorkflowNode("myFunction"); - `; - - const output = await build(input); - - expect(output).toContain(expected); -}); - -it("should strip runtime code", async () => { - const input = ` - import twilio from "twilio"; - - twilio.setup(); - - export const myFunction1 = () => { /* ... */ }; - export const myFunction2 = () => { /* ... */ }; - `; - - const expected = stripIndent` - import { createWorkflowNode } from "@notation/sdk"; - export const myFunction1 = createWorkflowNode("myFunction1"); - export const myFunction2 = createWorkflowNode("myFunction2"); - `; - - const output = await build(input); - - expect(output).toContain(expected); -}); diff --git a/packages/esbuild-plugins/test/plugins/function-infra-plugin.test.ts b/packages/esbuild-plugins/test/plugins/function-infra-plugin.test.ts new file mode 100644 index 0000000..72090e8 --- /dev/null +++ b/packages/esbuild-plugins/test/plugins/function-infra-plugin.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from "bun:test"; +import { stripIndent } from "common-tags"; +import { functionInfraPlugin } from "src/plugins/function-infra-plugin"; +import { createBuilder } from "test/esbuild-test-utils"; + +const buildInfra = createBuilder((input) => ({ + plugins: [functionInfraPlugin({ getFile: () => input })], +})); + +it("remaps exports", async () => { + const input = ` + import { handler, FnConfig } from "@notation/sdk"; + export const getNum = handler(() => 1); + `; + + const expected = stripIndent` + import { fn } from "@notation/sdk"; + const config = {}; + export const getNum = fn({ handler: "getNum", ...config }); + `; + + const output = await buildInfra(input); + + expect(output).toContain(expected); +}); + +it("merges config", async () => { + const input = ` + import { handler, FnConfig } from "@notation/sdk"; + export const getNum = handler(() => 1); + export const config: FnConfig = { memory: 64 }; + `; + + const expected = stripIndent` + import { fn } from "@notation/sdk"; + const config = { memory: 64 }; + export const getNum = fn({ handler: "getNum", ...config }); + `; + + const output = await buildInfra(input); + + expect(output).toContain(expected); +}); + +it("should strip runtime code", async () => { + const input = ` + import { handler } from "@notation/sdk"; + import lib from "lib"; + + let num = lib.getNum(); + + export const getNum = () => num; + export const getDoubleNum = () => num * 2; + `; + + const expected = stripIndent` + import { fn } from "@notation/sdk"; + const config = {}; + export const getDoubleNum = fn({ handler: "getDoubleNum", ...config }); + export const getNum = fn({ handler: "getNum", ...config }); + `; + + const output = await buildInfra(input); + + expect(output).toContain(expected); +}); diff --git a/packages/esbuild-plugins/test/plugins/function-runtime-plugin.test.ts b/packages/esbuild-plugins/test/plugins/function-runtime-plugin.test.ts new file mode 100644 index 0000000..b88dccc --- /dev/null +++ b/packages/esbuild-plugins/test/plugins/function-runtime-plugin.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "bun:test"; +import { stripIndent } from "common-tags"; +import { functionRuntimePlugin } from "src/plugins/function-runtime-plugin"; +import { createBuilder } from "test/esbuild-test-utils"; + +const buildRuntime = createBuilder((input) => ({ + plugins: [functionRuntimePlugin({ getFile: () => input })], +})); + +it("should strip infra code", async () => { + const input = stripIndent` + import { FnConfig, handler } from "@notation/aws"; + + const num = await fetch("http://api.com/num").then((res) => res.json()); + + export const getNum = handler(() => num); + export const getDoubleNum = handler(() => num * 2); + + export const config: FnConfig = { + memory: 64, + timeout: 5, + environment: "node:16", + }; + `; + + const expected = stripIndent` + import { handler } from "@notation/aws"; + const num = await fetch("http://api.com/num").then((res) => res.json()); + export const getNum = handler(() => num); + export const getDoubleNum = handler(() => num * 2); + `; + + const output = await buildRuntime(input); + + expect(output).toContain(expected); +}); diff --git a/packages/esbuild-plugins/test/utils/read-config-exports.test.ts b/packages/esbuild-plugins/test/utils/read-config-exports.test.ts new file mode 100644 index 0000000..699141b --- /dev/null +++ b/packages/esbuild-plugins/test/utils/read-config-exports.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { readConfigExport } from "src/parsers/read-config-export"; + +test("Valid config object", () => { + const input = ` + export const config = { + key1: "value1", + key2: 123, + key3: true, + key4: false + }; + `; + expect(readConfigExport(input)).toBe( + '{ key1: "value1", key2: 123, key3: true, key4: false }', + ); +}); + +test("Config object with invalid value type", () => { + const input = ` + export const config = { + key1: [123], + }; + `; + expect(() => readConfigExport(input)).toThrow( + /Invalid value type for key 'key1'/, + ); +}); + +test("No config object", () => { + const input = `export const somethingElse = { key1: "value1" };`; + expect(() => readConfigExport(input)).toThrow( + /No named export 'config' found./, + ); +}); + +describe("invalid inputs", () => { + const invalidInputs = { + identifier: "export const config = identifier;", + callExpression: "export const config = identifier;", + string: "export const config = 123;", + number: "export const config = '123';", + array: "export const config = [];", + }; + for (const [invalidType, invalidStatement] of Object.entries(invalidInputs)) { + test(invalidType, () => { + expect(() => readConfigExport(invalidStatement)).toThrow( + /'config' is not an object literal./, + ); + }); + } +}); diff --git a/packages/esbuild-plugins/tsconfig.json b/packages/esbuild-plugins/tsconfig.json index 6b7962d..b667b19 100644 --- a/packages/esbuild-plugins/tsconfig.json +++ b/packages/esbuild-plugins/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "tsconfig/base.json" + "extends": "tsconfig/base.json", + "compilerOptions": { + "baseUrl": "." + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a069b86..f0b976f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,8 +44,10 @@ importers: '@types/common-tags': ^1.8.2 common-tags: ^1.8.2 esbuild: ^0.19.3 + typescript: ^5.2.2 dependencies: esbuild: 0.19.3 + typescript: 5.2.2 devDependencies: '@types/common-tags': 1.8.2 common-tags: 1.8.2