Skip to content

Lazy services

craftLazy(load) code-splits a service, the way loadComponent code-splits a component — and reuses the same retry and cache-busting engine.

Use it when an expensive dependency is only needed on some paths: a PDF renderer, a chart library, an admin-only API client. Not for a service every route needs — the extra round-trip buys nothing.

craftLazy(load) lazily imports a module on demand from inside an async craft driver — an asyncProcess loader, or a route guard/resolver — reusing the exact same retry + cache-busting engine as route lazy loading (loadComponent / loadChildren).

Use it when you want to code-split a service function (an exported craftGen, an API helper, a heavy computation) and only fetch its chunk when it is actually needed, while keeping Craft's status / exception / reload semantics.

Why not a manual dynamic import?

The reflex is to reach for a manual import() and inject/call the result imperatively (an injectAsync-style helper):

ts
// ❌ manual: no status, no typed exceptions, no retry, not reactive
async function runSearch(q: string) {
  const { search } = await import('./search'); // may throw on a stale chunk
  return search(q); // exceptions are untyped, failures are unhandled
}

craftLazy replaces that with a first-class craft program:

ConcernManual import()craftLazy
Loading / resolved / exception stateyou wire it by handinherited from the enclosing asyncProcess (status())
Stale-chunk retry after a redeploynoneshared withRetry cache-busting engine
Import failurean unhandled rejectiona typed CRAFT_LAZY_LOAD_ERROR exception
The module's own business exceptionserased to anypreserved and propagated through the type system
Recoverymanual try/catch.pipe(catchTag(...)) or route handleExceptions

Signature

ts
craftLazy<T>(load: (helpers: CraftLazyLoadHelpers) => Promise<T>): CraftGenInvocation<never, T | CraftLazyLoadError>;

interface CraftLazyLoadHelpers {
  // Wrap the dynamic import so a chunk whose hashed URL went stale after a
  // redeploy is re-fetched with a cache-busting query param.
  withRetry<T>(moduleImport: Promise<T>): Promise<T>;
}
  • craftLazy(...) is a craftGen program: yield*-composable and .pipe(...)-able.
  • Its resolved value is the module T, untouched — the module's exported craftGens keep their own exception unions.
  • On a final import failure it returns a CraftLazyLoadError (code: 'CRAFT_LAZY_LOAD_ERROR'), which craftGen surfaces as a short-circuit → the enclosing resource's status() becomes 'exception'.

It must run in an async driver

craftLazy awaits its import through the async program pump, so it can only be yield*-ed from an asyncProcess loader or a route guard/resolver. It cannot be used inside a synchronous craftMethod (that driver throws on an await request). A craftMethod may only trigger the enclosing asyncProcess.

With asyncProcess

The module to split — an exported craftGen:

ts
// search.ts (its own chunk)
import { craftGen } from '@craft-ng/core';
import { SearchApi } from './search-api';

export const search = craftGen(function* (q: string) {
  const api = yield* SearchApi();
  return yield* api.search(q); // may raise E1 | E2
});

Load it from an asyncProcess loader. The simplest form triggers on demand with the generated method:

ts
import { asyncProcess, craftLazy } from '@craft-ng/core';

const searchModule = yield* asyncProcess('searchModule', {
  method: () => undefined, // call searchModule.method() to start loading
  loader: function* () {
    return yield* craftLazy(({ withRetry }) => withRetry(import('./search')));
  },
});

searchModule.status() walks idle → loading → resolved (or exception), exactly like any other asyncProcess, so the template can drive the UI:

html
@switch (searchModule.status()) { @case ('loading') { <spinner /> } @case
('exception') { <button (click)="searchModule.reload()">Réessayer</button> } }

To prefetch as soon as some event fires (the reactive equivalent of an eager injectAsync), bind the process to a source instead of a method:

ts
import { asyncProcess, craftLazy, on$ } from '@craft-ng/core';

const searchModule = yield* asyncProcess('searchModule', {
  // load at the first emission of the source (e.g. on focus of the search box)
  method: on$(searchFocused$, () => undefined),
  loader: function* () {
    return yield* craftLazy(({ withRetry }) => withRetry(import('./search')));
  },
});

Load once, use many

The canonical pattern: one asyncProcess owns the module, a second one awaits it with craftUntilSettled and calls the loaded function. Wrapping both in a craftService exposes a clean API:

ts
import {
  asyncProcess,
  craftLazy,
  craftService,
  craftUntilSettled,
  on$,
} from '@craft-ng/core';

const { Search } = craftService({ name: 'Search', scope: 'component' }, () => {
  // prefetch the module at the first emission of the source
  const searchModule = yield* asyncProcess('searchModule', {
    method: on$(searchFocused$, () => undefined),
    loader: function* () {
      return yield* craftLazy(({ withRetry }) =>
        withRetry(import('./search')),
      );
    },
  });

  // run a search on a user action — triggerSearch(q) sets the params
  const searchResult = yield* asyncProcess('searchResult', {
    method: (q: string) => q,
    loader: function* ({ params: q }) {
      const { search } = yield* craftUntilSettled(searchModule); // wait for the chunk
      return yield* search(q);
    },
  });

  return { searchModule, searchResult };
});

Exception propagation is fully typed, with no manual plumbing:

  • craftLazy may add CRAFT_LAZY_LOAD_ERROR;
  • craftUntilSettled(searchModule) relays it to searchResult;
  • search(q) relays its own E1 | E2.

So searchResult.exception()?.code is exactly 'CRAFT_LAZY_LOAD_ERROR' | 'E1' | 'E2', and searchResult.value() keeps the return type of search.

In routes

Guards and resolvers are async drivers too, so you can yield* craftLazy(...) directly inside them. A failed import surfaces as CRAFT_LAZY_LOAD_ERROR and flows into the route's exception handlers, exactly like any other guard/resolver exception:

ts
craftRoute(
  'search',
  {
    resolve: craftResolve(function* () {
      const { search } = yield* craftLazy(({ withRetry }) =>
        withRetry(import('./search')),
      );
      return yield* search('*');
    }),
  },
  {
    CRAFT_LAZY_LOAD_ERROR: craftExceptionHandler(function* ({ redirectTo }) {
      return yield* redirectTo({ to: 'offline' });
    }),
    E1: craftExceptionHandler(function* () {
      return [] as Result[];
    }),
    // E2 left unhandled → surfaces as a route exception
  },
);

This is the code-splitting counterpart of a lazy loadComponent: instead of splitting the component, you split the data-loading logic it depends on, with the same retry + error screen guarantees as Route Load Errors.

Handling the load error

CRAFT_LAZY_LOAD_ERROR is an ordinary craft exception, so all the usual tools apply.

Catch it at the source (fall back to another module or a default), which removes it from the exception union:

ts
loader: function* () {
  return yield* craftLazy(({ withRetry }) => withRetry(import('./search'))).pipe(
    catchTag('CRAFT_LAZY_LOAD_ERROR', function* () {
      return yield* craftLazy(({ withRetry }) => withRetry(import('./search-fallback')));
    }),
  );
}

Catch a business exception of the loaded functionsearch(q) is itself a pipeable craftGen:

ts
loader: function* ({ params: q }) {
  const { search } = yield* craftUntilSettled(searchModule);
  return yield* search(q).pipe(
    catchTag('E1', function* () { return [] as Result[]; }),
    // E2 stays in searchResult.exceptions()
  );
}

Read it reactively — anything left uncaught keeps status() at 'exception' and shows up in exceptions() / hasException(), ready to render in the template.

See Program Operators for catchTag / catchTag.exhaustive.

Retry & cache-busting

withRetry(import(...)) is what makes a stale chunk recover after a redeploy: on failure the chunk URL is re-fetched with a cache-busting query param. The attempt/back-off policy is injectable and defaults to the shared craft loader retry (one retry, 250 ms):

ts
import { provideCraftLazyLoadRetry } from '@craft-ng/core';

providers: [
  provideCraftLazyLoadRetry({
    attempts: 2,
    delayMs: (error, ctx) => 250 * ctx.attempt,
    shouldRetry: (error) => isRecoverable(error),
  }),
];

The dynamic import(url) used for cache-busting is itself overridable through CRAFT_DYNAMIC_IMPORT (useful in tests). This is the very same engine as route load retry, so a craftLazy import and a lazy route load behave identically under a bad deployment.

API

ExportPurpose
craftLazy(load)Lazily import a module from an async craft driver.
CraftLazyLoadHelpersThe { withRetry } helpers passed to load.
CraftLazyLoadError / CRAFT_LAZY_LOAD_ERROR_CODEThe exception (and its code) returned on a final import failure.
provideCraftLazyLoadRetry(config) / CRAFT_LAZY_LOAD_RETRYConfigure the craftLazy retry policy.
CRAFT_DYNAMIC_IMPORTOverride the dynamic import(url) (cache-busting / tests).

See Also