diff --git a/src/commands/optimize/index.ts b/src/commands/optimize/index.ts index 27cf461..8a5ff97 100644 --- a/src/commands/optimize/index.ts +++ b/src/commands/optimize/index.ts @@ -1,21 +1,13 @@ -import { parse } from '@babel/parser' -import type { - ClassMethod, - ClassProperty, - ClassPrivateProperty, - Identifier, - PrivateName, -} from '@babel/types' import type { Command } from 'commander' import * as esbuild from 'esbuild' import type { Hono } from 'hono' import { METHOD_NAME_ALL } from 'hono/router' import { buildInitParams, serializeInitParams } from 'hono/router/reg-exp-router' -import MagicString from 'magic-string' import { execFile } from 'node:child_process' import { existsSync, realpathSync, statSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { buildAndImportApp } from '../../utils/build.js' +import { removeApis } from './remove-apis.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] @@ -25,6 +17,7 @@ const REQUEST_BODY_METHODS = [ 'json', 'text', 'arrayBuffer', + 'bytes', 'blob', 'formData', '#cachedBody', @@ -370,77 +363,3 @@ export class Hono extends HonoBase { } ) } - -type NodeWithRange = { start: number | null | undefined; end: number | null | undefined } -type ClassElementNode = (ClassMethod | ClassProperty | ClassPrivateProperty) & NodeWithRange - -const removeApis = (contents: string, className: string, methods: string[]): string => { - const ast = parse(contents, { - sourceType: 'module', - plugins: [ - 'classPrivateProperties', - 'classPrivateMethods', - 'privateIn', - 'importMeta', - 'topLevelAwait', - ], - }) - - const magic = new MagicString(contents) - let modified = false - - for (const statement of ast.program.body as ((typeof ast.program.body)[number] & - NodeWithRange)[]) { - if (statement.type !== 'VariableDeclaration') { - continue - } - - for (const declaration of statement.declarations) { - if ( - declaration.id.type !== 'Identifier' || - declaration.id.name !== className || - !declaration.init || - declaration.init.type !== 'ClassExpression' - ) { - continue - } - - for (const member of declaration.init.body.body as ClassElementNode[]) { - if (!shouldRemoveClassMember(member, methods)) { - continue - } - const start = member.start ?? 0 - const end = member.end ?? start - magic.remove(start, end) - modified = true - } - } - } - - return modified ? magic.toString() : contents -} - -const shouldRemoveClassMember = (member: ClassElementNode, methods: string[]): boolean => { - if ( - (member.type === 'ClassMethod' || member.type === 'ClassProperty') && - isIdentifier(member.key) && - methods.includes(member.key.name) - ) { - return true - } - - if ( - member.type === 'ClassPrivateProperty' && - isPrivateIdentifier(member.key) && - methods.includes(`#${member.key.id.name}`) - ) { - return true - } - - return false -} - -const isIdentifier = (key: ClassMethod['key']): key is Identifier => key.type === 'Identifier' - -const isPrivateIdentifier = (key: ClassPrivateProperty['key']): key is PrivateName => - key.type === 'PrivateName' diff --git a/src/commands/optimize/remove-apis.test.ts b/src/commands/optimize/remove-apis.test.ts new file mode 100644 index 0000000..7f1e373 --- /dev/null +++ b/src/commands/optimize/remove-apis.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { removeApis } from './remove-apis' + +const source = `var HonoRequest = class { + routeIndex = 0; + #cachedBody = (key) => { + return key; + }; + json() { + return this.#cachedBody("json"); + } + text() { + return this.#cachedBody("text"); + } + param(key) { + return key; + } +}; +` + +describe('removeApis', () => { + it('should remove the listed members', () => { + const result = removeApis(source, 'HonoRequest', ['json', 'text', '#cachedBody']) + expect(result).not.toContain('#cachedBody') + expect(result).not.toContain('json()') + expect(result).not.toContain('text()') + expect(result).toContain('param(key)') + }) + + it('should skip removal if a removed private member is still referenced', () => { + // `text()` is not listed, like a method added in a newer hono + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = removeApis(source, 'HonoRequest', ['json', '#cachedBody']) + expect(result).toBe(source) + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('should return contents as-is if the class does not match', () => { + const result = removeApis(source, 'Context', ['json', '#cachedBody']) + expect(result).toBe(source) + }) +}) diff --git a/src/commands/optimize/remove-apis.ts b/src/commands/optimize/remove-apis.ts new file mode 100644 index 0000000..e8366e5 --- /dev/null +++ b/src/commands/optimize/remove-apis.ts @@ -0,0 +1,99 @@ +import { parse } from '@babel/parser' +import type { + ClassMethod, + ClassProperty, + ClassPrivateProperty, + Identifier, + PrivateName, +} from '@babel/types' +import MagicString from 'magic-string' + +type NodeWithRange = { start: number | null | undefined; end: number | null | undefined } +type ClassElementNode = (ClassMethod | ClassProperty | ClassPrivateProperty) & NodeWithRange + +export const removeApis = (contents: string, className: string, methods: string[]): string => { + const ast = parse(contents, { + sourceType: 'module', + plugins: [ + 'classPrivateProperties', + 'classPrivateMethods', + 'privateIn', + 'importMeta', + 'topLevelAwait', + ], + }) + + const magic = new MagicString(contents) + let modified = false + + for (const statement of ast.program.body as ((typeof ast.program.body)[number] & + NodeWithRange)[]) { + if (statement.type !== 'VariableDeclaration') { + continue + } + + for (const declaration of statement.declarations) { + if ( + declaration.id.type !== 'Identifier' || + declaration.id.name !== className || + !declaration.init || + declaration.init.type !== 'ClassExpression' + ) { + continue + } + + for (const member of declaration.init.body.body as ClassElementNode[]) { + if (!shouldRemoveClassMember(member, methods)) { + continue + } + const start = member.start ?? 0 + const end = member.end ?? start + magic.remove(start, end) + modified = true + } + } + } + + if (!modified) { + return contents + } + + const result = magic.toString() + + // A remaining member may still reference a removed private member, + // e.g. a method added in a newer hono version. Removing it would + // generate broken code, so skip the removal. + for (const method of methods) { + if (method.startsWith('#') && new RegExp(`${method}\\b`).test(result)) { + console.warn(`Skipped API removal for ${className}: ${method} is still referenced`) + return contents + } + } + + return result +} + +const shouldRemoveClassMember = (member: ClassElementNode, methods: string[]): boolean => { + if ( + (member.type === 'ClassMethod' || member.type === 'ClassProperty') && + isIdentifier(member.key) && + methods.includes(member.key.name) + ) { + return true + } + + if ( + member.type === 'ClassPrivateProperty' && + isPrivateIdentifier(member.key) && + methods.includes(`#${member.key.id.name}`) + ) { + return true + } + + return false +} + +const isIdentifier = (key: ClassMethod['key']): key is Identifier => key.type === 'Identifier' + +const isPrivateIdentifier = (key: ClassPrivateProperty['key']): key is PrivateName => + key.type === 'PrivateName'