Back to list
dev_to 2026年4月25日

KickJS Asset Manager — デバイクからリリースまで(Type-safe) ファイル解像度の向上

KickJS Asset Manager — Type-Safe File Resolution from Dev to Dist

Translated: 2026/4/25 4:00:49
kickjsasset-managementtypescriptnodejsbuild-tools

Japanese Translation

Node.js サービスがソースコード以外にディスクから何かを読み取らなければならない時、誰もが一度は経験するわずかな不快感があります。電子メールテンプレート、JSON スキーマ、シードフィクスチャ、あるいはベンダーされた PDF 文件など。最初のバージョンは常に一行コードになります:fs.readFile(path.join(__dirname, '../templates/welcome.ejs'))。これはあなたのラップトップでは機能します。CI でも機能します。しかし、本番環境にデプロイすると、バндлерが出力を扁平化し、__dirname が dist/handlers/ を示すように変化し、ソース/handlers/ を指すように変わります。相対パスの登攀がテンプレートディレクトリを見落とし、最初のパスワードリセットをトリガーしたユーザーが 500 エラーを受けることになります。実用的な解決策は、コードベース全体に process.env.NODE_ENV === 'production' ? '../../dist/templates' : '../templates' を散らすことです。原則的な解決策は、オンディスクの資産がビルド時の関心事であることを受け入れ、それだけの小さな型システムに値するということだということです。それが KickJS Asset Manager です。サーバーサイドの資産解像度には、あなたを害し合うための 3 つのプロパティがあります。第一に、パスは開発環境と dist で異なります。開発環境は src/ を対し、dist 環境は異なるレイアウトを持つビルド出力に対しており、それは最小化されたり、バンドルされたり、異なるツールによってエミットされたり、あなたのハンドラーをコンパイルしたツールとは異なるものであり得ます。第二に、キーは弱くタイプ化されています。readFile('templates/welcome.ejs') はただの文字列です。ファイル名を変更し、コールサイトを更新することを忘れると、TypeScript は何も示しません。あなたは通常、Sentry のアラートやエンドユーザーから、実行時で発見することになります。第三に、クレンジングの物語は悪いことです。NODE_ENV チェック、try/catch バックアップ、条件付き__dirname 演算、非対称性を覆うヘルパー関数——オンディスク資産を持つすべてのコードベースは、これらの小さな墓地を蓄積して行きます。それらは個々の正解ではありましたが、集合的には非整合的です。Asset Manager は、これら 3 つの問題を 1 つのメンタルモデルで置き換えます。ビルド時に、あなたが気にするすべての資産は既知の場所にコピーされ、マニフェストに記録されます。実行時、あなたはマニフェストから生成された形状を持つ型されたプロキシを通じてこれらの資産をアクセスします。NODE_ENV、__dirname 算術、型されたキーがチェックできない文字列の存在はありません。2 つのアーティファクトが重労働を行います。第一が .kickjs-assets.json です。これはビルド時に dist/ にエミットされるマニフェストです。「このファイルシステム上、mails.welcome という資産はどこにあるか」ということへの正規の答えです。それはフラットな JSON オブジェクトです——カテゴリーがトップレベル、スラッグがその下に、絶対パスまたは dist 相対パスが値です。第二が .kickjs/types/assets.d.ts です。これはフレームワーク提供される KickAssets インターフェースを拡張する生成された宣言ファイルです。インターフェースはマニフェストの形状を正確に反映します。KickAssets['mails']['welcome'] が存在するならばと、マニフェストに mails.welcome に関するエントリがあるとは等価です。その拡張がプロキシの型を与えます。実行時サイドはプロキシ(またはそのいくつかの形式——下記参照)です。assets.mails.welcome() と書くとき、プロキシはパス ['mails', 'welcome'] を通り、スラッグをマニフェストで検索し、解像されたファイルパスを返します。ファイルシステム読み取り、環境チェック、ハンドラー内の path.join 演算はありません。結果として、資産解像度は型されたキーから文字列パスへの純粋な関数になり、すべての誤字はコンパイルエラーになります。KickJS は 4 つの消費パターンを提供します。それらはすべて同じマニフェストを通じて解像され、差は純粋に人間工学です。デフォルトで最も一般的なパターン。一度インポートし、どこでもアクセス。import { assets } from '@forinda/kickjs'; const path = assets.mails.welcome(); const html = await renderEjs(await readFile(path, 'utf8'), { user }); assets は深いネストされたプロキシであり、その形状は拡張された KickAssets インターフェースから来ます。IDE 自動補完はすべてのレベルで機能します:assets. はカテゴリーを示し、assets.mails. はスラッグを示します。useAssets() — ズームファクトリ Sometimes you want the resolver as a value, not a singleton. useAssets() returns the same proxy but as a factory call, which is convenient when you want to swap implementations in tests or pass the resolver through a D

Original Content

There is a familiar little discomfort that shows up the first time a Node.js service needs to read something off disk that is not source code. An email template. A JSON schema. A seed fixture. A vendored PDF. The first version is always a one-liner: fs.readFile(path.join(__dirname, '../templates/welcome.ejs')). That works on your laptop. It also works in CI. And then you ship to production, your bundler flattens the output, __dirname points at dist/handlers/ instead of src/handlers/, the relative climb misses the templates directory, and the first user who triggers a password reset gets a 500. The pragmatic fix is to scatter process.env.NODE_ENV === 'production' ? '../../dist/templates' : '../templates' across the codebase. The principled fix is to admit that on-disk assets are a build-time concern that deserves its own little type system. That is what the KickJS Asset Manager is. Server-side asset resolution has three properties that conspire against you. First, the path differs between dev and dist. Dev runs against src/, dist runs against a built output that may have a different layout, possibly minified, possibly bundled, possibly emitted by a different tool than the one that compiled your handlers. Second, the keys are stringly-typed. readFile('templates/welcome.ejs') is just a string. Rename the file, forget to update the call site, and TypeScript gives you nothing — you find out at runtime, usually from a Sentry alert, usually from an end user. Third, the cleanup story is bad. NODE_ENV checks, try/catch fallbacks, conditional __dirname math, helper functions that paper over the asymmetry — every codebase with on-disk assets accretes a small graveyard of these. They are correct individually and incoherent collectively. The Asset Manager replaces all three problems with a single mental model: at build time, every asset you care about is copied into a known location and recorded in a manifest. At runtime, you address those assets through a typed proxy whose shape is generated from the manifest. There is no NODE_ENV, no __dirname arithmetic, and no string keys that the compiler cannot check. Two artifacts do the heavy lifting. The first is .kickjs-assets.json, a manifest emitted into dist/ during build. It is the canonical answer to "where, on this filesystem, is the asset named mails.welcome?". It is a flat JSON object — categories at the top level, slugs underneath, absolute or dist-relative paths as values. The second is .kickjs/types/assets.d.ts, a generated declaration file that augments the framework-supplied KickAssets interface. The interface mirrors the manifest's shape exactly: KickAssets['mails']['welcome'] exists if and only if the manifest has an entry for mails.welcome. That augmentation is what gives the proxy its types. The runtime side is a Proxy (or several flavours of one — see below). When you write assets.mails.welcome(), the proxy walks the path ['mails', 'welcome'], looks the slug up in the manifest, and returns the resolved file path. No filesystem reads, no environment checks, no path.join math in your handlers. The result is that asset resolution becomes a pure function from a typed key to a string path, and every typo is a compile error. KickJS gives you four consumption patterns. They all resolve through the same manifest — the difference is purely ergonomic. The default and most common pattern. Import once, address anywhere. import { assets } from '@forinda/kickjs'; const path = assets.mails.welcome(); const html = await renderEjs(await readFile(path, 'utf8'), { user }); assets is a deeply-nested proxy whose shape comes from the augmented KickAssets interface. IDE autocomplete works at every level: assets. shows categories, assets.mails. shows slugs. useAssets() — the hook factory Sometimes you want the resolver as a value, not a singleton. useAssets() returns the same proxy but as a factory call, which is convenient when you want to swap implementations in tests or pass the resolver through a DI container. import { useAssets } from '@forinda/kickjs'; class MailService { constructor(private readonly assets = useAssets()) {} welcome() { return this.assets.mails.welcome(); } } This is the form to reach for when you would otherwise be tempted to mock the import. @Asset() — the class field decorator For class-based services where you want the resolution to be lazy and declarative. import { Asset } from '@forinda/kickjs'; class MailService { @Asset('mails/welcome') private welcomeTpl!: string; async send(user: User) { const html = await renderEjs(await readFile(this.welcomeTpl, 'utf8'), { user }); } } The decorator installs a getter that resolves on first access. The string argument is type-checked against the manifest, so @Asset('mails/does-not-exist') is a compile error. resolveAsset() — the dynamic escape hatch When the key really is dynamic — an admin choosing a template at runtime, a feature-flagged variant — fall back to the function form. import { resolveAsset } from '@forinda/kickjs'; const path = resolveAsset('mails', slug); // slug: string You lose the per-key compile-time check, but the category is still typed and a missing slug throws a meaningful runtime error rather than returning a bogus path. The split between build and runtime is what makes the whole thing tractable. kick build (or the focused kick build:assets) reads assetMap from kick.config.ts. Each entry has a src path (required), an optional dest (defaults to the entry's name), and an optional glob (defaults to **/*). For every entry, the builder walks the glob, copies matching files to dist//, and records each one in .kickjs-assets.json. After the copy, kick typegen regenerates .kickjs/types/assets.d.ts so the type system reflects what was actually emitted. // kick.config.ts export default defineConfig({ assetMap: { mails: { src: 'src/templates/mails', glob: '**/*.ejs' }, schemas: { src: 'src/schemas', glob: '**/*.json' }, fixtures:{ src: 'src/fixtures', dest: 'fixtures' } } }); At runtime, resolution follows a strict order. First, the manager checks KICK_ASSETS_ROOT — if set, that directory is treated as the asset root and every lookup is rooted there. Second, it tries to load .kickjs-assets.json from dist/ (or wherever the manager was initialized). Third — and only when running from source with no manifest present — it synthesizes a manifest by walking src/ according to assetMap, so that pnpm dev works without a build step. The contract is straightforward: in production you have a manifest and the proxy is essentially a hash lookup; in dev you have a synthesized one and the proxy is essentially a filesystem walk plus a cache. Same API, same types, different machinery. kick typegen is the piece that closes the loop. It reads the manifest and writes a single .d.ts that augments the KickAssets interface declared by @forinda/kickjs: declare module '@forinda/kickjs' { interface KickAssets { mails: { welcome: AssetRef; 'password-reset': AssetRef }; schemas: { user: AssetRef }; } } Once that file is on disk, assets.mails.does_not_exist() is a compile error, not a runtime exception. Filenames that are not valid identifiers — anything with a hyphen, a leading digit, a dot — are still safely addressable through bracket notation: assets.mails['password-reset'](). The generated interface uses string-literal keys for everything, so the bracket form keeps full autocomplete. Run kick typegen (or let kick build do it for you) after any change to assetMap or to the files those entries point at. CI should treat a stale .kickjs/types/assets.d.ts as a failure. Because every lookup goes through the manifest, you can override the entire asset root at the process level by setting KICK_ASSETS_ROOT before the manager is first touched. Combined with clearAssetCache() to reset the manager between tests, this gives you a clean way to swap in fixtures without mocking. beforeEach(() => { process.env.KICK_ASSETS_ROOT = path.join(__dirname, 'fixtures'); clearAssetCache(); }); In a unit test for MailService, you can point at a fixtures/mails/welcome.ejs containing HELLO {{NAME}} and assert on the rendered output without touching the real templates. The same trick works for snapshot fixtures, JSON-schema variants, anything where you want a known-good input. The Asset Manager is the right tool whenever the runtime answer is fs.readFile. Email templates rendered with EJS, Handlebars, or MJML. JSON schemas loaded by Ajv. Seed fixtures. PDFs and CSVs that ship with the app. Anything that lives on disk in dev, needs to live on disk in dist, and is consumed by your own server-side code. It is not the right tool for HTTP-served static assets. Public images, downloadable user-facing files, anything a browser fetches — those want a static-file adapter or a CDN, not the Asset Manager. The line is "who reads this file?". If the answer is your handlers, use the manager. If the answer is a browser, use a serving layer. The payoff for the in-scope cases is that you stop writing path math, you stop scattering environment checks, and you find typos at compile time instead of at 2 a.m. Once the manifest exists, the type system does the rest. KickJS framework on GitHub KickJS Asset Manager guide @forinda/kickjs on npm