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. パッケージ開発者の苦悩
    -JavaScriptランタイム群雄割拠-
    小田島 太郎 @shimataro999
    TechFeed Experts Night #11 〜JavaScript/TypeScript最前線〜
    スライドはこちら→

    View Slide

  2. 自己紹介
    さくらインターネット所属
    大体このアイコン使ってます
    小田島 太郎
    Web業界の前は手品業界にいました
    (今も副業でやってます)
    @shimataro999
    https://shimataro.me
    スライドはこちら→

    View Slide

  3. この発表について
    対象
    ● パッケージ開発者
    ● Node.js / Denoの基本的なことは知っている
    ○ Bunは「TypeScriptが動くNode.js」くらいの認識で OK
    完全に理解した
    何も
    わからない
    チョット
    デキル
    このへん
    前提知識(説明しないこと)
    ● CommonJS / ES Modulesについて
    ● トランスパイラ(tsc / Babel)の役目や使い方
    ● パッケージの公開方法
    スライドはこちら→

    View Slide

  4. 背景
    JavaScriptランタイムって色々あるよね
    古いバージョンは技術的負債があるよね
    (特にNode.js)
    アプリケーション開発ではトランスパイラも使われるよね
    (パッケージ利用側)
    パッケージは古いランタイムバージョンにも
    対応したいよね
    Node.js / Deno / Bun
    tsc / Babel
    CommonJS / ES Modules
    Conditional Exports
    Node.js v4 / Deno v1.0

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  8. 前提(レギュレーション)
    パッケージはロジックだけで完結
    コードベースはTypeScript
    パッケージは複数のファイルで構成
    JSランタイムのAPIに
    非依存
    パッケージ内部でも
    別ファイルをimport
    Node.js向けには *.d.ts ファイルも提供
    Deno/Bun向けには *.ts ファイルのまま提供
    極力ランタイムのネイティブ形式で提供
    ES Modules対応のランタイムには
    極力ES Modules形式で提供

    View Slide

  9. パッケージ開発者の苦悩

    View Slide

  10. 苦悩1: ランタイムごとにモジュールの扱いが異なる
    苦悩レベル: ★☆☆☆☆
    ● Node.js: CommonJS / ES Modules(v8.5以降)
    ● Deno/Bun: ES Modules
    それぞれに合わせたファイルを用意する必要あり
    ビルド時に複数生成すればいいだけなので
    省略

    View Slide

  11. 苦悩2: ES Modulesは拡張子省略不可
    苦悩レベル: ★★★★☆
    パッケージ内部で別のファイルを読み込む場合
    import foo from "./foo.mjs" // for Node.js
    import foo from "./foo.ts" // for Deno/Bun
    共通のコードベースから、ビルド時に拡張子を解決する必要あり

    View Slide

  12. 苦悩3: tscはimport対象の拡張子を変更してくれない
    苦悩レベル: ★★★★☆
    TypeScriptはJavaScriptのスーパーセット
    JavaScriptとして有効なソースコードは
    TypeScriptでも有効
    JavaScriptとして有効なソースコードは
    ビルド時に一切手を加えない

    View Slide

  13. 苦悩3: tscはimport対象の拡張子を変更してくれない
    TypeScriptはJavaScriptのスーパーセット
    JavaScriptとして有効なソースコードは
    TypeScriptでも有効
    JavaScriptとして有効なソースコードは
    ビルド時に一切手を加えない
    拡張子追加してよ
    JSのコードは変更しないよ

    View Slide

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

    View Slide

  15. 苦悩5: default importの扱い
    苦悩レベル: ★★★☆☆
    import foo from "foo";
    var foo = require("foo").default;
    ".default" が追加される
    tsc / Babel

    View Slide

  16. 苦悩5: default importの扱い
    var foo = require("foo").default;
    var foo = require("foo");
    生のCommonJSで書いていても
    tsc/Babelを使っていても
    どちらでも動くようにしたい!
    パッケージの利用者が

    View Slide

  17. 苦悩6: 以前のDenoはNPM非対応
    苦悩レベル: ★☆☆☆☆
    Deno v1.27以前はNPM非対応
    Deno用パッケージはエントリーポイントをURLで公開
    import foo from "https://example.com/mod.ts";
    GitHubとかで簡単に公開できるので
    省略

    View Slide

  18. パッケージの作り方

    View Slide

  19. ソースコード記述時のポイント(1)
    パッケージ内で別ファイルをインポートする時は
    相対パス&拡張子をつけない
    import foo from "foo.ts";
    import foo from "./foo";


    拡張子はビルド時に解決
    詳細は後で!
    main.ts
    main.ts

    View Slide

  20. ソースコード記述時のポイント(2)
    パッケージのエントリーポイントからは
    default exportだけ行う
    export function foo() {...};
    export function bar() {...};
    export default {
    foo: () => {...},
    bar: () => {...},
    };
    理由は後で!


    main.ts
    main.ts

    View Slide

  21. ビルド時のポイント(1)
    CommonJS用のビルドには
    エントリーポイントの最後におまじないを追加
    // tscの出力
    exports.default = {
    foo: () => {...},
    bar: () => {...},
    };
    // おまじないを2行追加!
    module.exports = exports.default;
    module.exports.default = exports.default;
    require(“foo”)
    require(“foo”).default
    どちらでも使える!
    main.js

    View Slide

  22. ビルド時のポイント(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

    View Slide

  23. ビルド時のポイント(3)
    拡張子解決のBabelプラグインを使う
    babel-plugin-module-extension-resolver
    {
    "plugins": [
    ["module-extension-resolver", {"dstExtension": ".mjs"}]
    ]
    }
    ワシが作った
    .babelrc

    View Slide

  24. ビルド時のポイント(4)
    Deno/Bun用のビルドは専用ツールを使う
    deno-module-extension-resolver
    ワシが作った
    プラグインではなくCLIツール
    拡張子 .ts を解決
    {
    "scripts": {
    "build:deno": "deno-module-extension-resolver ./src ./libs"
    }
    }
    package.json

    View Slide

  25. 疑問: 1つのファイルにまとめれば拡張子解決不要では?
    モジュールバンドラーで 1つにまとめれば
    拡張子の解決に悩まずに済むのでは?
    Webpack, rollup, esbuild,
    deno bundle, …
    Deno/Bun向けには
    TypeScriptのまままとめてくれる
    バンドラーが必要
    知ってたら教えてください

    View Slide

  26. エントリーポイントの指定
    {
    "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

    View Slide

  27. エントリーポイントの指定
    {
    "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

    View Slide

  28. CommonJSとES Modulesの相互運用
    参照(利用者) 被参照(パッケージ)
    CommonJS
    ES Modules
    CommonJS
    ES Modules
    require
    import
    dynamic import
    default import
    だからdefault exportが必要!

    View Slide

  29. 実際に作ったパッケージ ● 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

    View Slide

  30. まとめ

    View Slide

  31. 単一コードベースでハイブリッドパッケージを作る方法
    ソースコードは・・・
    ● 拡張子をつけずに相対パスで
    import
    ● エントリーポイントからはdefault exportだけ
    行う
    ビルド時は・・・
    ● CommonJS版は最後におまじない
    ● ES Modules版はtsc+Babelでビルド
    ● Deno/Bun向けには専用ツールでビルド
    package.jsonは・・・
    ● mainには拡張子をつけずに指定
    ● Conditional Exportsをよしなに設定
    ● 一部のバージョンで、ES Modulesから
    CommonJS版が参照されます

    View Slide

  32. View Slide

  33. おまけスライド
    -Dual Packageの苦悩-
    ここまで見てくれてありがとう (*´艸`)

    View Slide

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

    View Slide

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

    View Slide

  36. エントリーポイントの指定(再掲)
    {
    "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用

    View Slide

  37. エントリーポイントの指定(再掲)
    {
    "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

    View Slide

  38. エントリーポイントの指定(再掲)
    {
    "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
    コレ、実は不正確です

    View Slide

  39. package.jsonのexportsフィールドについて
    https://nodejs.org/api/packages.html#exports
    Conditional Exportsはv12.16以降 / v13.2以降でサポート
    Subpath Exportsはv12.7以降でサポート
    特定のバージョン(12.11-12.15, 13.0-13.1)では
    そもそもConditional Exportsを使おうとするとエラー

    View Slide

  40. 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. (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"の中に "." が
    ないよ!

    View Slide

  41. つまりこういうこと
    該当のバージョンでは、 "exports" フィールド内では Subpath Exportsを期待
    example-packageの package.json 内に "." がないのでエラー
    Subpath Exportsはv12.7で導入されたのに
    なぜv12.11からこうなったのかは謎
    v12.11のExports Sugarが影響?

    View Slide

  42. 色々試してみた(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. (internal/modules/esm/module_job.js:43:40)
    at link (internal/modules/esm/module_job.js:42:36) {
    code: 'ERR_MODULE_NOT_FOUND'
    }
    まだオブジェクトに対応していない

    View Slide

  43. 色々試してみた(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. (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対応版)では...

    View Slide

  44. 結局どうすりゃええの?
    1. 一部のバージョンを
    あきらめる
    動かないバージョンはかなり限定されているので
    あきらめる
    "engines": {
    "node": ">=4.0.0 <12.11 || >=12.16 <13.0 || >=13.2"
    }
    2. ES Modulesを
    あきらめる
    "exports" フィールドを撤去
    常にCommonJS版を参照
    一応どのバージョンでも動く
    全バージョン対応が最優先ならこちら
    package.json

    View Slide

  45. 苦悩4: Dual Package対応の罠
    苦悩レベル: ★★★★★
    お わ か り い た だ け た だ ろ う か

    View Slide

  46. おまけスライド
    -CommonJSとES Modulesの相互運用-

    View Slide

  47. CommonJSとES Modulesの相互運用(再掲)
    参照(利用者) 被参照(パッケージ)
    CommonJS
    ES Modules
    CommonJS
    ES Modules
    require
    import
    dynamic import
    default import
    だからdefault exportが必要!

    View Slide

  48. CommonJSとES Modulesの相互運用(再掲)
    参照(利用者) 被参照(パッケージ)
    CommonJS
    ES Modules
    CommonJS
    ES Modules
    require
    import
    dynamic import
    default import
    だからdefault exportが必要!
    実は...
    named exportもES Modulesから使えます

    View Slide

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

    View Slide

  50. やり方
    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

    View Slide

  51. おまけスライド


    View Slide