Skip to content

Asset Manager

Type-safe path resolution for files that live near your code in dev but get copied to dist/ in prod — mail templates, report templates, JSON fixtures, schemas, anything you fs.readFile() at runtime.

The problem it solves

Without the asset manager, every render handler ends up doing some variant of:

ts
const path =
  process.env.NODE_ENV === 'production'
    ? join(__dirname, '../templates/mails/welcome.ejs')
    : join(__dirname, '../../src/templates/mails/welcome.ejs')

Three things go wrong:

  • Typos compile. 'welcom.ejs' doesn't fail until the render handler fires.
  • Manual dev/prod branching at every call site.
  • __dirname arithmetic breaks under monorepos, ESM, worker threads, bundled dist layouts.

With the asset manager

ts
import { assets } from '@forinda/kickjs'

const path = assets.mails.welcome() // typed, autocompletes, dev/prod handled
const html = await ejs.renderFile(path, data)

One resolver, three accessor variants, full TypeScript autocomplete after kick typegen runs.

Configure

Add assetMap to kick.config.ts:

ts
import { defineConfig } from '@forinda/kickjs-cli'

export default defineConfig({
  // copyDirs is unchanged — keeps doing raw directory copies for
  // adopters who don't want the typed surface.
  copyDirs: ['src/static'],

  // assetMap drives the typed asset manager.
  assetMap: {
    mails: { src: 'src/templates/mails' },
    reports: { src: 'src/templates/reports', glob: '**/*.{ejs,html}' },
    schemas: { src: 'src/schemas', glob: '**/*.json' },
  },
})

Each entry shape:

FieldRequiredDefaultNotes
srcyesSource directory, relative to project root.
destnodist/<name>/Destination inside dist/. Useful for matching downstream layouts.
globno**/*File filter. Common forms: **/*.ejs, **/*.{ejs,html}.
keysno'auto'Key strategy — 'auto', 'strip', or 'with-extension'. See Key strategies.

copyDirs and assetMap are independent — you can use both, neither, or only one. copyDirs is "copy this verbatim"; assetMap adds typed addressing on top of its own copy step.

Key strategies

The keys field controls how a file's source path becomes its manifest key — and therefore the property name on the typed assets.<ns> Proxy. Build, runtime dev resolver, and typegen all share one helper (groupAssetKeys() in @forinda/kickjs) so the three layers can never disagree.

StrategyStripped on conflict-free?Behaviour on collisionsWhen to pick it
'auto' (default)yesKeeps the extension on every file in the colliding groupYou don't think about it — welcome.ejs stays welcome, but if a sibling adds welcome.html later both files become addressable as welcome.ejs / welcome.html.
'strip'yesLast-walk-order wins under one stripped key (lossy)You're certain every basename is unique within the namespace and want short autocomplete (mails.welcome).
'with-extension'no — extensions always keptNot applicable (no collisions possible)You want uniform keys regardless of siblings — pages.{"index.html"}, pages.{"index.pug"} always quoted.

Auto-mode resolves collisions group-by-group, so a namespace can mix safe singletons with collision groups. With

src/templates/mails/welcome.ejs           → mails.welcome
src/templates/pages/index.html            → pages["index.html"]
src/templates/pages/index.pug             → pages["index.pug"]
src/templates/pages/about.html            → pages.about

the build emits one informational log per collision group it auto-resolved, e.g. assetMap.pages: auto-resolved 1 basename collision(s) by keeping extensions. Set keys: 'strip' to opt back into the legacy single-survivor behaviour, or keys: 'with-extension' to make every key in the namespace verbose for consistency.

Run typegen

bash
kick typegen

This walks every assetMap.*.src directory and emits .kickjs/types/assets.d.ts augmenting the framework's KickAssets interface:

ts
declare module '@forinda/kickjs' {
  interface KickAssets {
    mails: {
      welcome: () => string
      'password-reset': () => string
      orders: {
        confirmation: () => string
      }
    }
    reports: {
      monthly: () => string
    }
  }
}

kick dev runs typegen automatically (initial pass + on-change). For one-shot CI builds, kick build runs it after the JS build. Adopters who hand-roll their build pipeline can call kick typegen themselves.

Four ways to consume an asset

All four hit the same resolver under the hood — pick whichever fits the call site.

1. Proxy ambient — the default

ts
import { assets } from '@forinda/kickjs'

const path = assets.mails.welcome()
const html = await ejs.renderFile(path, data)

Most ergonomic, full IDE autocomplete, works for static call sites.

2. Hook factory — class fields + DI

ts
import { useAssets } from '@forinda/kickjs'

class MailService {
  private assets = useAssets()

  send(name: 'welcome' | 'password-reset', data: unknown) {
    return ejs.renderFile(this.assets.mails[name](), data)
  }
}

Same Proxy as assets, exposed via factory. Useful when:

  • The class wants the asset object as a field.
  • Tests need to mock the import (vi.mock('@forinda/kickjs', () => ({ useAssets: () => fakeAssets }))).
  • DI container should own the assets binding (container.registerInstance(ASSETS, useAssets()) then inject with @Inject(ASSETS)).

3. @Asset decorator — declarative class field

ts
import { Service, Asset } from '@forinda/kickjs'

@Service()
class MailService {
  @Asset('mails/welcome')
  private welcomeTemplate!: string

  send(user: User) {
    return ejs.renderFile(this.welcomeTemplate, { user })
  }
}

Mirrors @Value's lazy getter. Resolves on every property access through the same cached resolver — tests can swap fixtures + clearAssetCache() without re-instantiating the consuming class.

4. String resolver — dynamic dispatch

ts
import { resolveAsset } from '@forinda/kickjs'

function renderTemplate(category: string, slug: string): string {
  // namespace + key not known at compile time
  return resolveAsset(category, slug)
}

Escape hatch for cases where the typed Proxy's shape doesn't fit (CMS-style template selection, code generators, dynamic test fixtures). Throws UnknownAssetError on miss with namespace + key fields on the error object so callers can branch.

How resolution works at runtime

The resolver's discovery pipeline (cached after first call):

  1. KICK_ASSETS_ROOT env override — if set, treated as the manifest directory and loaded directly. Useful for test fixtures and Docker mount overrides.
  2. Built manifest — first tries the manifest under your configured build.outDir (when kick.config.ts declares one), then falls back to dist/, build/, out/ under cwd in order. Manifest existence = "we're running from a built dist"; mapped paths returned verbatim. Fast path; dominant in production.
  3. Dev fallback — synthesises an in-memory manifest by reading kick.config.{json,cjs,js} + walking each assetMap src dir. Cached after the walk.

kick build writes the manifest at <config.build.outDir>/.kickjs-assets.json. If your project uses a non-default Vite output directory, set it in your config so the resolver lines up:

ts
export default defineConfig({
  build: { outDir: 'out' }, // or 'build', whatever Vite is set to
  assetMap: { ... },
})

Build pipeline

kick build runs these steps in order:

  1. JS build via Vite (existing).
  2. copyDirs — raw directory copies (existing, unchanged).
  3. assetMap — for each entry: walks src/... matching glob, copies matches into dest.
  4. Writes <outDir>/.kickjs-assets.json (the manifest).
  5. Runs kick typegen if the project has a kick.config.ts.

For a manifest-only refresh (no JS rebuild), use:

bash
kick build:assets

Useful in dev when you've just added a template and want the manifest to pick it up without running the full Vite build.

Edge cases

Same basename in one directory (index.html + index.pug)

Default behaviour (keys: 'auto'): both files copy to dist and both are addressable — the colliding group keeps extensions, so the manifest holds <ns>/index.html and <ns>/index.pug (typed surface: assets.<ns>['index.html'](), assets.<ns>['index.pug']()). The build prints one informational log per group: auto-resolved N basename collision(s) by keeping extensions.

Three ways to opt out of auto-mode:

  • keys: 'with-extension' — keep extensions on every key in the namespace (uniform shape across the namespace, even for non-colliding files).
  • keys: 'strip' — restore the legacy "last-walk-order wins" behaviour under one stripped key. Lossy: only one file is addressable.
  • Tighten glob to a single extension (glob: '**/*.html') so the second file never enters the namespace in the first place.

Files with non-identifier names

welcome-email.ejs becomes assets.mails['welcome-email']() — the typegen quotes non-identifier keys. Bracket access works, but it's less ergonomic than bare property access. Rename to welcomeEmail.ejs or welcome_email.ejs if you want the autocomplete sugar.

File vs. directory at the same path

If you have both mails/welcome.ejs and mails/welcome/login.ejs, the typegen renderer promotes welcome to a sub-object (the directory wins). The build pipeline still copies both files, but only welcome.login is addressable through the Proxy.

Dev-mode glob

The build uses the full glob package. The runtime dev-mode resolver uses a lite matcher covering **/*, **/*.ext, and **/*.{a,b} — anything more exotic warns once and accepts every file. Run kick build:assets to use the full glob engine in dev.

Testing

Tests can swap fixtures by pointing KICK_ASSETS_ROOT at a directory containing a .kickjs-assets.json they wrote, then calling clearAssetCache() to invalidate the resolver:

ts
import { clearAssetCache } from '@forinda/kickjs'

beforeEach(() => {
  process.env.KICK_ASSETS_ROOT = join(__dirname, 'fixtures/assets')
  clearAssetCache()
})

afterEach(() => {
  delete process.env.KICK_ASSETS_ROOT
  clearAssetCache()
})

@Asset-decorated classes pick up the new fixtures on the next property access — no need to re-instantiate the class.

Reference

  • @forinda/kickjs exports: assets, useAssets, resolveAsset, Asset, ASSETS (DI token), clearAssetCache, UnknownAssetError, KickAssets (type), AssetKey (type), ASSET_MANIFEST_VERSION.
  • Manifest format: dist/.kickjs-assets.json with { version: 1, entries: { '<namespace>/<key>': '<relative-path>' } }.
  • DI token name: 'kick/assets/Map' (per the §22 v4 token convention).

See also

  • View Enginesassets.views.dashboard() works inside ctx.render() calls when you've named the views directory in assetMap.
  • Configuration — the full kick.config.ts schema.
  • Type Generationkick typegen end-to-end + KickAssets augmentation alongside KickEnv / KickJsRegistry.