Type Aware な TS Minifier を作る
課題感
世の中の TypeScript は型情報を持ってるはずなのに、 mangling 時にその情報を使えていない。
その結果 private プロパティの minify ができない。これをなんとかしたい
想定実装
TS to TS で実装する。
- TypeScript Language Service を使って、解析対象をインメモリに読み込む
- 解析対象の export type と export class を舐めつつ、 最終出力に含まれるかの判定と、 minifyable な参照を洗い出す
- 各ファイルの identifier symbol をなめて定義元が minifyable なら置き換えていく
- 最後まで Apply されたものを他に渡す (ので表向き中間トランスフォームのように振る舞う。terser 用のメタデータを吐いてもいい)
雑に作るとめちゃくちゃ重いと思う。依存ゼロの小さなパッケージをより小さくできるか、という用途を想定。
あると嬉しいもの
- class private フィールドの mangling
- 外部に export されない class public method
- 外部に export されない object のフィールド
- 関数呼び出しの引数部分
- import したライブラリへのアクセッサ
export されない中間計算にだけ使うパターン
type T1 = {
foo: number // manglable
};
const t1: T1 = { foo: 1 };
グローバル or 外部から import した関数に投げ込んだパターンは mangling できない
type T1 = {
foo: number not // manglable
};
const t1: T1 = { foo: 1 };
console.log(t1);
間接的に型のインターフェースが export されるパターンは mangling できない
type T1 = {
foo: number // not manglable
};
const t1: T1 = { foo: 1 };
export { t1 };
export されるがプロパティ名は保持する必要がないパターン
type T1 = {
foo: number // manglable
};
const t1: T1 = { foo: 1 };
export const foo = t1.foo;
最初はバニラな typescript language service に拘ってたけど、便利関数が一式使われてる ts-morph を使ったほうがいい気がしてきた。
useInMemory でオンメモリで計算して、最終状態を吐く感じ
基本的にはエントリポイントで export されてるオブジェクトから再帰的に解析していけばよいはずだが...
src/index.ts
export { transform } from "./compiler";
// transform によって間接的に export されるので mangling されない
type TransformOptions = {
at: number;
flagA: boolean;
flagB: boolean;
}
// ここは露出しないキーワードだから保持する必要はない
type Cache = { [key: string]: TransformResult }
type TransformResult = { at: number, result: string; }
const _cache: Cache = {};
export function transform(opts: TransformOptions){
if (_cache[opts.at]) {
return _cache[Transform.at.toString()]
}
return _transform(opts);
}
function _transform(opts: TransformOptions) {
return {
at: opts.at,
result: "..."
}
}
これ実装するには、関数レベルキャッシュが必要で、他の実装はファイルレベルなので、これができない
ts-morph 使って簡単なリネーム、複数ファイルのリネームを実装してみた。
import { Project } from "ts-morph";
const matcher = /^[\r\n]?(\s+)/;
function trim(str: string) {
/*
Get the initial indentation
But ignore new line characters
*/
if(matcher.test(str)) {
/*
Replace the initial whitespace
globally and over multiple lines
*/
return str.replace(new RegExp("^" + str.match(matcher)?.[1], "gm"), "");
} else {
// Regex doesn't match so return the original string
return str;
}
};
if (import.meta.vitest) {
const { test, expect } = import.meta.vitest;
test("exp", () => {
const project = new Project({
useInMemoryFileSystem: true,
});
const file = project.createSourceFile("index.ts", "const a = 1;");
// console.log("Hello, world!", file);
expect(file.getFullText()).toBe("const a = 1;");
});
test("rename enum", () => {
const project = new Project({
useInMemoryFileSystem: true,
});
const source = project.createSourceFile("index.ts", `
enum MyEnum {
myMember,
}
const myVar = MyEnum.myMember;
`.trim());
source.getEnum("MyEnum")!.rename("NewEnum");
const text = source.getFullText();
expect(text).toContain("const myVar = NewEnum.myMember");
});
test("rename function", () => {
const project = new Project({
useInMemoryFileSystem: true,
});
const source = project.createSourceFile("index.ts", `
function myFunc() {
return 1;
}
const myVar = myFunc();
`);
source.getFunction("myFunc")!.rename("newFunc");
const text = source.getFullText();
expect(text).toContain("const myVar = newFunc()");
});
test("rename exported function", () => {
const project = new Project({
useInMemoryFileSystem: true,
});
const source = project.createSourceFile("index.ts", trim(`
export function exportedFunc() {
return myFunc();
}
function myFunc() {
return 1;
}
`));
let i = 0;
const renameMap = new Map<string, string>();
for (const func of source.getFunctions() ) {
if (!func.isExported()) {
const original = func.getName()!;
const newName = `_${i++}`;
func.rename(newName);
renameMap.set(original, newName);
}
// console.log(func.getName(), func.isExported());
}
expect(renameMap.size).toBe(1);
expect(renameMap.get("myFunc")).toBe("_0");
const text = source.getFullText();
expect(text).toContain(trim(`
export function exportedFunc() {
return _0();
}
function _0() {
return 1;
}
`));
});
test("rename exported function with multifile", () => {
const project = new Project({
useInMemoryFileSystem: true,
});
const source = project.createSourceFile("index.ts", trim(`
import { exportedFunc } from "./exports";
exportedFunc();
`));
const source2 = project.createSourceFile("exports.ts", trim(`
export function exportedFunc() {
return myFunc();
}
function myFunc() {
return 1;
}
`));
let i = 0;
for (const func of source2.getFunctions() ) {
if (func.isExported()) {
const newName = `${func.getName()!}$pub`;
func.rename(newName);
}
if (!func.isExported()) {
const newName = `_${i++}`;
func.rename(newName);
}
}
expect(
source.getFullText()
).toContain(trim(`
import { exportedFunc$pub } from "./exports";
exportedFunc$pub();
`));
expect(
source2.getFullText()
).toContain(trim(`
export function exportedFunc$pub() {
return _0();
}
function _0() {
return 1;
}
`));
});
}
enum member の rename
test("rename enum member", () => {
const project = new Project({
useInMemoryFileSystem: true,
});
const source = project.createSourceFile("index.ts", `
enum MyEnum {
myMember,
}
const myVar = MyEnum.myMember;
`.trim());
source.getEnum("MyEnum")!.getMember("myMember")!.rename("newMember");
// rename("NewEnum");
const text = source.getFullText();
expect(text).toContain("const myVar = MyEnum.newMember");
});
ここで本当に効果があるか疑問に思ったので、短くなるシナリオを想定してテストしてみる。
class MyClass {
public publicMethod() {
return this;
}
}
const myClass = new MyClass();
myClass.publicMethod().publicMethod().publicMethod().publicMethod().publicMethod();
terser かけたものと、さらに gzip したものを用意
-rw-r--r-- 1 kotaro.chikuba staff 158B May 10 20:52 worst-case.min.ts
-rw-r--r-- 1 kotaro.chikuba staff 104B May 10 20:52 worst-case.min.ts.gz
-rw-r--r-- 1 kotaro.chikuba staff 186B May 10 20:51 worst-case.ts
これを想定しているコンパイラで publicMethod をminify できると仮定して、手作業で圧縮する。
class C {
a() {
return this;
}
}
const v = new C();
v.a().a().a().a().a();
結果
-rw-r--r-- 1 kotaro.chikuba staff 61B May 10 20:56 hand-mangle-worst.min.ts
-rw-r--r-- 1 kotaro.chikuba staff 90B May 10 20:56 hand-mangle-worst.min.ts.gz
-rw-r--r-- 1 kotaro.chikuba staff 89B May 10 20:56 hand-mangle-worst.ts
正直これぐらいのサイズだと gzip のメタデータの方が膨らみそう。
実装案の一つ、実装と無関係に型定義を自分で書いて、それだけを public とする
export type FooData = { foo: number }
export class Foo {
foo(): FooData;
}
実装は簡単で、このファイル内の全部の identifier 拾ってきて、それを terser で reserved なものとして列挙して mangling する。false positive あるけどたぶん許容範囲
サブタイプどうなる?
function f(arg: {aaa: number}) {
return arg;
}
const t = { aaa: 1};
const x = f(t);
変数の推論結果が一致しているから引数に使用可能で共変
この aaa はどう扱うべきか。
const t: typeof rhs = rhs
としてしまう?
コールバックどうなる?
シンボルを上から解析していけば実装できると思っていたが、コールバックで異なるファイルで定義されたものが飛び出てきた時に再解釈する必要がありそう
import {onGet} from "./xxx";
onGet((val) => {
val.xxxxxxxxxxxxxxxxx;
})
関数引数を定義するたびに引数に対してスコープの再解釈が必要
トップダウン解析とボトムアップ解析がある
トップダウン解析
入力するファイルを指定して、そこから export されたものを再帰的に解析していく
ボトムアップ解析
ファイルごとにすべての関数を解析する
とりあえず実装するもの
- 関数の引数の推論
- ファイル単位のスコープの列挙
- グラフの設計。とくに参照型にどういう定義を含めるか。名前を維持するか。エクスポートが伝播するか。
とりあえず、関数単位の dependency cruiser を作ることになるのはわかった。
メモ。
- 似たような関数を見つけるツール https://github.com/brentlintner/synt
- TypeScript をロスレスで圧縮するツール。 deno で使われてる。 https://github.com/denoland/eszip
- 依存解析ツール https://github.com/antoine-coulon/skott
ts-morph の依存解析に便利な関数
const source$index = project.createSourceFile(
"index.ts",
trim(`
import { sub } from "./sub";
export { sub };
function internal1(): number {}
`),
);
const source$sub = project.createSourceFile(
"sub.ts",
trim(`
export function sub(): number {
return 1;
}
function internal2(): number {}
`),
);
expect(
source$sub.getReferencingLiteralsInOtherSourceFiles().map((node) =>
node.getText()
),
).toEqual([
'"./sub"',
]);
expect(
source$sub.getReferencingNodesInOtherSourceFiles().map((node) =>
node.getText()
),
).toEqual([
'import { sub } from "./sub";',
]);
expect(
source$index.getNodesReferencingOtherSourceFiles().map((node) =>
node.getText()
),
).toEqual([
'import { sub } from "./sub";',
]);
expect(source$sub.getReferencedSourceFiles().length).toEqual(0);
expect(source$index.getReferencedSourceFiles().length).toEqual(1);
ts-morph でグローバル変数を列挙
const globalVars = source
.getSymbolsInScope(SymbolFlags.Variable)
.filter((x) =>
x.getDeclarations().some((x) => x.getSourceFile() !== source)
);
treeshake 判定機 https://github.com/Rich-Harris/agadoo
型定義を一つにまとめることができる
ts-morph も内部的に似たようなことをしている
型をまとめる過程で、Private なシンボルを発見できるか?
一つの types.d.ts にまとめてしまって、それを静的解析したものを解析対象にすると便利な気がしたけど、外部への副作用判定ができない?
探したら色々あった
外部 export だけではなく最終的なIOを列挙する必要もある。
例えば fetch()
関数に投げ込まれる body は mangling できない。
これ JSON.stringify の型を書き換えられると嬉しい気がする。
type Serialized<T> = string & { _: T }
function stringify<T>(json: T): Serialized<T>
function parse<infer T>(Serialized<T>): T
一番簡単な実装パターンとして、 rollup-plugin-dts を吐いてそのキーワードを terser にヒントとして渡す、というのを考えた。 false-positive はあるが、十分な性能が出ると考える。
実験してみた結果、 rollup-plugin-dts はAPIとして表に露出しないものを間引いてくれるので(自分はこれを作りたかったはずなのでコードを読むべき)、これをパースするだけで mangle-properties に渡すだけの十分なキーが作れそう。
fetch 等の副作用については明示的に指定することで mangling を抑制できそうだが、これは後でやる。
export { sub } from "./sub";
type Internal = {
result: number;
};
type Output = {
result: number;
};
export function sub(): Output {
const x = new X();
return { result: x.publicMethod().result };
}
class X {
public publicMethod(): Output {
return { result: 1 };
}
private privateMethod(): Output {
return { result: 1 };
}
}
{
"compilerOptions": {
"outDir": "test-dts-lib",
"declaration": true,
"emitDeclarationOnly": true,
// ry
}
}
install は略
import dts from "rollup-plugin-dts";
const config = [
// …
{
input: "./test-dts-lib/index.d.ts",
output: [{ file: "test-dts/bundle.d.ts", format: "es" }],
plugins: [dts()],
},
];
export default config;
実行
$ pnpm tsc -p tsconfig.dts.json
$ pnpm rollup -c rollup.config.test-dts.js
出力結果
type Output = {
result: number;
};
declare function sub(): Output;
export { sub };
達成できたこと
sub 関数の中身で X を呼んでいるが、結果を使ってるだけなので型には残らない。
推論してる?
試しに sub 関数の Output ReturnType を外す。
declare function sub(): {
result: number;
};
export { sub };
推論結果になっててまだ大丈夫
部分型
意地悪してみる。既存の型の一部だけを使う。
type Internal = {
result: number;
};
type Output = {
result: {
value: number;
};
};
export function sub() {
const x = new X();
return x.publicMethod().result;
}
class X {
public publicMethod(): Output {
return { result: { value: 1 } };
}
}
結果
declare function sub(): {
value: number;
};
export { sub };
Output["result"]
が推論結果に畳み込まれてる。これは ts-morph でもみた getApparentType で型引数含めて展開されてるやつかな
rollup-plugin-dts を読む
TS を常に dts モードで読み込む
なんかすごい気合transform を感じる。おそらく rollup の bundle コンテキストの中で typescript lanugage service (project) を起動して、bundle context が ModuleGraph 構築する時に project api を使って色々やってそうに見える。
気合があるときに読む。
実際、やりたいことは実現できてるので、ブラックボックスとして扱って構わない。
terser が副作用に関与したものをちゃんとトレースしてるかちゃんと確認する
// ...
type FetchRequestBodyType = {
keepMe: string;
};
function createFetchRequestBodyType(): FetchRequestBodyType {
return { keepMe: "xxx" } as FetchRequestBodyType;
}
export function effect(str: string) {
const newBody = createFetchRequestBodyType();
return fetch("https://example.com", {
method: "POST",
body: JSON.stringify(newBody),
});
}
bundle された dts はこうなって型が潰れている
declare function effect(str: string): Promise<Response>;
ビルド後の createFetchBodyType()
相当の部分
function t(e){const t={keepMe:"xxx"}
ちゃんと残っている
rollup-plugin-dts の作者による、どう public interface を切り出しているかの解説
terser はやっぱり fetch の中身を守ってくれない
まだ心配だから lib.dom.d.ts 等をパースして自前の予約語辞書を作り、 terserの予約語辞書と比較している
テストコードの形式をとっているが特に意味はない。
import { collectProperties } from "./analyzer.mjs";
import fs from "fs/promises";
import path from "path";
import { expect, test } from "vitest";
import ts from "typescript";
// @ts-ignore
import { domprops } from "../assets/domprops.js";
// const __dirname = path.dirname(new URL(import.meta.url).pathname);
test("check diff", async () => {
// const resolved = import.meta.resolve?.("typescript");
const resolved = require.resolve("typescript");
const tsLibDir = path.dirname(resolved);
const filepaths = await fs.readdir(tsLibDir);
const dtsPaths = filepaths
.filter((t) => t.endsWith(".d.ts"))
.map((filepath) => path.join(tsLibDir, filepath));
const reserved = new Set<string>();
for (const file of dtsPaths) {
const code = await fs.readFile(file, "utf-8");
const source = ts.createSourceFile(
"lib.dom.d.ts",
code,
ts.ScriptTarget.ES2019,
true,
);
const result = collectProperties(source);
for (const prop of result.reserved) {
reserved.add(prop);
}
}
const dompropsSet = new Set<string>(domprops);
// before
console.log("reserved.size", reserved.size);
console.log("dompropsSet.size", dompropsSet.size);
// merged
for (const prop of dompropsSet) {
reserved.add(prop);
}
console.log("reserved.size", reserved.size);
// for (const prop of dompropsSet) {
// // dompropsSet.delete(prop);
// reserved.delete(prop);
// }
// console.log("reserved.size", reserved.size, [...reserved].slice(0, 100));
// for (const prop of reserved) {
// dompropsSet.delete(prop);
// }
// console.log(
// "[unique] dompropsSet",
// dompropsSet.size,
// [...dompropsSet].slice(0, 30),
// );
// after unique
});
結果
reserved.size 8438
dompropsSet.size 7786
reserved.size 12342
個別にユニークな中身を見てみると、domprops 側にはベンダープレフィックスが多く、typescript/lib/*.d.ts
のはたぶん最新のAPIが多い?
よくみたらTS本体が混入してしまっていた。このへんは省こう。
stdout | src/env.test.mts > check diff
resereved- /Users/kotaro.chikuba/ghq/github.com/mizchi/optools/node_modules/.pnpm/typescript@5.0.4/node_modules/typescript/lib/tsserverlibrary.d.ts 3745
stdout | src/env.test.mts > check diff
resereved- /Users/kotaro.chikuba/ghq/github.com/mizchi/optools/node_modules/.pnpm/typescript@5.0.4/node_modules/typescript/lib/typescript.d.ts 3051
いくつかの水準で予約語辞書をビルドしておいて、後でビルトインを選択できるようにしておこう。
- es
- dom
- worker
- node
- cloudflare-workers
- react
- jest
- mocha
- vite(st)
それとは別に --ambient [...paths] の指定で任意の dts ファイルを食えるようにしておく。
ここまで複雑化すると、config ファイル食えるようにする必要がありそう。
optools.config.ts とかでいいかな。
ビルド時に除外する external の実装
ビルド時にアセットに含まない external の実装パターンで色々勘違いしてたことがあったのに気づいた。
import { foo } from "external-lib";
export function X() {
foo();
}
これは external-lib を一緒にビルドしない場合、 ここに渡す引数もまたライブラリの界をまたぐ副作用として扱わないといけない。
なので、これは外に対して exports する型としてトップレベルで宣言する必要がある。それを src/index.ts として記述するのは奇妙、というか意味的に破綻するので、ファイルを分けたほうが良さそう。
export * from "external-lib"
export * from "./index"
ついでに副作用をもつ内部関数を全部クロールしてしまっていい気がする。これを定義しておいて Eff<Operation, ReturnType>
その関数を全部ここに集約する。
export * from "external-lib"
export * from "./index";
export { request } from "./requester";
とすると src/effects.ts は自動生成でもいいかもしれない。検証してみる。
rollup-plugin-dts に手を加えることで ↑ の型定義展開を一緒に自動化できる気がする。
呼び出し側の
// x.ts
import {a, b} from "lib";
// y.ts
import {c} from "lib";
は、 src/effects.ts の
export {a,b,c} from "lib";
とできる。
Eff 型を export した場合、それをルートで import するのも同様。
そういう目的で rollup-plugin-dts のコードを読んでみよう。
https://github.com/Swatinem/rollup-plugin-dts/blob/master/docs/how-it-works.md の抜粋と翻訳
このプロジェクトは、rollupの内部実装を非常に興味深い方法で悪用します。
rollupは出力ファイルを生成するために文字列操作を使用します。
rollupに入力ファイルの一部を_keep_および_remove_するように指示することができます。
私たちが行うのは、Typescriptのコードを_virtual AST_に変換することです。
それ自体は本当に奇妙なコードですが、rollupに私たちが望むことをさせることができます。
各エクスポート(class
, function
, interface
, type
)に対して、次のような宣言を作成します。
rollupのために偽の FunctionDeclaration
を作成します。
ここで、この FunctionDeclaration
に特定のアノテーションを付けることが重要です。startとend
をつけることです。
すると、ロールアップは start
と end
の間のすべてのバイトを削除します。もしこの宣言が参照されていないとわかったら、Rollupはそのバイトが実際に何であるかを調べることなく、start
とend
の間のすべてのバイトを削除します。
function foo() {}
export function bar() {}
サイドエフェクトの作成
Rollupは実際に関数の副作用を分析し、副作用のない関数を喜んで削除します。
副作用のない関数は、他のコードで参照されていても、喜んで削除します。
rollupが少なくとも関数をバンドルに入れることを検討するためには、副作用を導入する必要があります、
その関数に副作用を導入する必要があります。どうすればいいのでしょうか?
答えは、rollupが内部を見ることができないようなコードを生成することです。例えば
を呼び出すことで、参照されない識別子を生成することができます。その識別子は、潜在的にwindow
に存在する可能性があります。
その識別子はwindow
に存在する可能性がありますが、rollupはそのことを知りません。だから、そのコードには触れない。
最終的に私は、異なる宣言の間に参照を作成することにしました。関数の引数の既定値として作成することにしました。そうすれば、rollupがセミコロンを挿入してTypeScriptのコードを混乱させることはない。が挿入され、TypeScriptのコードを混乱させることがない。
繰り返しになりますが、すべての Identifier
は、正しい start
と end
でアノテーションされています。
マーカーを付けています。そのため、もしrollupが名前を変更することになったとしても、コードの正しい部分に触れることになります。
また、関数名自体も識別子リストの一部です、なぜなら、関数名の前に識別子があるかもしれないからです。パラメータや削除したいものなどです。
function foo(_0 = foo) {}
function bar(_0 = bar, _1 = foo) {}
function baz(_0 = baz) {}
export function foobar(_0 = foobar, _1 = bar, _2 = baz) {}
ネストしたコードを削除する
先ほどの例を踏まえて、関数の引数のリストが
のデフォルトのリストと、トップレベルのコードの削除について学んだことを使って、入れ子のコードを削除することができます。
入れ子になっているコードを削除するためにマークします。
今回は、arrow関数を作成し、その中に死んだコードを入れてみます。この例のように
この例では、rollupがそのコードを削除します。この場合も、アノテーションとして
startと
end`のマーカーを付けて完了です。
function foo(_0 = foo, _1 = () => {removeme}) {}.
export function bar(_0 = bar, _1 = foo) {}.
感想
hacky 過ぎてあんまりこれをベースにしたくはない気がしてきた。。。
事前に都合よく加工するのはいいアイデアかもしれない。
入力されたファイルに対して、すべての関数実装に対して別ファイルに切り出してしまうのもありか。
カスタムな diagnostics plugin のプロポーサル。これがあれば Eff 型の副作用検知がスムーズに実装できる
// メモ
const checker = program.getTypeChecker();
const mods = checker.getAmbintModules();
で ambientModules が取れる
自分で rollup-pulgin-dts にissueを建てたが、自分で close した。nest した module をうまく解決できる気がしない。
やはり external については自分で解決しようと思う。指定したファイルを全部舐めた甘めの辞書を、 node-external と react-external で作る。偽陽性はしゃーない。
一応 node 標準ライブラリの型が欲しかった理由を書いておくと、 cloudflare workers の node(js)_compat モードを使う場合、ビルドして配布できず external に指定する必要があるため。deno の node 互換モードも同様。
TypeScript Compiler Service では files: string[] を要求され、実際の解析対象になりうる src/ 配下をまるっと取得する方法がなかったりする。
TypeScript で現在の tsconfig.json を元に解析対象のファイル一覧を取得する方法が必要だったので、調べた。
compiler host を生成し、 ts.createWatchProgram を実行することで、再帰的にファイルが読み込まれる。この実装では close してるが、おそらく普通の使用用途なら不要だと思う。
async function getCurrentSourceFiles(): Promise<ts.SourceFile[]> {
const configPath = ts.findConfigFile(
"./",
ts.sys.fileExists,
"tsconfig.json",
);
const host = ts.createWatchCompilerHost(
configPath!,
undefined,
ts.sys,
);
const origPostProgramCreate = host.afterProgramCreate;
return await new Promise<ts.SourceFile[]>((resolve) => {
host.afterProgramCreate = (program) => {
origPostProgramCreate!(program);
resolve(program.getSourceFiles() as ts.SourceFile[]);
watch.close();
};
const watch = ts.createWatchProgram(host);
});
}
node 用の辞書ができたおかげで、これがちゃんとコンパイルできた上で実行できた
import { createSourceFile, ScriptTarget } from "typescript";
console.log(
createSourceFile(
"input.ts",
`export const x = 1;`,
ScriptTarget.Latest,
true,
),
);
ts自体は公開インターフェースに含まないので、内部アクセッサがminifyされて見えるが、一緒にビルドすぐ限りは問題ない。一緒に mangle されるので
-rw-r--r-- 1 kotaro.chikuba staff 2.1M Jun 6 22:18 _bundle.opt.cjs
-rw-r--r-- 1 kotaro.chikuba staff 699K Jun 6 22:18 _bundle.opt.js.gz
-rw-r--r-- 1 kotaro.chikuba staff 2.8M Jun 6 22:18 _bundle.raw.cjs
-rw-r--r-- 1 kotaro.chikuba staff 804K Jun 6 22:18 _bundle.raw.js.gz