> ## Documentation Index
> Fetch the complete documentation index at: https://pulian.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# @hile/reloader

> Stabilize config-driven runtime reloads with debounce, singleflight, latest-wins, safe disposal, and multi-source config aggregation.

# @hile/reloader

Stabilize config-driven runtime reloads with debounce, singleflight, latest-wins, safe disposal, and multi-source config aggregation.

## Choose This Package When

| User asks for                           | Use              | Also read                                                  |
| --------------------------------------- | ---------------- | ---------------------------------------------------------- |
| Stabilize config-driven runtime reloads | `@hile/reloader` | `packages/reloader.md`, `recipes/stable-runtime-reload.md` |

## Use When

Use `@hile/reloader` when runtime config can change frequently and service code needs debounce, singleflight reload, latest-wins behavior, and safe old-runtime preservation.

## Do Not Use When

* Do not use it as a durable queue.
* Do not use it to restart an HTTP server on the same port; keep the listener stable and reload handlers or runtime state.
* Do not pass `Map`, `Set`, class instances, functions, symbols, circular objects, or sparse arrays to the default comparator; use `signature` or `normalize`.
* Do not rely on timeouts to kill non-cooperative work; timeouts reject the reload path and abort the signal, and late-created runtimes are disposed best-effort.

## Install

```bash theme={null}
pnpm add @hile/reloader
```

## Imports

```ts theme={null}
import {
  createRuntimeReloader,
  createConfigAggregator,
  type RuntimeReloader,
  type ConfigAggregator,
} from '@hile/reloader'
```

## Copy-Paste Example

Stable HTTP shell with reloadable runtime:

```ts theme={null}
import type { Middleware } from 'koa'
import { createRuntimeReloader } from '@hile/reloader'

type RuntimeConfig = {
  mysql: { host: string; port: number }
  redis: { host: string; port: number }
  flags: Record<string, boolean>
}

type RuntimeHandler = Middleware & {
  close?: () => Promise<void> | void
}

const reloader = createRuntimeReloader<RuntimeConfig, RuntimeHandler>({
  debounceMs: 100,
  createTimeoutMs: 30_000,
  disposeTimeoutMs: 10_000,
  normalize: config => ({
    mysql: config.mysql,
    redis: config.redis,
    flags: config.flags,
  }),
  create: async config => {
    const runtime = await createRuntime(config)
    return runtime.handler
  },
  dispose: async handler => {
    await handler.close?.()
  },
  onError: (error, context) => {
    logger.error({ error, stage: context.stage }, 'runtime reload failed')
  },
})

http.use(async (ctx, next) => {
  const handler = reloader.current()
  if (!handler) {
    ctx.status = 503
    ctx.body = { error: 'runtime is not ready' }
    return
  }
  await handler(ctx, next)
})
```

## More Examples

Aggregate multiple micro config topics before reloading:

```ts theme={null}
import { createConfigAggregator, createRuntimeReloader } from '@hile/reloader'

type RuntimeConfig = {
  mysql: { host: string; port: number }
  redis: { host: string; port: number }
  flags: Record<string, boolean>
}

const configs = createConfigAggregator<RuntimeConfig>({
  required: ['mysql', 'redis'],
  defaults: { flags: {} },
  debounceMs: 100,
  normalize: config => ({
    mysql: config.mysql,
    redis: config.redis,
    flags: config.flags,
  }),
  onError: (error) => {
    logger.error({ error }, 'runtime config emit failed')
  },
})

const reloader = createRuntimeReloader<RuntimeConfig, Runtime>({
  debounceMs: 100,
  signature: config => `${config.mysql.host}:${config.mysql.port}|${config.redis.host}:${config.redis.port}`,
  create: createRuntime,
  dispose: runtime => runtime.close(),
})

configs.onChange(config => reloader.update(config))

const cleanupMysql = await app.subscribe('config:mysql', value => configs.set('mysql', value))
const cleanupRedis = await app.subscribe('config:redis', value => configs.set('redis', value))
const cleanupFlags = await app.subscribe('config:flags', value => configs.set('flags', value))

shutdown(async () => {
  await cleanupMysql()
  await cleanupRedis()
  await cleanupFlags()
  configs.dispose()
  await reloader.stop()
})
```

Manual file watcher:

```ts theme={null}
watch(configFile, async () => {
  const next = await loadConfig(configFile)
  await reloader.update(next)
})
```

## Runtime And Lifecycle Notes

* `RuntimeReloader.update(input)` schedules reload work and settles when the scheduler becomes idle.
* `RuntimeReloader.onStateChange(state)` fires when the observable state changes, including `status`, `hasCurrent`, and `hasPending` transitions.
* During an active reload, a newer update flips `hasPending` to `true`; when that pending input is consumed, `hasPending` flips back to `false`.
* If that input starts `create()` and `create()` fails, that `update()` rejects, the old runtime remains current, and the failed input is not marked applied.
* If an update is superseded before its input starts `create()`, it settles with the newer pending input because latest-wins coalesced it into that reload.
* Only one reload runs at a time.
* Updates during an active reload collapse to the latest pending input.
* Debounce timers reset while updates arrive before reload starts.
* `create()` must succeed before `current()` switches.
* After a successful switch, `dispose(oldRuntime)` runs; dispose failures are reported but do not roll back the new runtime.
* `stop()` rejects future updates, aborts an in-flight `create()` through `AbortSignal`, clears queued input, and disposes the current runtime.
* If `create()` resolves after timeout or abort, that late runtime is not made current and is disposed best-effort.
* Default compare priority is `equals`, then `signature`, then `normalize`, then an internal stable signature.
* The internal stable signature is not exported and only supports JSON-like plain config values plus `Date` and `bigint`.
* Default timeouts are 30 seconds for create and 10 seconds for dispose. Override with `createTimeoutMs` and `disposeTimeoutMs`.
* `ConfigAggregator.required` keys must be explicitly received; `defaults` do not satisfy required keys.
* `undefined` is an explicit received value. Use `unset(key)` to remove a value and mark the key as not received.
* `ConfigAggregator` serializes emits. If values change while listeners are running, the next emit uses the latest snapshot after the current emit finishes.
* `ConfigAggregator` records the last emitted signature only after all listeners finish successfully.
* `ConfigAggregator.flush()` rejects when a listener fails. Timer-driven emits call `onError` and leave the signature uncommitted so the same config can retry later.

## Anti-Patterns

* Recreating an HTTP server on the same port for every config change.
* Letting multiple subscribe callbacks each reload the same runtime independently.
* Comparing whole runtime objects instead of normalized config.
* Forgetting to call `stop()` during application shutdown.

## Verification Checklist

* Burst config updates produce at most one reload per debounce window.
* A reload failure leaves the previous runtime serving traffic.
* Multiple required config keys are present before the first reload.
* HTTP handlers read `reloader.current()` instead of rebinding the port.
* `onError` and `onCompareError` are connected to logs or metrics.

## Related Recipes

### Stable Runtime Reload

## Complete Example

```ts theme={null}
import { defineService, loadService } from '@hile/core'
import { Application } from '@hile/micro'
import { Http } from '@hile/http'
import { createConfigAggregator, createRuntimeReloader } from '@hile/reloader'
import appService from './micro.app.boot'
import httpService from './http.boot'

type RuntimeConfig = {
  mysql: { host: string; port: number }
  redis: { host: string; port: number }
  flags: Record<string, boolean>
}

export default defineService('runtime.reload', async (shutdown) => {
  const app = await loadService<Application>(appService)
  const http = await loadService<Http>(httpService)

  const reloader = createRuntimeReloader<RuntimeConfig, AppRuntime>({
    debounceMs: 100,
    normalize: config => ({
      mysql: config.mysql,
      redis: config.redis,
      flags: config.flags,
    }),
    create: config => createAppRuntime(config),
    dispose: runtime => runtime.close(),
    onError: (error, context) => {
      logger.error({ error, stage: context.stage }, 'runtime reload failed')
    },
  })

  const configs = createConfigAggregator<RuntimeConfig>({
    required: ['mysql', 'redis'],
    defaults: { flags: {} },
    debounceMs: 100,
    onError: (error) => {
      logger.error({ error }, 'runtime config emit failed')
    },
  })

  configs.onChange(config => reloader.update(config))

  const cleanupMysql = await app.subscribe('config:mysql', value => configs.set('mysql', value))
  const cleanupRedis = await app.subscribe('config:redis', value => configs.set('redis', value))
  const cleanupFlags = await app.subscribe('config:flags', value => configs.set('flags', value))

  http.use(async (ctx, next) => {
    const runtime = reloader.current()
    if (!runtime) {
      ctx.status = 503
      ctx.body = { error: 'runtime is not ready' }
      return
    }
    await runtime.handle(ctx, next)
  })

  shutdown(async () => {
    await cleanupMysql()
    await cleanupRedis()
    await cleanupFlags()
    configs.dispose()
    await reloader.stop()
  })

  return reloader
})
```

### Runtime Dynamic Config

## Complete Example

```ts theme={null}
import { z } from 'zod'
import { MicroDynamicConfigsServer } from '@hile/micro-dynamic-configs'

const schema = z.object({
  featureCheckout: z.boolean().default(false),
  maxRetries: z.number().int().min(1).max(10).default(3),
})

const configs = new MicroDynamicConfigsServer({
  app,
  redis,
  schema,
  redis_key: 'configs:checkout',
})

const cleanup = await configs.initialize()
shutdown(cleanup)

configs.on('change:featureCheckout', (next, previous) => {
  logger.info({ previous, next }, 'featureCheckout changed')
})

await configs.save({ featureCheckout: true })
```

## Package-Local AI Guide

This package also ships `AI.md` in npm so agents can read accurate examples after installation.
