GitHubのトレンドで cordiverse/cordis を見かけて、説明文の一行だけで手が止まりました。「Meta-Framework of Spatiotemporal Composability」。
正直なところ、何を言っているのか分からなかったんですよね。時空間的合成可能性のメタフレームワーク。日本語にしても分からない。
ただ、自分はプラグイン機構のあるツールをよく触るほうで、Vite のプラグインだったり ESLint のプラグインだったり、「拡張の差し込み口をどう設計するか」は前から気になっているテーマだったりします。それで、README を開いてみたんですが——本文が5行しかありませんでした。paper へのリンクとドキュメントへのリンク、それと「API は安定していません、予告なく変わります」という警告だけ。
これは動かしてみないと何も分からないなと思って、使い捨てのコンテナで触ってみました。結論から言うと、フレームワーク本体の設計はかなり気持ちよかったのに、周辺の「間違えたときの挙動」で3回ほど無言の壁に当たった、という体験になりました。
試した環境
ホストを汚したくないので、対象の clone から実行まで全部使い捨ての Docker コンテナの中で完結させています。以下の数値は全部その中での実測です。
- 対象:
cordiverse/cordiscommit8cc9e33(2026-08-13)、npm 上はcordis@4.0.0-rc.8、MIT - ベースイメージ:
node:22-bookworm(Debian GNU/Linux 12、Node v22.23.1、npm 10.9.8、x86_64) - 一緒に入れたもの:
@cordisjs/plugin-loader@1.0.0-rc.5、@cordisjs/plugin-include@1.0.4、@cordisjs/plugin-logger-console@1.0.0
まず単純に軽さで驚きました。npm i cordis が3パッケージ・276KB・1.4秒。依存は cosmokit と @standard-schema/spec だけです。フレームワーク本体は lib/index.js の49,117バイト、単一ファイル。最近このシリーズで触ってきたものだと、インストールしたら site-packages が数百MBというケースが続いていたので、ここは素直に嬉しかったところです。
で、その直後に1回目の壁に当たりました。
# --- 1. 入れて、同梱のCLIをそのまま叩いてみる ---
git clone --depth 1 https://github.com/cordiverse/cordis.git # real 1.080s
npm i cordis # added 3 packages / real 1.406s
du -sh node_modules # -> 276K
wc -c node_modules/cordis/lib/index.js # -> 49117
wc -c node_modules/cordis/README.md # -> 9 (npmに載っているREADMEは9バイト)
node node_modules/cordis/bin.js
# -> exit=1
# Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@cordisjs/plugin-loader'
# imported from /demo/node_modules/cordis/bin.js
# --- 2. bin.js が無条件importしている2つを追加で入れる ---
npm i @cordisjs/plugin-loader @cordisjs/plugin-include
du -sh node_modules # -> 1.6M
node node_modules/cordis/bin.js
# -> exit=1
# Error: config file not found: /demo/cordis.yml
cordis パッケージには bin.js が同梱されていて、中身は12行くらいのブートストラップです。Context を作って Loader プラグインを載せて、./cordis.yml を読む。素直で読みやすいコードでした。
ただ、その bin.js が import Loader from '@cordisjs/plugin-loader' を無条件でやっているのに、package.json 側ではこの2つが optional な peerDependencies として宣言されているんですよね。なので npm i cordis だけした状態では、同梱の CLI は起動できません。ここは「CLI を使うなら追加で2つ入れる」と分かればいいだけの話なので、詰まったというより最初の道標が無かった感じです。
injectの依存解決は気持ちいい。ただし綴りを間違えると永久に黙る
ここからがフレームワーク本体です。ドキュメントは README からリンクされている primer を読みました。cordis は DeepSeek Harness というプロジェクトに vendor として取り込まれていて、まとまった解説はそちらのドキュメント側にあります。そこに挙がっている5つの核心概念のうち、自分が一番気になったのが inject による依存解決でした。
「プラグインは必要なサービスを宣言すると、それが揃うまで起動を待つ」。読み込み順を手で並べるのではなく依存で表現する、という話です。
これは実際にやってみたら本当に気持ちよかったです。サービスを提供する側より 先に 消費側を登録しても、ちゃんと待ってくれる。
// --- CASE C: 消費側を先に登録する。1ケース1プロセスで実行 ---
const consumer = {
name: 'consumer',
inject: ['greeter'],
apply(ctx) {
console.log('consumer sees: ' + ctx.greeter.hello('cordis'))
},
}
const cf = ctx.plugin(consumer) // greeter はまだ存在しない
console.log('state=' + stateName(cf.state))
await ctx.plugin(Greeter) // ここで初めて greeter が現れる
// 出力:
// after plugin(consumer): state=PENDING
// consumer.apply
// consumer sees: hello cordis
// after plugin(Greeter): consumer state=ACTIVE
// --- CASE D: 対照実験。injectしたサービスを誰も提供しない ---
const consumer2 = {
name: 'consumer',
inject: ['neverProvided'], // 綴りミスを想定
apply() { applied = true },
}
ctx.plugin(consumer2)
await new Promise((r) => setTimeout(r, 300))
// 出力:
// apply() ran? = false
// fiber.state = PENDING (numeric 0)
// registry.size = 1
// process is exiting normally with no error thrown
// exit=0
CASE C はドキュメント通りで、PENDING で待って、依存が現れた瞬間に apply が走って ACTIVE になります。fiber.state が外から観測できるので、待っているのか動いているのかが自分で確認できるのは良い設計だなと思いました。
問題は対照実験の CASE D です。inject に書いた名前が存在しないサービスだった場合、そのプラグインは PENDING のまま永久に待ちます。apply は一度も呼ばれない。例外は飛ばない。registry.size は 1 のままなので登録自体は生きている。そしてプロセスは exit 0 で正常終了します。
つまり inject: ['greeter'] を inject: ['greter'] と打ち間違えたら、そのプラグインは何も言わずに存在しないのと同じ状態になるわけです。
今思えば、これは「依存が揃うまで待つ」という仕様を素直に実装すると必ずこうなるんですよね。待っている状態と、待ち続けて詰んでいる状態を、フレームワーク側から区別する方法がない。ただ、実際に踏むと気持ちのいいものではなくて、自分は起動時に fiber.state を全部見て PENDING が残っていたら警告を出す、みたいなヘルパを最初に書くと思います。
登録の巻き戻しは、cordis経由のものだけ
primer が挙げる概念のうち「登録は可逆な副作用である」は、素直に強いと感じたところです。プラグインを破棄すると、その中で登録したリスナーは自動で取り消されます。
// --- CASE B: dispose するとリスナーが消える ---
// before dispose: fired = [1] | state = ACTIVE
// after dispose: fired = [1] | state = DISPOSED ← 2回目のemitは届いていない
// registry.size = 0
// --- CASE F: ctx.effect() を通したものと、通していないものを並べる ---
apply(ctx) {
ctx.effect(() => {
const t = setInterval(() => {}, 1000)
return () => { log.push('effect: released'); clearInterval(t) }
}, 'my-timer')
rawTimer = setInterval(() => {}, 1000) // わざとcordisに登録しない
}
// 出力:
// effects = [{"label":"my-timer","children":[]}]
// effect: acquired
// raw: acquired (not registered)
// effect: released ← dispose時に自動で呼ばれた
// raw timer still alive after dispose? = true ← こちらは生き残る
ctx.effect() に渡した後始末は dispose() のタイミングで確実に呼ばれて、fiber.getEffects() でラベル付きの一覧も取れます。一方で、ctx.effect() を経由していない生の setInterval は当然そのまま生き残ります。
当たり前の挙動ではあるんですが、「フレームワークが後始末してくれる」と思い込むと危ないポイントだなと。cordis が面倒を見るのは cordis に預けたものだけで、そこは自分で線を引く必要があります。ラベル付きで一覧が取れるのは、レビューで「この effect はどこで解放されるの」を追うときに効きそうです。
waterfallを「値のパイプ」だと思って呼んだのが一番の勘違いだった
ここが今回いちばん時間を使ったところです。cordis の型定義には5つの分発モードがあります。
| モード | 2つのリスナーのうち呼ばれたもの | 戻り値 | primerの分発モード表 |
|---|---|---|---|
| emit | L1, L2 両方 | undefined | あり |
| parallel | L1, L2 両方 | undefined | あり |
| serial | 戻り値が non-nullish になるまで順に | 最初の non-nullish な戻り値 | あり |
| bail | 戻り値が non-nullish になるまで順に | 最初の non-nullish な戻り値 | 記載なし |
| waterfall | next() を呼んだ分だけ内側へ | 最も外側のリスナーの戻り値 | あり |
DispatchMode 型は 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' の5つで、実装側も mixin("events", ["on", "once", "parallel", "emit", "serial", "bail", "waterfall"]) と5つ生えています。ただ primer の分発モード表に載っているのは4つで、bail はそのページに1度も出てきません。serial と実測の挙動が同じだったので、非同期版と同期版の対、という理解でいます(ここは推測です)。
で、自分の勘違いは waterfall でした。名前から「値を1段ずつ変換して流していくパイプ」だと思い込んで、こう呼んだんですよね。
// --- CASE E2: waterfall('イベント名', 値) と呼んでみた ---
ctx.on('demo/wf', (...args) => {
console.log('L1 argc =', args.length, '| types =', JSON.stringify(args.map(a => typeof a)))
return 'L1-returns'
})
const out = await ctx.waterfall('demo/wf', 'SEED')
// 出力:
// L1 argc = 1 | types = ["function"]
// L1 arg0 is a function named = "next"
// L1 arg1 = undefined ← 'SEED' がどこにも来ていない
// L1 got from next() = "L2-returns"
// waterfall final return = "L1-returns"
// --- 実装を読んだら理由が分かった(lib/index.js:327)---
waterfall(...args) {
const [thisArg, callbacks] = this._resolve("waterfall", args);
const inner = args.pop(); // ← 最後の引数を「最内側の関数」としてpopする
const next = () => {
const callback = callbacks.shift();
return callback ? Reflect.apply(callback, thisArg, args) : inner(...args);
};
args.push(next); // ← popした場所にnextを詰める
return next();
}
// --- CASE E3: next()を呼ばないと下流は走らない ---
// called = ["L1"] | return = "stopped-at-L1"
waterfall は値のパイプではなく、koa 的な環状ミドルウェアでした。リスナーは (...args, next) を受け取って、next() を呼べば内側に降りていき、呼ばなければそこで短絡する。
そして、引数リストの 最後が最内側の関数 という位置の約束があります。自分は最後に 'SEED' という文字列を渡してしまったので、実装はそれを inner(最内側の関数)として pop し、リスナーには next だけが渡り、'SEED' はどのリスナーにも届かないまま消えました。エラーは一切出ません。
ここでフェアに書いておくと、primer にはちゃんと「ctx.waterfall は環状ミドルウェア。監聴器は (...args, next) を受け取る」と明記されていました。ドキュメントは正しくて、自分が名前から早合点しただけです。ただ、TypeScript で書いていればイベント定義の型が next: () => void を最後の引数として持っているので弾かれるはずのミスが、素の JavaScript だと 黙って通ってしまう のが厄介でした。この落とし穴に限っては、型を付けて書くのが実質必須だと思います。
cordis.ymlのエントリ形式を間違えたら、出力が0バイトだった
最後にもう一度、無言の壁です。同梱の bin.js は ./cordis.yml を読んでプラグインを載せてくれるので、その形式を書きました。YAML でプラグイン名をキーにして設定をぶら下げる書き方をよく見るので、それに寄せたんですが——
# --- 自分が最初に書いた形式 ---
cat cordis.yml
# - ./plugin-hello.mjs:
# who: cordis
node node_modules/cordis/bin.js
# exit=0
# --stdout(0 bytes)--
# --stderr(0 bytes)-- ← プラグインは動いていないのに、何も言われない
# --- plugin-include のソースを読んで、entryが {name, config} の配列だと分かった ---
cat cordis.yml
# - name: ./plugin-hello.mjs
# config:
# who: cordis
node node_modules/cordis/bin.js
# exit=0
# [hello] started with config = {"who":"cordis"}
# [hello] tick 1 ← 動いた
# --- 形式が正しくても、プラグインのファイルが無い場合 ---
# - name: ./does-not-exist.mjs
# -> exit=0 / stdout 0 bytes / stderr 0 bytes ← これも無言
# --- logger-console を載せて enableLogs:true にすると、同じケースで喋る ---
# 2026-08-17 12:12:34 [E] include Error [ERR_MODULE_NOT_FOUND]:
# Cannot find module '/demo/does-not-exist.mjs'
# 2026-08-17 12:12:35 [E] include TypeError:
# Cannot read properties of undefined (reading 'startsWith')
# at Include.import (.../plugin-loader/lib/index.js:224:14)
エントリ形式が違っていた回も、プラグインのファイルが存在しなかった回も、stdout と stderr がどちらも0バイトで exit 0 でした。設定を読んで、プラグインを1つも起動できなくて、それでも正常終了する。
ここも原因を追ったら納得はしました。エラーは捨てられているわけではなくて、cordis のロガーサービス宛てに送られているんですよね。ただ同梱の bin.js はロガープラグインを載せていないので、宛先が居ない。@cordisjs/plugin-logger-console を自分で載せて Include の enableLogs を true にしたら、同じケースで [E] include ... とスタック付きで出てきました。エラーの中身も十分に具体的で、startsWith で落ちている行まで分かります。
| 状況 | 同梱bin.jsのまま | logger-console + enableLogs:true |
|---|---|---|
| cordis.yml が無い | exit 1・例外が飛ぶ | 同じ |
| エントリ形式が違う | exit 0・出力0バイト | exit 0・[E] TypeError |
| プラグインのファイルが無い | exit 0・出力0バイト | exit 0・[E] ERR_MODULE_NOT_FOUND |
なお、ロガーを載せた場合もエラーは stdout 側(1,139バイト)に出て、stderr は0バイトのままでした。exit code も 0 で変わりません。CI で使うなら、終了コードだけを見る作りにはしないほうが安全だと思います。
試してみての所感
inject で読み込み順を書かなくてよくなるのと、ctx.effect() で後始末がラベル付きで追えるのは、素直に良い設計だと感じました。本体49KB・依存2つでこれが手に入るなら、プラグイン機構を自作しかけている場面では十分に候補になると思います。fiber の状態が外から観測できるのも、デバッグの取っ掛かりとして効きます。
一方で今回当たった無言の壁は、3つとも共通して「間違えたことが分からない」タイプでした。inject の綴りミスは PENDING のまま、yml のエントリ形式ミスとファイル欠損は0バイトの出力、waterfall の引数ミスは型を付けていなければ素通り。どれも動作としては筋が通っているんですが、初見で自力で気づけたものは1つもなくて、全部ソースを読んで理由を突き止めた形です。
なので自分は、しばらくは様子見しつつ、触る場合は最初にロガープラグインを載せて enableLogs を有効にし、TypeScript で書く、というところから入ります。ここは好みが分かれるところですが、bin.js が既定でロガーを載せてくれると初見の体験はだいぶ変わりそうだなと思いました。まだ 4.0.0-rc の途中で、README 自身が「API は予告なく変わる」と書いている段階なので、この辺りは今後変わっていく部分かもしれません。
HMR のパッケージも同梱されていたんですが、今回はそこまで手が回らなかったので未検証です。もっと良い使い方があったら教えてください。