From dae9c481364a71279b507d3f49c3c13fa7bd9809 Mon Sep 17 00:00:00 2001 From: Andy Jessop Date: Fri, 13 Sep 2024 15:20:29 +0200 Subject: [PATCH 1/5] feat: transform Request, Response, and WebSocket classes to interfaces with var declarations --- types/src/index.ts | 2 + types/src/transforms/class-to-interface.ts | 391 ++++++++++++++++++ .../transforms/class-to-interface.spec.ts | 76 ++++ 3 files changed, 469 insertions(+) create mode 100644 types/src/transforms/class-to-interface.ts create mode 100644 types/test/transforms/class-to-interface.spec.ts diff --git a/types/src/index.ts b/types/src/index.ts index 1e8cd08038d..e7997f260f2 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -15,6 +15,7 @@ import { createIteratorTransformer, createOverrideDefineTransformer, } from "./transforms"; +import { createClassToInterfaceTransformer } from "./transforms/class-to-interface"; const definitionsHeader = `/*! ***************************************************************************** Copyright (c) Cloudflare. All rights reserved. @@ -75,6 +76,7 @@ export function printDefinitions( // Run global scope transformer after overrides so members added in // overrides are extracted createGlobalScopeTransformer(checker), + createClassToInterfaceTransformer(["Request", "Response", "WebSocket"]), // TODO: enable this once we've figured out how not to expose internal modules // createInternalNamespaceTransformer(root, structureMap), createCommentsTransformer(commentData), diff --git a/types/src/transforms/class-to-interface.ts b/types/src/transforms/class-to-interface.ts new file mode 100644 index 00000000000..abda2d05aae --- /dev/null +++ b/types/src/transforms/class-to-interface.ts @@ -0,0 +1,391 @@ +import ts from "typescript"; + +/** + * Transforms an array of classes to an interface/variable pair, preserving the ability to construct the class + * and call static methods. + * + * @example + * + * class MyClass { + * constructor(str: string): MyClass; + * prop: T; + * method(): void {} + * static staticMethod(str?: string): void {} + * } + * + * // Becomes this: + * + * declare var MyClass: { + * prototype: MyClass; + * new (str: string): MyClass; + * staticMethod(str?: string): void; + * ; + * interface MyClass { + * prop: T; + * method(): U; + * } + * + * NB: + * 1. Generics are preserved and provided to the `new` method on the var declaration. + * 2. Static methods are added to the var declaration instead of the interface. + */ +export function createClassToInterfaceTransformer( + classNames: string[] +): ts.TransformerFactory { + return (context) => { + const visitor: ts.Visitor = (node) => { + if ( + ts.isClassDeclaration(node) && + node.name && + classNames.includes(node.name.text) + ) { + return transformClassToInterface(node, context); + } + return ts.visitEachChild(node, visitor, context); + }; + + return (sourceFile) => { + const transformedNodes = ts.visitNodes(sourceFile.statements, visitor); + const filteredNodes = transformedNodes.filter(ts.isStatement); + return context.factory.updateSourceFile(sourceFile, filteredNodes); + }; + }; +} + +/** + * Transforms a TypeScript class declaration into an interface and a variable declaration. + * Used where you want to separate the type definition (interface) + * from the runtime representation (variable declaration) of a class. + */ +function transformClassToInterface( + node: ts.ClassDeclaration, + context: ts.TransformationContext +): ts.Statement[] { + const interfaceDeclaration = createInterfaceDeclaration(node, context); + const varDeclaration = createVariableDeclaration(node, context); + return [varDeclaration, interfaceDeclaration]; +} + +/** + * Creates an interface declaration from a class declaration. + * Extracts class members and converts them into interface members, + * preserving access modifiers and type parameters. + */ +function createInterfaceDeclaration( + node: ts.ClassDeclaration, + context: ts.TransformationContext +): ts.InterfaceDeclaration { + const interfaceMembers = transformClassMembers(node.members, context, false); + return context.factory.createInterfaceDeclaration( + getAccessModifiers(ts.getModifiers(node)), + node.name!, + node.typeParameters, + node.heritageClauses, + interfaceMembers + ); +} + +/** + * Transforms class members into interface type elements. + * Filters and converts class elements into a format suitable for interfaces, + * optionally including static members. + */ +function transformClassMembers( + members: ts.NodeArray, + context: ts.TransformationContext, + includeStatic: boolean +): ts.TypeElement[] { + return members + .map((member) => + transformClassMemberToInterface(member, context, includeStatic) + ) + .filter((member): member is ts.TypeElement => member !== undefined); +} + +/** + * Transforms a single class member into an interface element. + * Handles different types of class elements, such as properties and methods, + * and applies access modifiers appropriately. + * + * @example + * // Given the following class declarations: + * + * myMethod(): void {} + * private myPrivateProperty: string; + * + * // The function will produce an interface declarations similar to: + * + * myMethod(): void; + * + * Note: Private members like `myPrivateProperty` are not included in the interface. + */ +function transformClassMemberToInterface( + member: ts.ClassElement, + context: ts.TransformationContext, + includeStatic: boolean +): ts.TypeElement | undefined { + const modifiers = ts.canHaveModifiers(member) + ? ts.getModifiers(member) + : undefined; + const isStatic = + modifiers?.some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword) ?? false; + + if (isStatic !== includeStatic) { + return undefined; + } + + const isPrivate = + modifiers?.some((mod) => mod.kind === ts.SyntaxKind.PrivateKeyword) ?? + false; + + if (isPrivate) { + return undefined; + } + + const accessModifiers = getAccessModifiers(modifiers); + + if (ts.isPropertyDeclaration(member)) { + return createPropertySignature(member, accessModifiers, context); + } else if (ts.isMethodDeclaration(member)) { + return createMethodSignature(member, accessModifiers, context); + } else if (ts.isGetAccessor(member)) { + return createGetAccessorSignature(member, accessModifiers, context); + } else if (ts.isSetAccessor(member) || ts.isConstructorDeclaration(member)) { + return undefined; + } + + console.warn(`Unhandled member type: ${ts.SyntaxKind[member.kind]}`); + return undefined; +} + +/** + * Creates a property signature for an interface from a class property declaration. + * Preserves access modifiers and optionality. + * + * @example + * // Given a TypeScript class property declaration: + * + * public optionalProp?: string; + * + * // The `createPropertySignature` function will produce an interface property signature: + * + * optionalProp?: string; + */ +function createPropertySignature( + member: ts.PropertyDeclaration, + modifiers: ts.Modifier[] | undefined, + context: ts.TransformationContext +): ts.PropertySignature { + return context.factory.createPropertySignature( + modifiers, + member.name, + member.questionToken, + member.type + ); +} + +/** + * Creates a method signature for an interface from a class method declaration. + * Handles method parameters and return types. + * + * @example + * // Given a TypeScript class method declaration: + * + * public doSomething(param: number): string { + * return param.toString(); + * } + * + * // The `createMethodSignature` function will produce an interface method signature: + * + * doSomething(param: number): string; + */ +function createMethodSignature( + member: ts.MethodDeclaration, + modifiers: ts.Modifier[] | undefined, + context: ts.TransformationContext +): ts.MethodSignature { + return context.factory.createMethodSignature( + modifiers, + member.name, + member.questionToken, + member.typeParameters, + member.parameters, + member.type + ); +} + +/** + * Creates a property signature for an interface from a class `get` accessor declaration. + * Used to represent getter methods as properties in interfaces. + * + * @example + * // Given a TypeScript class with a getter: + * + * get value(): number { + * return 42; + * } + * + * // The `createGetAccessorSignature` function will produce an interface property signature: + * + * value: number; + */ +function createGetAccessorSignature( + member: ts.GetAccessorDeclaration, + modifiers: ts.Modifier[] | undefined, + context: ts.TransformationContext +): ts.PropertySignature { + return context.factory.createPropertySignature( + modifiers, + member.name, + undefined, + member.type + ); +} + +/** + * Creates a variable declaration for a class, representing its runtime type. + * Declares a variable with the class name and its associated type. + * + * @example + * // Given a TypeScript class declaration: + * + * class Example { + * static staticMethod(): void {} + * constructor(public value: number) {} + * } + * + * // The `createVariableDeclaration` function will produce a variable declaration: + * + * declare var Example: { + * prototype: Example; + * new (value: number): Example; + * staticMethod(): void; + * }; + */ +function createVariableDeclaration( + node: ts.ClassDeclaration, + context: ts.TransformationContext +): ts.VariableStatement { + return context.factory.createVariableStatement( + [context.factory.createModifier(ts.SyntaxKind.DeclareKeyword)], + context.factory.createVariableDeclarationList( + [ + context.factory.createVariableDeclaration( + node.name!, + undefined, + createClassType(node, context) + ), + ], + ts.NodeFlags.None + ) + ); +} + +/** + * Creates a type literal node representing the static members and prototype of a class. + * Used to define the type structure of a class's static side. + * + * @example + * // Given a TypeScript class with static members: + * + * class Example { + * constructor(public value: number) {} + * static staticMethod(): void {} + * } + * + * // The `createClassType` function will produce a type literal node: + * + * { + * prototype: Example; + * new (value: number): Example; + * staticMethod(): void; + * } + */ +function createClassType( + node: ts.ClassDeclaration, + context: ts.TransformationContext +): ts.TypeLiteralNode { + const staticMembers = transformClassMembers(node.members, context, true); + return context.factory.createTypeLiteralNode([ + createPrototypeProperty(node, context), + createConstructSignature(node, context), + ...staticMembers, + ]); +} + +/** + * Creates a construct signature for a class, representing its constructor. + * Includes type parameters and parameter types in the signature. + * + * @example + * // Given a TypeScript class constructor: + * + * class Example { + * constructor(public value: T) {} + * } + * + * // The `createConstructSignature` function will produce a construct signature: + * + * new (value: T): Example; + */ +function createConstructSignature( + node: ts.ClassDeclaration, + context: ts.TransformationContext +): ts.ConstructSignatureDeclaration { + const constructorDeclaration = node.members.find(ts.isConstructorDeclaration); + const typeParameters = node.typeParameters; + + const returnType = context.factory.createTypeReferenceNode( + node.name!, + typeParameters?.map((param) => + context.factory.createTypeReferenceNode(param.name, undefined) + ) + ); + + return context.factory.createConstructSignature( + typeParameters, + constructorDeclaration?.parameters ?? [], + returnType + ); +} + +/** + * Creates a property signature for the prototype property of a class. + * Used to represent the prototype chain in the class type. + * + * @example + * // Given a TypeScript class: + * + * class Example {} + * + * // The `createPrototypeProperty` function will produce a property signature: + * + * prototype: Example; + */ +function createPrototypeProperty( + node: ts.ClassDeclaration, + context: ts.TransformationContext +): ts.PropertySignature { + return context.factory.createPropertySignature( + undefined, + "prototype", + undefined, + context.factory.createTypeReferenceNode(node.name!, undefined) + ); +} + +/** + * Filters and returns the access modifiers applicable to a class member. + * Extracts modifiers such as readonly, public, protected, and private. + */ +function getAccessModifiers( + modifiers: readonly ts.Modifier[] | undefined +): ts.Modifier[] | undefined { + return modifiers?.filter( + (mod) => + mod.kind === ts.SyntaxKind.ReadonlyKeyword || + mod.kind === ts.SyntaxKind.PublicKeyword || + mod.kind === ts.SyntaxKind.ProtectedKeyword || + mod.kind === ts.SyntaxKind.PrivateKeyword + ); +} diff --git a/types/test/transforms/class-to-interface.spec.ts b/types/test/transforms/class-to-interface.spec.ts new file mode 100644 index 00000000000..bde52d2522c --- /dev/null +++ b/types/test/transforms/class-to-interface.spec.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2022-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import assert from "assert"; +import { test } from "node:test"; +import path from "path"; +import ts from "typescript"; +import { printer } from "../../src/print"; +import { createMemoryProgram } from "../../src/program"; +import { createClassToInterfaceTransformer } from "../../src/transforms/class-to-interface"; + +test("createClassToInterfaceTransformer: transforms class to interface", () => { + const source = ` + /** + * MyClass + */ + class MyClass { + /* constructor */ + constructor(str: string): MyClass; + /* prop */ + prop: T; + /* method */ + method(): U {} + /* getter */ + get accessor(): number { return 42; } + /* static method */ + static staticMethod(str?: string): void {} + /* private method */ + private privateMethod() {} + } + `; + + const expectedOutput = ` + declare var MyClass: { + prototype: MyClass; + /* constructor */ + new (str: string): MyClass; + /* static method */ + staticMethod(str?: string): void; + }; + /** + * MyClass + */ + interface MyClass { + /* prop */ + prop: T; + /* method */ + method(): U; + /* getter */ + accessor: number; + } + `; + + const sourcePath = path.resolve(__dirname, "source.ts"); + const sources = new Map([[sourcePath, source]]); + const program = createMemoryProgram(sources); + const sourceFile = program.getSourceFile(sourcePath); + assert(sourceFile !== undefined); + + const result = ts.transform(sourceFile, [ + createClassToInterfaceTransformer(["MyClass"]), + ]); + assert.strictEqual(result.transformed.length, 1); + + const output = printer.printFile(result.transformed[0]); + assert.strictEqual( + normalizeWhitespace(output.trim()), + normalizeWhitespace(expectedOutput.trim()), + "The transformed output did not match the expected output" + ); +}); + +function normalizeWhitespace(str: string) { + return str.replace(/\s+/g, " ").trim(); +} From 46b0635183ca9cf6e5ebb481b18e3eb2787401da Mon Sep 17 00:00:00 2001 From: Andy Jessop Date: Fri, 13 Sep 2024 17:05:27 +0200 Subject: [PATCH 2/5] chore: add onmessage declaration --- types/src/index.ts | 2 ++ types/src/transforms/onmessage-declaration.ts | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 types/src/transforms/onmessage-declaration.ts diff --git a/types/src/index.ts b/types/src/index.ts index e7997f260f2..3de3b113841 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -16,6 +16,7 @@ import { createOverrideDefineTransformer, } from "./transforms"; import { createClassToInterfaceTransformer } from "./transforms/class-to-interface"; +import { addOnMessageDeclarationTransformer } from "./transforms/onmessage-declaration"; const definitionsHeader = `/*! ***************************************************************************** Copyright (c) Cloudflare. All rights reserved. @@ -80,6 +81,7 @@ export function printDefinitions( // TODO: enable this once we've figured out how not to expose internal modules // createInternalNamespaceTransformer(root, structureMap), createCommentsTransformer(commentData), + addOnMessageDeclarationTransformer(), ]); // TODO: enable this once we've figured out how not to expose internal modules diff --git a/types/src/transforms/onmessage-declaration.ts b/types/src/transforms/onmessage-declaration.ts new file mode 100644 index 00000000000..c909d8f8a1b --- /dev/null +++ b/types/src/transforms/onmessage-declaration.ts @@ -0,0 +1,31 @@ +import ts from "typescript"; + +export function addOnMessageDeclarationTransformer(): ts.TransformerFactory { + return (context) => { + return (sourceFile) => { + // Create the new variable declaration + const onMessageDeclaration = context.factory.createVariableStatement( + [context.factory.createModifier(ts.SyntaxKind.DeclareKeyword)], + context.factory.createVariableDeclarationList( + [ + context.factory.createVariableDeclaration( + "onmessage", + undefined, + context.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword) + ), + ], + ts.NodeFlags.None + ) + ); + + // Append the new declaration to the source file + const updatedStatements = ts.factory.createNodeArray([ + onMessageDeclaration, + ...sourceFile.statements, + ]); + + // Return the updated source file + return ts.factory.updateSourceFile(sourceFile, updatedStatements); + }; + }; +} From 0563824f7be0474fd0d58e80612d8807ee00ceb0 Mon Sep 17 00:00:00 2001 From: Andy Jessop Date: Fri, 13 Sep 2024 17:14:57 +0200 Subject: [PATCH 3/5] chore: fix test --- types/test/index.spec.ts | 1 + .../test/transforms/class-to-interface.spec.ts | 17 ----------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/types/test/index.spec.ts b/types/test/index.spec.ts index bdf635e1c05..4a3a30f1b3b 100644 --- a/types/test/index.spec.ts +++ b/types/test/index.spec.ts @@ -144,6 +144,7 @@ and limitations under the License. ***************************************************************************** */ /* eslint-disable */ // noinspection JSUnusedGlobalSymbols +declare var onmessage: never; /** * An event which takes place in the DOM. * diff --git a/types/test/transforms/class-to-interface.spec.ts b/types/test/transforms/class-to-interface.spec.ts index bde52d2522c..4e33e22f197 100644 --- a/types/test/transforms/class-to-interface.spec.ts +++ b/types/test/transforms/class-to-interface.spec.ts @@ -12,21 +12,12 @@ import { createClassToInterfaceTransformer } from "../../src/transforms/class-to test("createClassToInterfaceTransformer: transforms class to interface", () => { const source = ` - /** - * MyClass - */ class MyClass { - /* constructor */ constructor(str: string): MyClass; - /* prop */ prop: T; - /* method */ method(): U {} - /* getter */ get accessor(): number { return 42; } - /* static method */ static staticMethod(str?: string): void {} - /* private method */ private privateMethod() {} } `; @@ -34,20 +25,12 @@ test("createClassToInterfaceTransformer: transforms class to interface", () => { const expectedOutput = ` declare var MyClass: { prototype: MyClass; - /* constructor */ new (str: string): MyClass; - /* static method */ staticMethod(str?: string): void; }; - /** - * MyClass - */ interface MyClass { - /* prop */ prop: T; - /* method */ method(): U; - /* getter */ accessor: number; } `; From 9a1a34eb8ef200897a258fc517d6ed8830908daa Mon Sep 17 00:00:00 2001 From: Andy Jessop Date: Fri, 13 Sep 2024 17:46:02 +0200 Subject: [PATCH 4/5] chore: tidying up --- types/src/index.ts | 4 ++-- types/src/transforms/class-to-interface.ts | 2 +- types/src/transforms/onmessage-declaration.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/types/src/index.ts b/types/src/index.ts index 3de3b113841..d64d807e6cf 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -16,7 +16,7 @@ import { createOverrideDefineTransformer, } from "./transforms"; import { createClassToInterfaceTransformer } from "./transforms/class-to-interface"; -import { addOnMessageDeclarationTransformer } from "./transforms/onmessage-declaration"; +import { createAddOnMessageDeclarationTransformer } from "./transforms/onmessage-declaration"; const definitionsHeader = `/*! ***************************************************************************** Copyright (c) Cloudflare. All rights reserved. @@ -81,7 +81,7 @@ export function printDefinitions( // TODO: enable this once we've figured out how not to expose internal modules // createInternalNamespaceTransformer(root, structureMap), createCommentsTransformer(commentData), - addOnMessageDeclarationTransformer(), + createAddOnMessageDeclarationTransformer(), ]); // TODO: enable this once we've figured out how not to expose internal modules diff --git a/types/src/transforms/class-to-interface.ts b/types/src/transforms/class-to-interface.ts index abda2d05aae..77de2b48644 100644 --- a/types/src/transforms/class-to-interface.ts +++ b/types/src/transforms/class-to-interface.ts @@ -19,7 +19,7 @@ import ts from "typescript"; * prototype: MyClass; * new (str: string): MyClass; * staticMethod(str?: string): void; - * ; + * } * interface MyClass { * prop: T; * method(): U; diff --git a/types/src/transforms/onmessage-declaration.ts b/types/src/transforms/onmessage-declaration.ts index c909d8f8a1b..e6170578081 100644 --- a/types/src/transforms/onmessage-declaration.ts +++ b/types/src/transforms/onmessage-declaration.ts @@ -1,6 +1,6 @@ import ts from "typescript"; -export function addOnMessageDeclarationTransformer(): ts.TransformerFactory { +export function createAddOnMessageDeclarationTransformer(): ts.TransformerFactory { return (context) => { return (sourceFile) => { // Create the new variable declaration From cfd832b3b100c932e5e1de61120d913a8cb37335 Mon Sep 17 00:00:00 2001 From: Andy Jessop Date: Mon, 16 Sep 2024 16:18:24 +0200 Subject: [PATCH 5/5] chore: update wording --- types/src/transforms/onmessage-declaration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/src/transforms/onmessage-declaration.ts b/types/src/transforms/onmessage-declaration.ts index e6170578081..48732242940 100644 --- a/types/src/transforms/onmessage-declaration.ts +++ b/types/src/transforms/onmessage-declaration.ts @@ -18,7 +18,7 @@ export function createAddOnMessageDeclarationTransformer(): ts.TransformerFactor ) ); - // Append the new declaration to the source file + // Prepend the new declaration to the source file const updatedStatements = ts.factory.createNodeArray([ onMessageDeclaration, ...sourceFile.statements,