Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 2 additions & 83 deletions src/commands/optimize/index.ts
Original file line number Diff line number Diff line change
@@ -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']

Expand All @@ -25,6 +17,7 @@ const REQUEST_BODY_METHODS = [
'json',
'text',
'arrayBuffer',
'bytes',
'blob',
'formData',
'#cachedBody',
Expand Down Expand Up @@ -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'
43 changes: 43 additions & 0 deletions src/commands/optimize/remove-apis.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
99 changes: 99 additions & 0 deletions src/commands/optimize/remove-apis.ts
Original file line number Diff line number Diff line change
@@ -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'