Upgrade to Pro — share decks privately, control downloads, hide ads and more …

パッケージ開発者の苦悩 -JavaScriptランタイム群雄割拠- / distress of package developer

shimataro
January 18, 2023

パッケージ開発者の苦悩 -JavaScriptランタイム群雄割拠- / distress of package developer

王者 “Node.js”、王者の親による次世代王者の卵 “Deno”、新進気鋭の “Bun”・・・Node.js一強かと思われていたJavaScriptランタイムの世界も、混戦の様相を呈してきました。
一方、パッケージの開発者は複数ランタイムへの対応に追われています。

この発表では、それぞれのモジュールの扱いの違いや、単一のコードベースで全てのランタイム向けのパッケージに対応する方法などを説明します!

TechFeed Experts Night#11 〜 JavaScript/TypeScript最前線
https://techfeed.io/events/techfeed-experts-night-11

shimataro

January 18, 2023
Tweet

More Decks by shimataro

Other Decks in Technology

Transcript

  1. この発表について 対象 • パッケージ開発者 • Node.js / Denoの基本的なことは知っている ◦ Bunは「TypeScriptが動くNode.js」くらいの認識で

    OK 完全に理解した 何も わからない チョット デキル このへん 前提知識(説明しないこと) • CommonJS / ES Modulesについて • トランスパイラ(tsc / Babel)の役目や使い方 • パッケージの公開方法 スライドはこちら→
  2. 目標 Node.js / Deno / Bun対応 CommonJS / ES Modules対応

    (AMDとかは省略...) パッケージ利用者が tsc / Babelを使っていても動く 古いランタイムバージョンにも対応
  3. 目標 Node.js / Deno / Bun対応 CommonJS / ES Modules対応

    (AMDとかは省略...) パッケージ利用者が tsc / Babelを使っていても動く 古いランタイムバージョンにも対応 単一のコードベースから パッケージを作りたい!
  4. 目標 Node.js / Deno / Bun対応 CommonJS / ES Modules対応

    (AMDとかは省略...) パッケージ利用者が tsc / Babelを使っていても動く 古いランタイムバージョンにも対応 単一のコードベースから パッケージを作りたい! わりと昔話が多めです
  5. 前提(レギュレーション) パッケージはロジックだけで完結 コードベースはTypeScript パッケージは複数のファイルで構成 JSランタイムのAPIに 非依存 パッケージ内部でも 別ファイルをimport Node.js向けには *.d.ts

    ファイルも提供 Deno/Bun向けには *.ts ファイルのまま提供 極力ランタイムのネイティブ形式で提供 ES Modules対応のランタイムには 極力ES Modules形式で提供
  6. 苦悩1: ランタイムごとにモジュールの扱いが異なる 苦悩レベル: ★☆☆☆☆ • Node.js: CommonJS / ES Modules(v8.5以降)

    • Deno/Bun: ES Modules それぞれに合わせたファイルを用意する必要あり ビルド時に複数生成すればいいだけなので 省略
  7. 苦悩2: ES Modulesは拡張子省略不可 苦悩レベル: ★★★★☆ パッケージ内部で別のファイルを読み込む場合 import foo from "./foo.mjs"

    // for Node.js import foo from "./foo.ts" // for Deno/Bun 共通のコードベースから、ビルド時に拡張子を解決する必要あり
  8. 苦悩4: Dual Package対応の罠 苦悩レベル: ★★★★★ うまい具合に ./libs/main.js と ./libs/main.mjs を作れたとして

    package.jsonをどう書けばいい? requireされたら ./libs/main.js を読ませたい importされたら ./libs/main.mjs を読ませたい
  9. 苦悩5: default importの扱い 苦悩レベル: ★★★☆☆ import foo from "foo"; var

    foo = require("foo").default; ".default" が追加される tsc / Babel
  10. 苦悩5: default importの扱い var foo = require("foo").default; var foo =

    require("foo"); 生のCommonJSで書いていても tsc/Babelを使っていても どちらでも動くようにしたい! パッケージの利用者が
  11. ビルド時のポイント(1) CommonJS用のビルドには エントリーポイントの最後におまじないを追加 // tscの出力 exports.default = { foo: ()

    => {...}, bar: () => {...}, }; // おまじないを2行追加! module.exports = exports.default; module.exports.default = exports.default; require(“foo”) require(“foo”).default どちらでも使える! main.js
  12. ビルド時のポイント(2) ES Modules for Node.js用のビルドは tscとBabelの多段構成 { "scripts": { "build:esm":

    "run-s build:esm:*", "build:esm:1-tsc": "tsc", "build:esm:2-babel": "babel ./src --out-dir ./libs --out-file-extension .mjs" } } 文法変換 .d.ts ファイルの作成 import文の拡張子解決 package.json
  13. エントリーポイントの指定 { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js",

    "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 Bun用 拡張子を省略 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js
  14. エントリーポイントの指定 { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js",

    "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 Bun用 拡張子を省略 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js ES ModulesからCommonJSが参照される状況が出てしまう v12.0-12.15 v13.0-13.1
  15. 実際に作ったパッケージ • written in TypeScript • hybrid package • supports

    Node.js >=4 • well-tested (in many versions & 100% coverage) { "id": "123.45", "name": "Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Ciprin Cipriano de la Santísima Trinidad Ruiz y Picasso" } input { "id": 123, "name": "Pablo Diego José" } output id: 数値型 • 整数(端数は切り捨て) • 1以上(1未満はエラー) name: 文字列型 • 空文字列はエラー • 最大16文字(17文字目以降は削除) schema value-schema
  16. 単一コードベースでハイブリッドパッケージを作る方法 ソースコードは・・・ • 拡張子をつけずに相対パスで import • エントリーポイントからはdefault exportだけ 行う ビルド時は・・・

    • CommonJS版は最後におまじない • ES Modules版はtsc+Babelでビルド • Deno/Bun向けには専用ツールでビルド package.jsonは・・・ • mainには拡張子をつけずに指定 • Conditional Exportsをよしなに設定 • 一部のバージョンで、ES Modulesから CommonJS版が参照されます
  17. 苦悩4: Dual Package対応の罠(再掲) 苦悩レベル: ★★★★★ うまい具合に ./libs/main.js / ./libs/main.mjs を作れたとして

    package.jsonをどう書けばいい? requireされたら ./libs/main.js を読ませたい importされたら ./libs/main.mjs を読ませたい
  18. 苦悩4: Dual Package対応の罠(再掲) 苦悩レベル: ★★★★★ うまい具合に ./libs/main.js / ./libs/main.mjs を作れたとして

    package.jsonをどう書けばいい? requireされたら ./libs/main.js を読ませたい importされたら ./libs/main.mjs を読ませたい 言うほど苦悩? Conditional Export使うだけなのに?
  19. エントリーポイントの指定(再掲) { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js",

    "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 拡張子を省略して指定 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js Bun用
  20. エントリーポイントの指定(再掲) { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js",

    "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 拡張子を省略して指定 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js Bun用 ES ModulesからCommonJSが参照される状況が出てしまう v12.0-12.15 v13.0-13.1
  21. エントリーポイントの指定(再掲) { "main": "./libs/main", "exports": { "bun": "./libs/main.ts", "require": "./libs/main.js",

    "default": "./libs/main.mjs" } } package.json Conditional Exports v12.16以降 or v13.2以降 or v14以降で対応 拡張子を省略して指定 Node.js v11以前: 呼び出し元に依存 Node.js v12以降: 常に ./libs/main.js Bun用 ES ModulesからCommonJSが参照される状況が出てしまう v12.0-12.15 v13.0-13.1 コレ、実は不正確です
  22. Node.js v12.11.0での実行例 { "name": "example-package", "main": "./libs/main", "exports": { "bun":

    "./libs/main.ts", "require": "./libs/main.js", "default": "./libs/main.mjs" } } package.json $ node --experimental-modules example.mjs (node:30008) ExperimentalWarning: The ESM module loader is experimental. internal/modules/esm/default_resolve.js:79 let url = moduleWrapResolve(specifier, parentURL); ^ Error: Cannot resolve package exports target 'undefined' matched for '.' in /PATH/TO/example/node_modules/example-package/package.json, imported from /PATH/TO/example/example.mjs at Loader.resolve [as _resolve] (internal/modules/esm/default_resolve.js:79:13) at Loader.resolve (internal/modules/esm/loader.js:73:33) at Loader.getModuleJob (internal/modules/esm/loader.js:152:40) at ModuleWrap.<anonymous> (internal/modules/esm/module_job.js:43:40) at link (internal/modules/esm/module_job.js:42:36) { code: 'ERR_MODULE_NOT_FOUND' } import example from "example-package"; example.mjs "exports"の中に "." が ないよ!
  23. つまりこういうこと 該当のバージョンでは、 "exports" フィールド内では Subpath Exportsを期待 example-packageの package.json 内に "."

    がないのでエラー Subpath Exportsはv12.7で導入されたのに なぜv12.11からこうなったのかは謎 v12.11のExports Sugarが影響?
  24. 色々試してみた(1) { "main": "./libs/main", "exports": { ".": { "require": "./libs/main.js",

    "default": "./libs/main.mjs" } } } package.json Subpath Exportの中にConditional Exports $ node --experimental-modules example.mjs (node:30240) ExperimentalWarning: The ESM module loader is experimental. internal/modules/esm/default_resolve.js:79 let url = moduleWrapResolve(specifier, parentURL); ^ Error: Cannot resolve package exports target '[object Object]' matched for '.' in /PATH/TO/example/node_modules/example-package/package.json, imported from /PATH/TO/example/example.mjs at Loader.resolve [as _resolve] (internal/modules/esm/default_resolve.js:79:13) at Loader.resolve (internal/modules/esm/loader.js:73:33) at Loader.getModuleJob (internal/modules/esm/loader.js:152:40) at ModuleWrap.<anonymous> (internal/modules/esm/module_job.js:43:40) at link (internal/modules/esm/module_job.js:42:36) { code: 'ERR_MODULE_NOT_FOUND' } まだオブジェクトに対応していない
  25. 色々試してみた(2) { "main": "./libs/main", "exports": { ".": "./libs/main.js", "require": "./libs/main.js",

    "default": "./libs/main.mjs" } } package.json Subpath ExportとConditional Exportsを 併用 該当のバージョンでは動いた! (CommonJSを参照) $ node --experimental-modules example.mjs (node:30391) ExperimentalWarning: The ESM module loader is experimental. internal/modules/esm/resolve.js:58 let url = moduleWrapResolve(specifier, parentURL); ^ SyntaxError: Cannot resolve package exports in /PATH/TO/example/node_modules/example-package/package.json, imported from /PATH/TO/example/example.mjs. "exports" cannot contain some keys starting with '.' and some not. The exports object must either be an object of package subpath keys or an object of main entry condition name keys only. at Loader.defaultResolve [as _resolve] (internal/modules/esm/resolve.js:58:13) at Loader.resolve (internal/modules/esm/loader.js:85:40) at Loader.getModuleJob (internal/modules/esm/loader.js:188:40) at ModuleWrap.<anonymous> (internal/modules/esm/module_job.js:42:40) at link (internal/modules/esm/module_job.js:41:36) { code: 'ERR_INVALID_PACKAGE_CONFIG' } Subpath ExportsとConditional Exportsは 併用できない!! v12.16(Conditional Exports対応版)では...
  26. 結局どうすりゃええの? 1. 一部のバージョンを あきらめる 動かないバージョンはかなり限定されているので あきらめる "engines": { "node": ">=4.0.0

    <12.11 || >=12.16 <13.0 || >=13.2" } 2. ES Modulesを あきらめる "exports" フィールドを撤去 常にCommonJS版を参照 一応どのバージョンでも動く 全バージョン対応が最優先ならこちら package.json
  27. CommonJSとES Modulesの相互運用(再掲) 参照(利用者) 被参照(パッケージ) CommonJS ES Modules CommonJS ES Modules

    require import dynamic import default import だからdefault exportが必要! 実は... named exportもES Modulesから使えます
  28. やり方 exports.foo = () => {...}; exports.bar = () =>

    {...}; module.js import module from "module"; module.foo(); module.bar(); main.mjs named exportして... default importする
  29. やり方 exports.foo = () => {...}; exports.bar = () =>

    {...}; module.js import module from "module"; module.foo(); module.bar(); main.mjs named exportして... default importする 色々ややこしいので やめたほうがいいです。 Node.js v12.0-15: default import Node.js v13.0-1: default import それ以外&Deno/Bun: named import