webpack 5 Node.js polyfill

webpack 4 以前では Node.js core module の polyfill が webpack 自体に同梱されていて、bundle したい module が 1 つでも core module の機能を使っていれば自動的にその polyfill が追加されていました。 しかし webpack 5 からはこの自動 polyfill が廃止され、真に必要な場合にのみユーザが手動で polyfill を追加するというようになりました 1

具体例を挙げると、react-markdown は unified 2 を通じて vfile というパッケージに依存しており 3、vfile は path module に依存している 4 ので、webpack 5 では何も polyfill の設定をしないと react-markdown を利用したコードのビルドに失敗します。

小さな例で実験してみましょう。

webpack v4 の場合

package.json

{
  "scripts": {
    "build": "webpack --mode production"
  },
  "devDependencies": {
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "ts-loader": "^8.0.11",
    "typescript": "^4.1.2",
    "webpack": "^4.44.2",
    "webpack-cli": "^4.2.0"
  },
  "dependencies": {
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "react-markdown": "^5.0.3"
  }
}

webpack.config.js

const path = require("path");

module.exports = {
  entry: path.resolve(__dirname, "src/index.tsx"),

  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },

  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: {
          loader: "ts-loader",
        }
      },
    ],
  },

  resolve: {
    extensions: [".ts", ".tsx", ".js"],
  },
}

tsconfig.json

{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",
    "moduleResolution": "node",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true
  }
}

src/index.tsx

import ReactDOM from "react-dom";
import { Markdown } from "./components/Markdown";

ReactDOM.render(<Markdown></Markdown>, document.getElementById("root"));

src/components/Markdown.tsx

import ReactMarkdown from "react-markdown";

export const Markdown = () => {
  const body = `
## Hello, Markdown!

Hello! react-markdown is [here](https://github.com/remarkjs/react-markdown).
`;

  return (
    <>
      <ReactMarkdown children={body}></ReactMarkdown>
    </>
  );
};

dist/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>
    <div id="root"></div>
    <script src="bundle.js"></script>
  </body>
</html>

この状態で npm run build を実行するとビルドは正常に完了し、 dist/index.html をブラウザで開けば想像通りの画面が表示されます (ちなみにこの状態で bundle.js のサイズは 210 KiB でした)。

webpack v5 の場合

しかし上記の状態で webpack を最新バージョン(執筆時点では v5.9.0)に上げると:

-    "webpack": "^4.44.2",
+    "webpack": "^5.9.0",

npm run buildpath を解決できずに失敗します:

ERROR in ./node_modules/vfile/core.js 3:11-26
Module not found: Error: Can't resolve 'path' in '/<path-to-the-current-directory>/node_modules/vfile'

BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.

If you want to include a polyfill, you need to:
    - add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
    - install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
    resolve.fallback: { "path": false }
 @ ./node_modules/vfile/index.js 4:12-32
 @ ./node_modules/unified/index.js 8:12-28
 @ ./node_modules/react-markdown/lib/react-markdown.js 5:14-32
 @ ./src/components/Markdown.tsx 2:0-43 9:45-58
 @ ./src/index.tsx 3:0-49 4:21-29

そこで、エラーメッセージに従って path-browserify をインストールし resolve.fallback の設定 5 を行います:

package.json

   "devDependencies": {
     "@types/react": "^17.0.0",
     "@types/react-dom": "^17.0.0",
+    "path-browserify": "^1.0.1",
     "ts-loader": "^8.0.11",
     "typescript": "^4.1.2",
     "webpack": "^5.9.0",

webpack.config.js

   resolve: {
     extensions: [".ts", ".tsx", ".js"],
+    fallback: {
+      "path": require.resolve("path-browserify"),
+    }
   },
 }

すると一応 npm run build は成功するようになります。

が、 dist/index.html をブラウザで開くと画面には何も表示されず、コンソールに ReferenceError: process is not defined というエラーが表示されるでしょう。これは実は vfile が process という名のグローバルオブジェクトに依存しているためです 6 。 Node.js では process はグローバルな文脈に存在します 7 が、ブラウザ環境では process の shim は手で行わなければなりません。そういった特定のグローバル変数の shim を行うために imports-loader という loader が存在します:

github.com

こいつの仕事は主に test でマッチしたファイルの先頭に何らかのモジュールを import することです。

そこで

% npm i -D imports-loader process

の後 imports-loader の設定 8 を行い

webpack.config.js

           loader: "ts-loader",
         }
       },
+      {
+        test: /node_modules\/vfile\/core\.js/,
+        use: {
+          loader: "imports-loader",
+          options: {
+            type: "commonjs",
+            imports: ["single process/browser process"],
+          },
+        },
+      },
     ],
   },

改めて npm run build を実行すれば、晴れて react-markdown が使えるようになります。 ちなみにこの状態で bundle.js のサイズは 208 KiB でした。きめ細かく polyfill を設定できるようになったことで若干バンドルサイズも小さくなり、嬉しいですね。

warp filter 設定

warp

warpreqwesthyper の作者である Sean McArthur 氏が hyper の上で作っている Rust 製の Web フレームワーク: github.com

執筆時点での最新バージョンは 0.2.5: docs.rs

どのエンドポイントにどんな種類のリクエストが来たらどんな処理をするかを記述するための Filter と呼ばれる概念があり、これを使ってサーバ側の処理を書いていくのが warp の特徴。 README 1 に乗っている例はこんな感じ:

[dependencies]
tokio = { version = "0.2", features = ["full"] }
warp = "0.2"
use warp::Filter;

#[tokio::main]
async fn main() {
    // GET /hello/warp => 200 OK with body "Hello, warp!"
    let hello = warp::path!("hello" / String)
        .map(|name| format!("Hello, {}!", name));

    warp::serve(hello)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

組み立てた filter を warp::serve に渡して走らせる。

Filter

Built-in Filter の一覧がここに載っている:

warp::filters - Rust

眺めてみるだけでもなんとなく使い方が予想できるのではないか。

path

起点となるのは path filter かもしれない:

warp::filters::path - Rust

その名の通り path を定義する。

ドキュメント 2 にあるコード例は:

use warp::Filter;

// GET /hi
let hi = warp::path("hi").map(|| {
    "Hello, World!"
});

param

path からパラメータを抜き出したいときは param を使う:

warp::filters::path::param - Rust

こんな感じ:

use warp::Filter;

// GET /sum/:u32/:u32
let sum = warp::path("sum")
    .and(warp::path::param::<u32>())
    .and(warp::path::param::<u32>())
    .map(|a, b| format!("{} + {} = {}", a, b, a + b));

path や param を定義するのに便利な path! マクロもある:

warp::path - Rust

ドキュメント 3 にあるコード例:

use warp::Filter;

// GET /hello/from/warp
let hello_from_warp = warp::path!("hello" / "from" / "warp").map(|| {
    "Hello from warp!"
});

// GET /sum/:u32/:u32
let sum = warp::path!("sum" / u32 / u32).map(|a, b| {
    format!("{} + {} = {}", a, b, a + b)
});

header

header filter もよく使いそう:

warp::filters::header - Rust

例えばこんな感じ:

use warp::Filter;

let authorization_token =
    warp::header::<String>("authorization").map(|autorization: String| {
        autorization
            .trim()
            .strip_prefix("Bearer ")
            .unwrap_or("")
            .to_string()
    });

CORS

場合によっては CORS のことも考えないといけない。

developer.mozilla.org

warp には cors filter もある:

warp::filters::cors - Rust

warp::cors を呼ぶと warp::filters::cors::Builder が返るので適宜設定していく。 allow_headers, allow_methods, allow_origin らへんを主に使うと思う。こんな感じに:

use warp::Filter;

let allowed_origin = env::var("ALLOWED_ORIGIN").expect("ALLOWED_ORIGIN must be set");
let cors = warp::cors()
    .allow_origin(allowed_origin.as_str())
    .allow_headers(vec!["authorization"])
    .allow_methods(vec!["GET", "POST", "PUT", "DELETE"]);

warpcors のテストコード も参考になると思う。

ちなみに cors filter を設定しておくとプリフライトリクエストの処理は warp 側でよしなにやってくれるので、アプリケーションコードには OPTIONS リクエストの処理は書かずに単純に目的のエンドポイントの処理だけ書けば良い。

Filter の合成

filter たちを合成してルーティングを構築したり値を抜き出して加工したりするためのメソッドが Filter trait には定義されている:

warp::Filter - Rust

and, or

一番使うのは andor だと思う。

前述までの例にも出てきていたが、例えばこんな感じでルーティングを定義したりできる:

use warp::Filter;

let api = warp::path("api");
let v1 = warp::path("v1");

let users = warp::path("users").map(|| {
    let data = vec!["Alice", "Bob", "Carol"];
    warp::reply::json(&data)
});

let posts = warp::path("posts").map(|| {
    let data = vec!["Hello", "Nice", "Uh-oh"];
    warp::reply::json(&data)
});

let routes = api.and(v1.and(users.or(posts)));

map, and_then

filter たちが抜き出したり作ったりした値を加工して返す。

ドキュメント 4 にあるコード例:

use warp::Filter;

// Map `/:id`
warp::path::param().map(|id: u64| {
  format!("Hello #{}", id)
});

and_then に渡すクロージャTryFuture を返し、 Err 内部の値は Rejection 型である必要があるとドキュメントに書かれていて、実際ドキュメント 5 のコード例にも

use warp::Filter;

// Validate after `/:id`
warp::path::param().and_then(|id: u64| async move {
    if id != 0 {
        Ok(format!("Hello #{}", id))
    } else {
        Err(warp::reject::not_found())
    }
});

と書かれている。

勘違いしやすいが、4xx/5xx エラーであったとしても必ずしも Rejection とするべきとは限らない。 warp の filter システムにおける Rejection というのは「この filter では前提条件を満たせず処理できなかったけど、他の filter なら正常に処理できるかもしれないのでそっちに期待します」という意思の表明であって、マッチしたフィルター内で 4xx/5xx エラーが発生したとしてそれを最終結果としたい(他の filter に移りたくない)のであれば、 Err(Rejection) よりむしろステータスコードとともに Ok(Reply) を返すべきである。

Sean McArthur 氏のコメントexamples/todos も参考になるかもしれない。

boxed

filter の数が増えてくると、それらを関心別に関数に分割したくなると思う。

しかし単純にやろうとすると、こういうダルい型を書かなくてはならない:

use warp::Filter;

fn routes() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    let api = warp::path("api");
    let v1 = warp::path("v1");

    api.and(v1.and(users().or(posts())))
}

fn users() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path("users").map(|| {
        let data = vec!["Alice", "Bob", "Carol"];
        warp::reply::json(&data)
    })
}

fn posts() -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
    warp::path("posts").map(|| {
        let data = vec!["Hello", "Nice", "Uh-oh"];
        warp::reply::json(&data)
    })
}

こういう処理をより楽に書くために boxed というメソッドが用意されている。 boxedBoxedFilter という型を返し、こんな風に書ける:

use warp::{Filter, filters::BoxedFilter, Reply};

fn routes() -> BoxedFilter<(impl Reply,)> {
    let api = warp::path("api");
    let v1 = warp::path("v1");

    api.and(v1.and(users().or(posts()))).boxed()
}

fn users() -> BoxedFilter<(impl Reply,)> {
    warp::path("users")
        .map(|| {
            let data = vec!["Alice", "Bob", "Carol"];
            warp::reply::json(&data)
        })
        .boxed()
}

fn posts() -> BoxedFilter<(impl Reply,)> {
    warp::path("posts")
        .map(|| {
            let data = vec!["Hello", "Nice", "Uh-oh"];
            warp::reply::json(&data)
        })
        .boxed()
}

いかがでしたか

他にもいろいろありますがとりあえずこの辺で。

warp は独自の概念を理解するのにちょっと時間がかかったけど慣れると面白い Web フレームワークだと思う。

Template string types and mapped type 'as' clauses 機能説明(意訳)

(これは以下の PR で Anders Hejlsberg 氏が最初にコメントした内容を意訳しただけの個人用メモです)

github.com

この PR は以下 2 つの新機能を実装します:

テンプレート文字列型

テンプレート文字列型はテンプレート文字列式の型版 (the type space equivalent of template string expressions) です。テンプレート文字列式と同様、テンプレート文字列型はバックティックで囲まれ、${T} の形のプレースホルダを含めることができます。ここで Tstring, number, boolean, または bigintアサイン可能な型です。テンプレート文字列型ではリテラル文字列を結合したり、非 string なプリミティブ型をその文字列表現に変換したり、文字列リテラルの大文字小文字を変えることができます。さらに、型推論によって、テンプレート文字列型はシンプルな形式の文字列パターンマッチと分解を行ってくれます。

テンプレート文字列型は以下のように解決されます:

  • プレースホルダ内の union 型はテンプレート文字列全体に分配されます。例えば `[${A|B|C}]``[${A}]` | `[${B}]` | `[${C}]` に解決します。複数のプレースホルダ内の union 型はクロス積に解決します。例えば `[${A|B},${C|D}]``[${A},${C}]` | `[${A},${D}]` | `[${B},${C}]` | `[${B},${D}]` に解決します。
  • プレースホルダ内の string, number, boolean, そして bigint リテラル型はそのリテラル型の文字列表現でプレースホルダを置き換えます。例えば `[${'abc'}]``[abc]` に、 `[${42}]``[42]` に解決します。
  • プレースホルダ内に any, string, number, boolean, または bigint 型のうちのいずれかが 1 つでもあれば、そのテンプレート文字列は string 型に解決します。
  • プレースホルダ内に never 型があれば、そのテンプレート文字列は never 型に解決します。

例をいくつか:

type EventName<T extends string> = `${T}Changed`;
type Concat<S1 extends string, S2 extends string> = `${S1}${S2}`;
type ToString<T extends string | number | boolean | bigint> = `${T}`;
type T0 = EventName<'foo'>;  // 'fooChanged'
type T1 = EventName<'foo' | 'bar' | 'baz'>;  // 'fooChanged' | 'barChanged' | 'bazChanged'
type T2 = Concat<'Hello', 'World'>;  // 'HelloWorld'
type T3 = `${'top' | 'bottom'}-${'left' | 'right'}`;  // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
type T4 = ToString<'abc' | 42 | true | -1234n>;  // 'abc' | '42' | 'true' | '-1234'

Union 型をクロス積分配するとあっという間に非常に大きくてコストのかかる型へと拡大する可能性があることに注意してください。また union 型の構成要素は 100,000 未満に制限されることにも注意してください。以下のコードはエラーになります:

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Zip = `${Digit}${Digit}${Digit}${Digit}${Digit}`;  // Error

テンプレート文字列プレースホルダは型の前に uppercase, lowercase, capitalize, または uncapitalize 修飾子を任意で指定できます。この修飾子は置き換えられる文字列の全体または先頭文字の大文字小文字を変化させます。例えば:

type GetterName<T extends string> = `get${capitalize T}`;
type Cases<T extends string> = `${uppercase T} ${lowercase T} ${capitalize T} ${uncapitalize T}`;
type T10 = GetterName<'foo'>;  // 'getFoo'
type T11 = Cases<'bar'>;  // 'BAR bar Bar bar'
type T12 = Cases<'BAR'>;  // 'BAR bar BAR bAR'

テンプレート文字列型はすべて string 型とそのサブタイプにアサインできます。さらに、テンプレート文字列型 `${T}``${C}` (ここで CT の文字列リテラル型制約) とそのサブタイプにアサインできます。例えば:

function test<T extends 'foo' | 'bar'>(name: `get${capitalize T}`) {
    let s1: string = name;
    let s2: 'getFoo' | 'getBar' = name;
}

型推論では文字列リテラル型からテンプレート文字列型への推論をサポートしています。推論がうまくいくためには、推論先となるテンプレート文字列型の中の各プレースホルダが少なくとも1文字のスパンで隔てられていなければなりません(言い換えると、ぴったり隣り合ったプレースホルダたちへと推論することはできません)。各スパンを左から右へと非貪欲マッチを行いそのスパンで隔てられた2つの部分文字列からプレースホルダ型への推論を行うことで推論が進みます。例をいくつか:

type MatchPair<S extends string> = S extends `[${infer A},${infer B}]` ? [A, B] : unknown;
type T20 = MatchPair<'[1,2]'>;  // ['1', '2']
type T21 = MatchPair<'[foo,bar]'>;  // ['foo', 'bar']
type T22 = MatchPair<' [1,2]'>;  // unknown
type T23 = MatchPair<'[1,2] '>;  // unknown
type T24 = MatchPair<'[1,2,3,4]'>;  // ['1', '2,3,4']

テンプレート文字列型を再帰的 conditional types と組み合わせて、繰り返しパターン上をイテレートする Join 型と Split 型を書くことができます。

type Join<T extends (string | number | boolean | bigint)[], D extends string> =
    T extends [] ? '' :
    T extends [unknown] ? `${T[0]}` :
    T extends [unknown, ...infer U] ? `${T[0]}${D}${Join<U, D>}` :
    string;
type T30 = Join<[1, 2, 3, 4], '.'>;  // '1.2.3.4'
type T31 = Join<['foo', 'bar', 'baz'], '-'>;  // 'foo-bar-baz'
type T32 = Join<[], '.'>;  // ''
type Split<S extends string, D extends string> =
    string extends S ? string[] :
    S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] :
    [S];
type T40 = Split<'foo', '.'>;  // ['foo']
type T41 = Split<'foo.bar.baz', '.'>;  // ['foo', 'bar', 'baz']
type T42 = Split<any, '.'>;  // string[]

再帰的推論機能は、例えば、プロパティにドット繋ぎでアクセスする関数や、 JavaScript フレームワークでたまに使われるパターンを強く型付けするのに使えます。

type PropType<T, Path extends string> =
    string extends Path ? unknown :
    Path extends keyof T ? T[Path] :
    Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropType<T[K], R> : unknown :
    unknown;

declare function getPropValue<T, P extends string>(obj: T, path: P): PropType<T, P>;
declare const s: string;

const obj = { a: { b: {c: 42, d: 'hello' }}};
getPropValue(obj, 'a');  // { b: {c: number, d: string } }
getPropValue(obj, 'a.b');  // {c: number, d: string }
getPropValue(obj, 'a.b.d');  // string
getPropValue(obj, 'a.b.x');  // unknown
getPropValue(obj, s);  // unknown

Mapped type as

この PR により, mapped types はオプションの as 節をサポートします。 as 節を通して、生成されるプロパティ名の変換方法を指定できます:

{ [P in K as N]: X }

ここで Nstring 型にアサイン可能な型でなければなりません。 通常、N は、Pプレースホルダ内で利用するテンプレート文字列型のような、P を変換した型です。例えば:

type Getters<T> = { [P in keyof T & string as `get${capitalize P}`]: () => T[P] };
type T50 = Getters<{ foo: string, bar: number }>;  // { getFoo: () => string, getBar: () => number }

上記において、keyof T & string の交差が必要です。なぜなら keyof T はテンプレート文字列型を使って変換できない symbol 型を含むことがあるからです。

as 節内で指定された型が never に解決した場合、その key のプロパティは生成されません。したがって、as 節はフィルターとして使えます:

type Methods<T> = { [P in keyof T as T[P] extends Function ? P : never]: T[P] };
type T60 = Methods<{ foo(): number, bar: boolean }>;  // { foo(): number }

as 節内で指定された型がリテラル型の union に解決した場合、同じ型の複数のプロパティが生成されます:

type DoubleProp<T> = { [P in keyof T & string as `${P}1` | `${P}2`]: T[P] }
type T70 = DoubleProp<{ a: string, b: number }>;  // { a1: string, a2: string, b1: number, b2: number }

Fixes #12754.

Playground: https://www.typescriptlang.org/play?ts=4.1.0-pr-40336-88