Open42

Type Aware な TS Minifier を作る

mizchimizchi

課題感

世の中の 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 したライブラリへのアクセッサ
mizchimizchi

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;
mizchimizchi

最初はバニラな typescript language service に拘ってたけど、便利関数が一式使われてる ts-morph を使ったほうがいい気がしてきた。

useInMemory でオンメモリで計算して、最終状態を吐く感じ

mizchimizchi

基本的にはエントリポイントで 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: "..."
  }
}

これ実装するには、関数レベルキャッシュが必要で、他の実装はファイルレベルなので、これができない

mizchimizchi

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;
    }
    `));
  });

}
mizchimizchi

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");
  });
mizchimizchi

ここで本当に効果があるか疑問に思ったので、短くなるシナリオを想定してテストしてみる。

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 のメタデータの方が膨らみそう。

mizchimizchi

実装案の一つ、実装と無関係に型定義を自分で書いて、それだけを public とする

export type FooData = { foo: number }
export class Foo {
  foo(): FooData;
}

実装は簡単で、このファイル内の全部の identifier 拾ってきて、それを terser で reserved なものとして列挙して mangling する。false positive あるけどたぶん許容範囲

mizchimizchi

サブタイプどうなる?

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;
})

関数引数を定義するたびに引数に対してスコープの再解釈が必要

mizchimizchi

トップダウン解析とボトムアップ解析がある

トップダウン解析

入力するファイルを指定して、そこから export されたものを再帰的に解析していく

ボトムアップ解析

ファイルごとにすべての関数を解析する

mizchimizchi

とりあえず実装するもの

  • 関数の引数の推論
  • ファイル単位のスコープの列挙
  • グラフの設計。とくに参照型にどういう定義を含めるか。名前を維持するか。エクスポートが伝播するか。

とりあえず、関数単位の dependency cruiser を作ることになるのはわかった。

mizchimizchi

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);

mizchimizchi

ts-morph でグローバル変数を列挙

    const globalVars = source
      .getSymbolsInScope(SymbolFlags.Variable)
      .filter((x) =>
        x.getDeclarations().some((x) => x.getSourceFile() !== source)
      );
mizchimizchi

型定義を一つにまとめることができる

https://api-extractor.com/

ts-morph も内部的に似たようなことをしている

型をまとめる過程で、Private なシンボルを発見できるか?
https://api-extractor.com/pages/setup/configure_rollup/

一つの types.d.ts にまとめてしまって、それを静的解析したものを解析対象にすると便利な気がしたけど、外部への副作用判定ができない?

探したら色々あった

https://www.npmjs.com/package/rollup-plugin-dts

https://github.com/timocov/dts-bundle-generator

mizchimizchi

外部 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
mizchimizchi

一番簡単な実装パターンとして、 rollup-plugin-dts を吐いてそのキーワードを terser にヒントとして渡す、というのを考えた。 false-positive はあるが、十分な性能が出ると考える。

実験してみた結果、 rollup-plugin-dts はAPIとして表に露出しないものを間引いてくれるので(自分はこれを作りたかったはずなのでコードを読むべき)、これをパースするだけで mangle-properties に渡すだけの十分なキーが作れそう。

fetch 等の副作用については明示的に指定することで mangling を抑制できそうだが、これは後でやる。

test-dts/index.ts
export { sub } from "./sub";
test-dts/sub.ts
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 };
  }
}
tsconfig.dts.json
{
  "compilerOptions": {
    "outDir": "test-dts-lib",
    "declaration": true,
    "emitDeclarationOnly": true,
    // ry
  }
}

install は略

rollup.config.dts.js
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

出力結果

test-dts/bundle.d.ts
type Output = {
    result: number;
};
declare function sub(): Output;

export { sub };

達成できたこと

sub 関数の中身で X を呼んでいるが、結果を使ってるだけなので型には残らない。

推論してる?

試しに sub 関数の Output ReturnType を外す。

test-dts/bundle.d.ts
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 で型引数含めて展開されてるやつかな

mizchimizchi

rollup-plugin-dts を読む

TS を常に dts モードで読み込む

https://github.com/Swatinem/rollup-plugin-dts/blob/master/src/index.ts#L26-L62

なんかすごい気合transform を感じる。おそらく rollup の bundle コンテキストの中で typescript lanugage service (project) を起動して、bundle context が ModuleGraph 構築する時に project api を使って色々やってそうに見える。

https://github.com/Swatinem/rollup-plugin-dts/blob/master/src/transform/index.ts#L76-L92

気合があるときに読む。
実際、やりたいことは実現できてるので、ブラックボックスとして扱って構わない。

mizchimizchi

terser が副作用に関与したものをちゃんとトレースしてるかちゃんと確認する

test-dts/sub.ts
// ...
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"}

ちゃんと残っている

mizchimizchi

まだ心配だから 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が多い?

mizchimizchi

よくみたら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
mizchimizchi

いくつかの水準で予約語辞書をビルドしておいて、後でビルトインを選択できるようにしておこう。

  • es
  • dom
  • worker
  • node
  • cloudflare-workers
  • react
  • jest
  • mocha
  • vite(st)

それとは別に --ambient [...paths] の指定で任意の dts ファイルを食えるようにしておく。

mizchimizchi

ここまで複雑化すると、config ファイル食えるようにする必要がありそう。
optools.config.ts とかでいいかな。

mizchimizchi

ビルド時に除外する external の実装

ビルド時にアセットに含まない external の実装パターンで色々勘違いしてたことがあったのに気づいた。

import { foo } from "external-lib";

export function X() {
  foo();
}

これは external-lib を一緒にビルドしない場合、 ここに渡す引数もまたライブラリの界をまたぐ副作用として扱わないといけない。

なので、これは外に対して exports する型としてトップレベルで宣言する必要がある。それを src/index.ts として記述するのは奇妙、というか意味的に破綻するので、ファイルを分けたほうが良さそう。

src/effects.ts
export * from "external-lib"
export * from "./index"

ついでに副作用をもつ内部関数を全部クロールしてしまっていい気がする。これを定義しておいて Eff<Operation, ReturnType> その関数を全部ここに集約する。

src/effects.ts
export * from "external-lib"
export * from "./index";
export { request } from "./requester";

とすると src/effects.ts は自動生成でもいいかもしれない。検証してみる。

mizchimizchi

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 のコードを読んでみよう。

mizchimizchi

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をつけることです。
すると、ロールアップは startend の間のすべてのバイトを削除します。もしこの宣言が参照されていないとわかったら、Rollupはそのバイトが実際に何であるかを調べることなく、startendの間のすべてのバイトを削除します。

function foo() {}
export function bar() {}

https://rollupjs.org/repl/?version=3.10.0&shareable=JTdCJTIyZXhhbXBsZSUyMiUzQW51bGwlMkMlMjJtb2R1bGVzJTIyJTNBJTVCJTdCJTIybmFtZSUyMiUzQSUyMm1haW4uanMlMjIlMkMlMjJjb2RlJTIyJTNBJTIyZnVuY3Rpb24lMjBmb28oKSUyMCU3QiU3RCU1Q25leHBvcnQlMjBmdW5jdGlvbiUyMGJhcigpJTIwJTdCJTdEJTIyJTdEJTVEJTJDJTIyb3B0aW9ucyUyMiUzQSU3QiU3RCU3RA==

サイドエフェクトの作成

Rollupは実際に関数の副作用を分析し、副作用のない関数を喜んで削除します。
副作用のない関数は、他のコードで参照されていても、喜んで削除します。

rollupが少なくとも関数をバンドルに入れることを検討するためには、副作用を導入する必要があります、
その関数に副作用を導入する必要があります。どうすればいいのでしょうか?
答えは、rollupが内部を見ることができないようなコードを生成することです。例えば
を呼び出すことで、参照されない識別子を生成することができます。その識別子は、潜在的にwindowに存在する可能性があります。
その識別子はwindowに存在する可能性がありますが、rollupはそのことを知りません。だから、そのコードには触れない。

https://rollupjs.org/repl/?version=3.10.0&shareable=JTdCJTIyZXhhbXBsZSUyMiUzQW51bGwlMkMlMjJtb2R1bGVzJTIyJTNBJTVCJTdCJTIybmFtZSUyMiUzQSUyMm1haW4uanMlMjIlMkMlMjJjb2RlJTIyJTNBJTIyXygpJTIyJTdEJTVEJTJDJTIyb3B0aW9ucyUyMiUzQSU3QiU3RCU3RA==

最終的に私は、異なる宣言の間に参照を作成することにしました。関数の引数の既定値として作成することにしました。そうすれば、rollupがセミコロンを挿入してTypeScriptのコードを混乱させることはない。が挿入され、TypeScriptのコードを混乱させることがない。

繰り返しになりますが、すべての Identifier は、正しい startend でアノテーションされています。
マーカーを付けています。そのため、もし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がそのコードを削除します。この場合も、アノテーションとして
startend`のマーカーを付けて完了です。

function foo(_0 = foo, _1 = () => {removeme}) {}.
export function bar(_0 = bar, _1 = foo) {}.

感想

hacky 過ぎてあんまりこれをベースにしたくはない気がしてきた。。。

mizchimizchi

事前に都合よく加工するのはいいアイデアかもしれない。

入力されたファイルに対して、すべての関数実装に対して別ファイルに切り出してしまうのもありか。

mizchimizchi
// メモ
const checker = program.getTypeChecker();
const mods = checker.getAmbintModules();

で ambientModules が取れる

mizchimizchi

やはり external については自分で解決しようと思う。指定したファイルを全部舐めた甘めの辞書を、 node-external と react-external で作る。偽陽性はしゃーない。

一応 node 標準ライブラリの型が欲しかった理由を書いておくと、 cloudflare workers の node(js)_compat モードを使う場合、ビルドして配布できず external に指定する必要があるため。deno の node 互換モードも同様。

mizchimizchi

TypeScript Compiler Service では files: string[] を要求され、実際の解析対象になりうる src/ 配下をまるっと取得する方法がなかったりする。

TypeScript で現在の tsconfig.json を元に解析対象のファイル一覧を取得する方法が必要だったので、調べた。
compiler host を生成し、 ts.createWatchProgram を実行することで、再帰的にファイルが読み込まれる。この実装では close してるが、おそらく普通の使用用途なら不要だと思う。

参考: https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#writing-an-incremental-program-watcher

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);
  });
}
mizchimizchi

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