alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,514 lines (1,327 loc) • 44.3 kB
text/typescript
import { KIND } from "./constants/KIND.ts";
import { MODULE } from "./constants/MODULE.ts";
import { OPTIONS } from "./constants/OPTIONS.ts";
import { AlephaError } from "./errors/AlephaError.ts";
import { CircularDependencyError } from "./errors/CircularDependencyError.ts";
import { ContainerLockedError } from "./errors/ContainerLockedError.ts";
import { TooLateSubstitutionError } from "./errors/TooLateSubstitutionError.ts";
import { coerceObject } from "./helpers/coerceStrings.ts";
import { Primitive } from "./helpers/primitive.ts";
import { __alephaRef } from "./helpers/ref.ts";
import type { Async } from "./interfaces/Async.ts";
import type { LoggerInterface } from "./interfaces/LoggerInterface.ts";
import {
type InstantiableClass,
isClass,
type RunFunction,
type Service,
type ServiceEntry,
} from "./interfaces/Service.ts";
import type { Atom, AtomStatic, TAtomObject } from "./primitives/$atom.ts";
import type { Computed } from "./primitives/$computed.ts";
import type { InjectOptions } from "./primitives/$inject.ts";
import { Module, type WithModule } from "./primitives/$module.ts";
import { AlsProvider, type StateScope } from "./providers/AlsProvider.ts";
import { CodecManager } from "./providers/CodecManager.ts";
import { EventManager } from "./providers/EventManager.ts";
import { StateManager } from "./providers/StateManager.ts";
import {
type Static,
type TObject,
type TSchema,
z,
} from "./providers/TypeProvider.ts";
/**
* Core container of the Alepha framework.
*
* It is responsible for managing the lifecycle of services,
* handling dependency injection,
* and providing a unified interface for the application.
*
* @example
* ```ts
* import { Alepha, run } from "alepha";
*
* class MyService {
* // business logic here
* }
*
* const alepha = Alepha.create({
* // state, env, and other properties
* })
*
* alepha.with(MyService);
*
* run(alepha); // trigger .start (and .stop) automatically
* ```
*
* ### Alepha Factory
*
* Alepha.create() is an enhanced version of new Alepha().
* - It merges `process.env` with the provided state.env when available.
* - It populates the test hooks for Vitest or Jest environments when available.
*
* new Alepha() is fine if you don't need these helpers.
*
* ### Platforms & Environments
*
* Alepha is designed to work in various environments:
* - **Browser**: Runs in the browser, using the global `window` object.
* - **Serverless**: Runs in serverless environments like Vercel or Vite.
* - **Test**: Runs in test environments like Jest or Vitest.
* - **Production**: Runs in production environments, typically with NODE_ENV set to "production".
* * You can check the current environment using the following methods:
*
* - `isBrowser()`: Returns true if the App is running in a browser environment.
* - `isServerless()`: Returns true if the App is running in a serverless environment.
* - `isTest()`: Returns true if the App is running in a test environment.
* - `isProduction()`: Returns true if the App is running in a production environment.
*
* ### State & Environment
*
* The state of the Alepha container is stored in the `store` property.
* Most important property is `store.env`, which contains the environment variables.
*
* ```ts
* const alepha = Alepha.create({ env: { MY_VAR: "value" } });
*
* // You can access the environment variables using alepha.env
* console.log(alepha.env.MY_VAR); // "value"
*
* // But you should use $env() primitive to get typed values from the environment.
* class App {
* env = $env(
* z.object({
* MY_VAR: z.text(),
* })
* );
* }
* ```
*
* ### Modules
*
* Modules are a way to group services together.
* You can register a module using the `$module` primitive.
*
* ```ts
* import { $module } from "alepha";
*
* class MyLib {}
*
* const myModule = $module({
* name: "my.project.module",
* services: [MyLib],
* });
* ```
*
* Do not use modules for small applications.
*
* ### Hooks
*
* Hooks are a way to run async functions from all registered providers/services.
* You can register a hook using the `$hook` primitive.
*
* ```ts
* import { $hook } from "alepha";
*
* class App {
* log = $logger();
* onCustomerHook = $hook({
* on: "my:custom:hook",
* handler: () => {
* this.log?.info("App is being configured");
* },
* });
* }
*
* Alepha.create()
* .with(App)
* .start()
* .then(alepha => alepha.events.emit("my:custom:hook"));
* ```
*
* Hooks are fully typed. You can create your own hooks by using module augmentation:
*
* ```ts
* declare module "alepha" {
* interface Hooks {
* "my:custom:hook": {
* arg1: string;
* }
* }
* }
* ```
*
* @module alepha
*/
export class Alepha {
/**
* Creates a new instance of the Alepha container with some helpers:
*
* - merges `process.env` with the provided state.env when available.
* - populates the test hooks for Vitest or Jest environments when available.
*
* If you are not interested about these helpers, you can use the constructor directly.
*/
public static create(state: Partial<State> = {}): Alepha {
// merge process.env with the state.env
if (typeof process === "object" && typeof process.env === "object") {
state.env = {
...process.env,
...state.env,
};
}
// force production mode when building with vite
// vite's define replaces `process.env.NODE_ENV` with `"production"` at build time,
// but the spread above doesn't carry it (especially in workerd/cloudflare).
if (typeof process === "object" && process.env?.NODE_ENV === "production") {
state.env ??= {};
Object.assign(state.env, {
NODE_ENV: "production",
});
}
// Browser/Vite builds have no `process`, so both blocks above are
// stripped as dead code — leaving `NODE_ENV` unset and `isProduction()`
// always false in the browser bundle. Fall back to Vite's build-time
// `import.meta.env.PROD` flag, baked at build time. It is `undefined`
// outside Vite (server bundles, workerd) so this is a no-op there.
if (state.env?.NODE_ENV == null) {
const viteEnv = (import.meta as { env?: { PROD?: boolean } }).env;
if (viteEnv?.PROD) {
state.env ??= {};
Object.assign(state.env, {
NODE_ENV: "production",
});
}
}
const alepha = new Alepha(state);
if (alepha.isTest()) {
// inject global hooks for testing purposes
// > for vitest, { globals: true } is required in the config
const g = globalThis as any;
const beforeAll = state["alepha.test.beforeAll"] ?? g.beforeAll;
const afterAll = state["alepha.test.afterAll"] ?? g.afterAll;
const afterEach = state["alepha.test.afterEach"] ?? g.afterEach;
const onTestFinished =
state["alepha.test.onTestFinished"] ?? g.onTestFinished;
beforeAll?.(() => alepha.start());
afterAll?.(() => alepha.stop());
try {
onTestFinished?.(() => alepha.stop());
} catch (_error) {
// ignore
}
alepha.store
.set("alepha.test.beforeAll", beforeAll)
.set("alepha.test.afterAll", afterAll)
.set("alepha.test.afterEach", afterEach)
.set("alepha.test.onTestFinished", onTestFinished);
} else {
state["alepha.logger"] ??= {
trace: console.log,
debug: console.debug,
info: console.log,
warn: console.warn,
error: console.error,
};
}
return alepha;
}
/**
* Flag indicating whether the App won't accept any further changes.
* Pass to true when #start() is called.
*/
protected locked = false;
/**
* True if the App has been configured.
*/
protected configured = false;
/**
* True if the App has started.
*/
protected started = false;
/**
* True if the App is ready.
*/
protected ready = false;
/**
* In-flight startup promise returned by boot().
*
* Concurrent callers of start() share this same promise. Cleared on
* success, failure, or stale-detection.
*/
protected startPromise?: Promise<this>;
/**
* Timestamp (performance.now) when the current boot() began.
*
* In serverless environments (e.g. Cloudflare Workers), the runtime can
* kill an invocation mid-startup without running cleanup. The global
* Alepha instance persists, leaving startPromise as a never-settling
* promise. We detect this by comparing elapsed time against STARTUP_TIMEOUT.
*/
protected startedAt = 0;
/**
* During the instantiation process, we keep a list of pending instantiations.
* > It allows us to detect circular dependencies.
*/
protected pendingInstantiations: Service[] = [];
/**
* Cache for environment variables.
* > It allows us to avoid parsing the same schema multiple times.
*/
protected cacheEnv: Map<TSchema, any> = new Map();
/**
* List of modules that are registered in the container.
*
* Modules are used to group services and provide a way to register them in the container.
*/
protected modules: Array<Module> = [];
/**
* List of service substitutions.
*
* Services registered here will be replaced by the specified service when injected.
*/
protected substitutions = new Map<Service, { use: Service }>();
/**
* Registry of primitives.
*/
protected primitiveRegistry = new Map<Service<Primitive>, Array<Primitive>>();
/**
* List of all services + how they are provided.
*/
protected registry: Map<Service, ServiceDefinition> = new Map();
// -------------------------------------------------------------------------------------------------------------------
/**
* Node.js feature that allows to store context across asynchronous calls.
*
* This is used for logging, tracing, and other context-related features.
*
* Mocked for browser environments.
*/
public context: AlsProvider;
/**
* Event manager to handle lifecycle events and custom events.
*/
public events: EventManager;
/**
* State manager to store arbitrary values.
*/
public store: StateManager<State>;
/**
* Codec manager for encoding and decoding data with different formats.
*
* Supports multiple codec formats (JSON, Protobuf, etc.) with a unified interface.
*/
public codec: CodecManager;
/**
* Get logger instance.
*/
public get log(): LoggerInterface | undefined {
return this.store.get("alepha.logger");
}
/**
* The environment variables for the App.
*/
public get env(): Readonly<Env> {
return this.store.get("env") ?? {};
}
constructor(state: Partial<State> = {}) {
this.store = this.inject(StateManager, {
args: [state],
});
this.events = this.inject(EventManager);
this.events.logFn = () => this.log;
this.context = this.inject(AlsProvider);
this.codec = this.inject(CodecManager);
}
public fork<R>(callback: () => R, data: Record<string, any> = {}): R {
return this.context.run(callback, data);
}
public get<R>(target: Computed<R>, scope?: StateScope): R;
public get<T extends TAtomObject>(
target: Atom<T>,
scope?: StateScope,
): Static<T>;
public get<Key extends keyof State>(
target: Key,
scope?: StateScope,
): State[Key] | undefined;
public get(target: any, scope?: StateScope): any {
return this.store.get(target, scope);
}
public set<T extends TAtomObject>(
target: Atom<T>,
value: AtomStatic<T>,
): this;
public set<Key extends keyof State>(
target: Key,
value: State[Key] | undefined,
): this;
public set(target: any, value: any): this {
this.store.set(target, value);
return this;
}
/**
* Reset an atom back to its declared default value.
*/
public reset<T extends TAtomObject>(target: Atom<T>): this {
this.store.reset(target);
return this;
}
/**
* True when start() is called.
*
* -> No more services can be added, it's over, bye!
*/
public isLocked(): boolean {
return this.locked;
}
/**
* Returns whether the App is configured.
*
* It means that Alepha#configure() has been called.
*
* > By default, configure() is called automatically when start() is called, but you can also call it manually.
*/
public isConfigured(): boolean {
return this.configured;
}
/**
* Returns whether the App has started.
*
* It means that #start() has been called but maybe not all services are ready.
*/
public isStarted(): boolean {
return this.started;
}
/**
* True if the App is ready. It means that Alepha is started AND ready() hook has beed called.
*/
public isReady(): boolean {
return this.ready;
}
/**
* True if the App is running in a Continuous Integration environment.
*/
public isCI(): boolean {
if (this.env.GITHUB_ACTIONS) {
return true;
}
return !!this.env.CI;
}
/**
* True if the App is running in a browser environment.
*/
public isBrowser(): boolean {
return typeof window !== "undefined"; // pretty cheap check
}
/**
* Returns whether the App is running in Vite dev mode.
*/
public isViteDev(): boolean {
if (this.isBrowser()) {
return false;
}
return !!this.env.VITE_ALEPHA_DEV;
}
/**
* Returns whether the App is running in Bun.js environment.
*/
public isBun(): boolean {
return "Bun" in globalThis;
}
/**
* Returns whether the App is running in a serverless environment.
*/
public isServerless(): boolean {
if (this.isBrowser()) {
return false;
}
if (this.env.ALEPHA_SERVERLESS) {
return true;
}
// Vercel support
if (this.env.VERCEL_REGION) {
return true;
}
// Cloudflare Workers support
if (
typeof global === "object" &&
typeof (global as any).Cloudflare === "object"
) {
return true;
}
return false;
}
/**
* Returns whether the App is in test mode. (Running in a test environment)
*
* > This is automatically set when running tests with Jest or Vitest.
*/
public isTest(): boolean {
const env = this.env.NODE_ENV;
return env === "test";
}
/**
* Returns whether the App is in production mode. (Running in a production environment)
*
* > This is automatically set by Vite or Vercel. However, you have to set it manually when running Docker apps.
*/
public isProduction(): boolean {
const env = this.env.NODE_ENV;
return env === "production";
}
// -------------------------------------------------------------------------------------------------------------------
/**
* Max time (ms) a boot() is allowed to run before being considered stale.
*
* In serverless runtimes (Cloudflare Workers, etc.) an invocation can be
* killed mid-startup. The global Alepha instance survives, but
* `startPromise` becomes a zombie that never settles.
* Any new invocation that sees an older-than-STARTUP_TIMEOUT promise
* discards it and boots fresh.
*/
protected static readonly STARTUP_TIMEOUT = 30_000;
/**
* Starts the App.
*
* - Lock any further changes to the container.
* - Run "configure" hook for all services. Primitives will be processed.
* - Run "start" hook for all services. Providers will connect/listen/...
* - Run "ready" hook for all services. This is the point where the App is ready to serve requests.
*
* Concurrent callers share the same boot promise. If a previous boot was
* abandoned (serverless invocation killed), the stale promise is detected
* and a fresh boot is triggered.
*
* @return A promise that resolves when the App has started.
*/
public async start(): Promise<this> {
if (this.ready) {
this.log?.debug("App is already started, skipping...");
return this;
}
if (this.startPromise) {
const elapsed = performance.now() - this.startedAt;
if (elapsed > Alepha.STARTUP_TIMEOUT) {
this.log?.warn(
`Previous start attempt is stale (${Math.round(elapsed)}ms ago), resetting...`,
);
this.resetStartup();
} else {
this.log?.warn("App is already starting, waiting for it to finish...");
return this.startPromise;
}
}
this.startedAt = performance.now();
this.startPromise = this.boot();
return this.startPromise;
}
/**
* Perform the actual startup sequence.
*
* Separated from start() so that start() remains a thin state-machine
* and boot() owns the real work. The promise returned here is stored as
* `startPromise` and shared with concurrent callers.
*/
protected async boot(): Promise<this> {
const now = performance.now();
this.log?.info("Starting App...");
try {
for (const [key] of this.substitutions.entries()) {
this.inject(key);
}
const target = this.store.get("alepha.target");
if (target) {
this.store.set("alepha.target", undefined);
this.modules = [];
this.registry = new Map();
this.primitiveRegistry = new Map();
this.pendingInstantiations = [];
this.events.clear();
delete (target as any)[MODULE];
this.with(target);
for (const [key] of this.substitutions.entries()) {
this.inject(key);
}
}
this.locked = true;
await this.events.emit("configure", this, { log: true });
this.configured = true;
await this.events.emit("start", this, { log: true });
this.started = true;
await this.events.emit("ready", this, { log: true });
this.log?.info(
`App is now ready [${Math.round(performance.now() - now)}ms]`,
);
this.ready = true;
return this;
} catch (error) {
this.resetStartup();
throw error;
}
}
/**
* Reset startup state so that a fresh boot() can be attempted.
*
* Called when:
* - boot() fails (error during configure/start/ready hooks)
* - a stale startPromise is detected (serverless invocation was killed)
*/
protected resetStartup(): void {
this.startPromise = undefined;
this.startedAt = 0;
this.locked = false;
this.configured = false;
this.started = false;
this.ready = false;
}
/**
* Stops the App.
*
* - Run "stop" hook for all services.
*
* Stop will NOT reset the container.
* Stop will NOT unlock the container.
*
* > Stop is used to gracefully shut down the application, nothing more. There is no "restart".
*
* @return A promise that resolves when the App has stopped.
*/
public async stop(): Promise<void> {
if (!this.started) {
return;
}
this.log?.info("Stopping App...");
await this.events.emit("stop", this, { log: true });
this.log?.info("App is now off");
this.started = false;
this.ready = false;
this.startPromise = undefined;
this.startedAt = 0;
}
/**
* Destroys the App and clears all internal state.
*
* Use this for HMR in development mode to prevent duplicate class registrations.
* Unlike stop(), this method fully resets the container state.
*
* @return A promise that resolves when the App has been destroyed.
*/
public async destroy(): Promise<void> {
await this.stop();
// Clear all internal state to prevent duplicate classes on HMR reload
this.modules = [];
this.registry = new Map();
this.primitiveRegistry = new Map();
this.pendingInstantiations = [];
this.substitutions = new Map();
this.cacheEnv = new Map();
this.events.clear();
// Reset flags
this.locked = false;
this.configured = false;
}
// -------------------------------------------------------------------------------------------------------------------
/**
* Check if entry is registered in the container.
*/
public has(
entry: ServiceEntry,
opts: {
/**
* Check if the entry is registered in the pending instantiation stack.
*
* @default true
*/
inStack?: boolean;
/**
* Check if the entry is registered in the container registry.
*
* @default true
*/
inRegistry?: boolean;
/**
* Check if the entry is registered in the substitutions.
*
* @default true
*/
inSubstitutions?: boolean;
/**
* Where to look for registered services.
*
* @default this.registry
*/
registry?: Map<Service, ServiceDefinition>;
} = {},
): boolean {
if (entry === Alepha) {
return true;
}
const {
inStack = true,
inRegistry = true,
inSubstitutions = true,
registry = this.registry,
} = opts;
const { provide } =
typeof entry === "object" && "provide" in entry
? entry
: { provide: entry };
if (inSubstitutions) {
const substitute = this.substitutions.get(provide);
if (substitute) {
return true;
}
}
if (inRegistry) {
const match = registry.get(provide);
if (match) {
return true;
}
}
if (inStack) {
const substitute = this.substitutions.get(provide)?.use;
if (substitute && this.pendingInstantiations.includes(substitute)) {
return true;
}
return this.pendingInstantiations.includes(provide);
}
return false;
}
/**
* Registers the specified service in the container.
*
* - If the service is ALREADY registered, the method does nothing.
* - If the service is NOT registered, a new instance is created and registered.
*
* Method is chainable, so you can register multiple services in a single call.
*
* > ServiceEntry allows to provide a service **substitution** feature.
*
* @example
* ```ts
* class A { value = "a"; }
* class B { value = "b"; }
* class M { a = $inject(A); }
*
* Alepha.create().with({ provide: A, use: B }).get(M).a.value; // "b"
* ```
*
* > **Substitution** is an advanced feature that allows you to replace a service with another service.
* > It's useful for testing or for providing different implementations of a service.
* > If you are interested in configuring a service, use Alepha#configure() instead.
*
* @param serviceEntry - The service to register in the container.
* @return Current instance of Alepha.
*/
public with<T extends object>(
serviceEntry: ServiceEntry<T> | { default: ServiceEntry<T> },
): this {
// Early cutoff: if a $mode already claimed the target, skip further registrations.
// The target's own dependencies are resolved via $inject during its constructor.
if (this.store?.get("alepha.target")) {
return this;
}
const entry: ServiceEntry<T> =
"default" in serviceEntry ? serviceEntry.default : serviceEntry;
// just check if the entry is not present in the pending instantiation stack
// Alepha#get will handle the rest
if (
this.has(entry, {
inSubstitutions: false,
inRegistry: false,
})
) {
return this;
}
const isSubstitution = typeof entry === "object";
if (isSubstitution) {
if (entry.provide === entry.use) {
this.inject(entry.provide);
return this;
}
if (!this.substitutions.has(entry.provide) && !this.has(entry.provide)) {
if (this.started) {
throw new ContainerLockedError();
}
// inherit of module, if service has no module
if (
MODULE in entry.provide &&
typeof entry.provide[MODULE] === "function"
) {
(entry.use as WithModule)[MODULE] ??= entry.provide[MODULE];
}
this.substitutions.set(entry.provide, {
use: entry.use,
});
} else if (!entry.optional) {
throw new TooLateSubstitutionError(entry.provide.name, entry.use.name);
}
return this;
}
this.inject(entry);
return this;
}
/**
* @alias {@link Alepha#with}.
*/
public register<T extends object>(
serviceEntry: ServiceEntry<T> | { default: ServiceEntry<T> },
): this {
return this.with(serviceEntry);
}
/**
* Get an instance of the specified service from the container.
*
* @see {@link InjectOptions} for the available options.
*/
public inject<T extends object>(
service: Service<T> | string,
opts: InjectOptions<T> = {},
): T {
const lifetime = opts.lifetime ?? "singleton";
const parent =
opts.parent !== undefined ? opts.parent : (__alephaRef?.parent ?? Alepha);
const transient = lifetime === "transient";
// TODO: warn-once when scoped lifetime silently falls back to the global
// singleton registry. This happens when AsyncLocalStorage is not available
// (typically in the browser, where AlsProvider is a no-op) — the user
// asked for per-request isolation and got a cross-request singleton.
// Today this is silent. Plan: detect (this.context.get("registry") === undefined)
// on a "scoped" inject, log a one-shot warning via the Logger module
// ("[alepha] scoped DI requested for <Service> but no AsyncLocalStorage
// context — falling back to singleton. This is expected in browser
// builds; in server runtimes ensure the request is wrapped in
// alepha.context.run()."). Gate behind a Set<Service> so we don't spam.
const registry =
lifetime === "scoped"
? (this.context.get<Map<Service, ServiceDefinition>>("registry") ??
this.registry)
: this.registry;
// If the requested type is the container, the current instance is returned.
if ((service as any) === Alepha) {
return this as any;
}
if (typeof service === "string") {
for (const [key, value] of registry.entries()) {
if (key.name === service) {
return value.instance as T;
}
}
throw new AlephaError(`Service not found: ${service}`);
}
const substitute = this.substitutions.get(service);
if (substitute) {
return this.inject(substitute.use, {
parent,
lifetime,
});
}
const index = this.pendingInstantiations.indexOf(service);
if (index !== -1 && !transient) {
throw new CircularDependencyError(
service.name,
this.pendingInstantiations.slice(0, index).map((it) => it.name),
);
}
if (!transient) {
// the requested type is searched in the container
const match = registry.get(service);
if (match) {
if (!match.parents.includes(parent) && parent !== service) {
match.parents.push(parent);
}
return match.instance;
}
// The locked-container guard protects the GLOBAL registry from gaining new
// singletons after start. A `scoped` inject targets a per-request registry
// (a fresh Map created by `alepha.context.run` per request, resolved at
// `registry` above) and is stored only there — it never mutates the global
// container. Refusing it after start makes `lifetime: "scoped"` unusable at
// the only time it matters (while handling a request), so the guard must
// apply only when we are instantiating into the global registry itself.
if (this.started && registry === this.registry) {
const mod = (service as WithModule)[MODULE]?.name;
throw new ContainerLockedError(
`Container is locked. No more services can be added. Attempted to inject '${service.name}' from '${parent?.name}'. ${mod ? `Module '${mod}' is not registered ?` : ""}`,
);
}
}
const module = (service as WithModule)[MODULE];
if (module && typeof module === "function") {
this.with(module);
}
// check if service has been registered by a module
if (this.has(service, { registry }) && !transient) {
// if the service is already registered, we just return the instance
return this.inject(service, { parent, lifetime });
}
const instance: T = this.new(service, opts.args);
const definition: ServiceDefinition<T> = {
parents: [parent],
instance,
};
if (!transient) {
registry.set(service, definition);
}
// [feature]: modules - it's just a way to group services together
if (instance instanceof Module) {
this.modules.push(instance);
const parent = __alephaRef.parent;
// propagate the current module
__alephaRef.parent = instance.constructor as Service;
instance.register(this);
// restore the previous $get context
__alephaRef.parent = parent;
}
return instance;
}
// -------------------------------------------------------------------------------------------------------------------
/**
* Merge additional environment variables into the env store at runtime.
*
* Serverless entrypoints (Cloudflare Workers) receive their secrets and
* vars on the runtime `env` binding rather than `process.env`, so they are
* absent from `alepha.env` — which is frozen at `create()` from
* `process.env`. Call this from the entrypoint to lift the binding's string
* values into `alepha.env`, so `$env` and `alepha.env.*` resolve them (e.g.
* `PUBLIC_URL`).
*
* Only string values are lifted; non-string bindings (D1, R2, KV, queues,
* …) are skipped. Existing env values win, so explicit configuration is
* never clobbered. The env cache is cleared so subsequent `$env` reads see
* the merged values.
*/
public loadEnv(env: Record<string, unknown>): this {
const incoming: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (typeof value === "string") {
incoming[key] = value;
}
}
this.store.set("env", { ...incoming, ...this.env });
this.cacheEnv.clear();
return this;
}
/**
* Applies environment variables to the provided schema and state object.
*
* It replaces also all templated $ENV inside string values.
*
* @param schema - The schema object to apply environment variables to.
* @return The schema object with environment variables applied.
*/
public parseEnv<T extends TObject>(schema: T): Static<T> {
if (this.cacheEnv.has(schema)) {
return this.cacheEnv.get(schema) as Static<T>;
}
// Env vars are strings on the wire — coerce declared fields to their
// schema types (boolean/number) before strict validation.
const config = this.codec.validate(
schema,
coerceObject(schema, this.env),
) as Record<string, any>;
// Sort keys longest-first to prevent substring collisions
// (e.g. $PORT must not match inside $PORT_NAME).
const sortedKeys = Object.keys(config).sort((a, b) => b.length - a.length);
let changed = true;
// Resolve $KEY references. Multiple passes handle transitive references
// where a replacement introduces a new $KEY that was already checked
// (e.g. C=$B, B=$A, A=value — single pass leaves C as "$A").
for (let pass = 0; changed && pass < 10; pass++) {
changed = false;
for (const key in config) {
if (typeof config[key] !== "string") continue;
for (const env of sortedKeys) {
const before = config[key] as string;
config[key] = before.replaceAll(`$${env}`, String(config[env] ?? ""));
if (config[key] !== before) {
changed = true;
}
}
}
}
this.cacheEnv.set(schema, config);
return config as Static<T>;
}
/**
* Get all environment variable schemas and their parsed values.
*
* This is useful for DevTools to display all expected environment variables.
*/
public getEnvSchemas(): Array<{
schema: TSchema;
values: Record<string, any>;
}> {
const result: Array<{ schema: TSchema; values: Record<string, any> }> = [];
for (const [schema, values] of this.cacheEnv.entries()) {
result.push({ schema, values });
}
return result;
}
// -------------------------------------------------------------------------------------------------------------------
/**
* Dump the current dependency graph of the App.
*
* This method returns a record where the keys are the names of the services.
*/
public graph(): Record<
string,
{ from: string[]; as?: string[]; module?: string }
> {
for (const [key] of this.substitutions.entries()) {
if (!this.has(key)) {
this.inject(key);
}
}
const graph: Record<
string,
{ from: string[]; as?: string[]; module?: string }
> = {};
for (const [provide, { parents }] of this.registry.entries()) {
if (provide.name === "") {
// ignore anonymous classes
continue;
}
if (Module.is(provide)) {
continue;
}
graph[provide.name] = {
from: parents.filter((it) => !!it).map((it) => it.name),
};
const aliases = this.substitutions
.entries()
.filter((it) => it[1].use === provide)
.map((it) => it[0].name)
.toArray();
if (aliases.length) {
graph[provide.name].as = aliases;
}
const module = Module.of(provide);
if (module) {
graph[provide.name].module = module.name;
}
}
return graph;
}
public dump(): AlephaDump {
// Force-instantiate the graph FIRST. `$env` is lazy — a key only lands in
// `cacheEnv` once its owning service is constructed. `graph()` injects
// every registered substitution (without emitting start/ready hooks), so
// calling it before reading `cacheEnv` ensures `env` reflects the whole
// app, not just whatever happened to be instantiated already. Reading
// `cacheEnv` first (the old order) under-reported env on a not-yet-started
// app and was internally inconsistent: dump() instantiated providers for
// `providers` but never saw their env.
const providers = this.graph();
const env: Record<string, AlephaDumpEnvVariable> = {};
for (const [schema] of this.cacheEnv.entries()) {
const ref = schema as any;
// zod object: `.properties` aliases `.shape`; `required` field names come
// from `z.schema.requiredKeys` (zod has no `.required` array — that name
// is the `.required()` method). Metadata (description/enum/default) lives
// on the unwrapped inner schema, under `.meta()`.
const shape = (ref.properties ?? {}) as Record<string, TSchema>;
const required = new Set(z.schema.requiredKeys(ref));
for (const [key, value] of Object.entries(shape)) {
const prop = value as any;
const inner = z.schema.unwrap(prop) as any;
const enumValues = z.schema.isEnum(inner)
? z.schema.enumValues(inner)
: undefined;
env[key] = {
description: inner?.description ?? prop?.description,
default: z.schema.getDefault(prop) as string | undefined,
required: required.has(key) ? true : undefined,
enum: enumValues?.length
? ([...enumValues] as Array<string>)
: undefined,
};
}
}
return {
env,
providers,
};
}
public services<T extends object>(base: Service<T>): Array<T> {
const list: Array<T> = [];
for (const [key, value] of this.registry.entries()) {
if (value.instance instanceof base) {
list.push(value.instance as T);
}
}
return list;
}
/**
* Get all primitives of the specified type.
*/
public primitives<TPrimitive extends Primitive>(
factory:
| {
[KIND]: InstantiableClass<TPrimitive>;
}
| string,
): Array<TPrimitive> {
if (typeof factory === "string") {
const key1 = factory.toLowerCase().replace("$", "");
const key2 = `${key1}primitive`;
for (const [key, value] of this.primitiveRegistry.entries()) {
const name = key.name.toLowerCase();
if (name === key1 || name === key2) {
return value as Array<TPrimitive>;
}
}
return [];
}
return (this.primitiveRegistry.get(factory[KIND]) ??
[]) as Array<TPrimitive>;
}
// -------------------------------------------------------------------------------------------------------------------
protected new<T extends object>(service: Service<T>, args: any[] = []): T {
// we keep a tree of dependencies to detect circular dependencies
// it's also useful for cleaning are global cursor
this.pendingInstantiations.push(service);
//
// we use a global cursor to store the current context and definition
// as new() is synchronous, there is no worry to do that
//
__alephaRef.alepha = this;
__alephaRef.service = service;
const instance: T = isClass(service)
? new service(...args)
: (((service as RunFunction)(...args) ?? {}) as T);
const obj = instance as unknown as Record<string, any>;
for (const [key, value] of Object.entries(obj)) {
if (value instanceof Primitive) {
this.processPrimitive(value, key);
}
if (
typeof value === "object" &&
value !== null &&
typeof value[OPTIONS] === "object" &&
"getter" in value[OPTIONS]
) {
const getter = value[OPTIONS].getter as keyof State;
Object.defineProperty(obj, key, {
get: () => this.store.get(getter),
});
}
}
this.pendingInstantiations.pop();
// tree is empty, now we can clean the global cursor
if (this.pendingInstantiations.length === 0) {
__alephaRef.alepha = undefined;
}
__alephaRef.service =
this.pendingInstantiations[this.pendingInstantiations.length - 1];
return instance;
}
protected processPrimitive(value: Primitive, propertyKey = "") {
value.config.propertyKey = propertyKey;
(value as any).onInit();
const kind = value.constructor as Service;
const existing = this.primitiveRegistry.get(kind);
if (existing) {
existing.push(value);
} else {
this.primitiveRegistry.set(kind, [value]);
}
}
}
// ---------------------------------------------------------------------------------------------------------------------
export interface Hook<T extends keyof Hooks = any> {
caller?: Service;
priority?: "first" | "last";
before?: Service[];
after?: Service[];
callback: (payload: Hooks[T]) => Async<void>;
}
// ---------------------------------------------------------------------------------------------------------------------
export interface AlephaDump {
env: Record<string, AlephaDumpEnvVariable>;
providers: Record<string, { from: string[]; as?: string[]; module?: string }>;
}
export interface AlephaDumpEnvVariable {
description: string;
default?: string;
required?: boolean;
enum?: Array<string>;
}
// ---------------------------------------------------------------------------------------------------------------------
/**
* This is how we store services in the Alepha container.
*/
interface ServiceDefinition<T extends object = any> {
/**
* The instance of the class or type definition.
* Mostly used for caching / singleton but can be used for other purposes like forcing the instance.
*/
instance: T;
/**
* List of classes which use this class.
*/
parents: Array<Service | null>;
}
// ---------------------------------------------------------------------------------------------------------------------
export interface Env {
[key: string]: string | boolean | number | undefined;
/**
* Optional environment variable that indicates the current environment.
*/
NODE_ENV?: string;
/**
* Optional name of the application.
*/
APP_NAME?: string;
/**
* Optional root module name.
*/
MODULE_NAME?: string;
/**
* The secret key used for signing JWTs, encrypting cookies, and other security features.
*/
APP_SECRET?: string;
/**
* Public-facing base URL of the deployed app (e.g. "https://lore.alepha.dev").
*
* Used to render absolute links — emails, OAuth callbacks, sitemap. On the
* Cloudflare platform it is auto-derived from the configured production
* domain and pushed as a Worker secret by `alepha platform up`; otherwise
* set it explicitly in `.env.<env>`. Unset → empty, and absolute-link
* builders fall back to relative URLs.
*/
PUBLIC_URL?: string;
}
// ---------------------------------------------------------------------------------------------------------------------
export interface State {
[key: string]: unknown;
/**
* Environment variables for the application.
*/
env?: Readonly<Env>;
/**
* Logger instance to be used by the Alepha container.
*
* @internal
*/
"alepha.logger"?: LoggerInterface;
/**
* If defined, the Alepha container will only register this service and its dependencies.
*
* @example
* ```ts
* class MigrateCmd {
* db = $inject(DatabaseProvider);
* alepha = $inject(Alepha);
* env = $env(
* z.object({
* MIGRATE: z.boolean().optional(),
* }),
* );
*
* constructor() {
* if (this.env.MIGRATE) {
* this.alepha.set("alepha.target", MigrateCmd);
* }
* }
*
* ready = $hook({
* on: "ready",
* handler: async () => {
* if (this.env.MIGRATE) {
* await this.db.migrate();
* }
* },
* });
* }
* ```
*/
"alepha.target"?: Service;
// test hooks
/**
* Bind to Vitest 'beforeAll' hook.
* Used for testing purposes.
* This is automatically attached if Alepha#create() detects a test environment and global 'beforeAll' is available.
*/
"alepha.test.beforeAll"?: (run: any) => any;
/**
* Bind to Vitest 'afterAll' hook.
* Used for testing purposes.
* This is automatically attached if Alepha#create() detects a test environment and global 'afterAll' is available.
*/
"alepha.test.afterAll"?: (run: any) => any;
/**
* Bind to Vitest 'afterEach' hook.
* Used for testing purposes.
* This is automatically attached if Alepha#create() detects a test environment and global 'afterEach' is available.
*/
"alepha.test.afterEach"?: (run: any) => any;
/**
* Bind to Vitest 'onTestFinished' hook.
* Used for testing purposes.
* This is automatically attached if Alepha#create() detects a test environment and global 'onTestFinished' is available.
*/
"alepha.test.onTestFinished"?: (run: any) => any;
/**
* List of static assets to be copied to the output directory during the build process.
*
* Used for Alepha-based applications that require static assets.
*
* See cli/services/ViteUtils for more details.
*/
"alepha.build.assets"?: Array<string>;
}
// ---------------------------------------------------------------------------------------------------------------------
export interface Hooks {
/**
* Used for testing purposes.
*/
echo: unknown;
/**
* Triggered during the configuration phase. Before the start phase.
*/
configure: Alepha;
/**
* Triggered during the start phase. When `Alepha#start()` is called.
*/
start: Alepha;
/**
* Triggered during the ready phase. After the start phase.
*/
ready: Alepha;
/**
* Triggered during the stop phase.
*
* - Stop should be called after a SIGINT or SIGTERM signal in order to gracefully shutdown the application. (@see `run()` method)
*
*/
stop: Alepha;
/**
* Triggered when a state value is mutated.
*/
"state:mutate": {
/**
* The key of the state that was mutated.
*/
key: keyof State;
/**
* The new value of the state.
*/
value: any;
/**
* The previous value of the state.
*/
prevValue: any;
};
/**
* Triggered the first time an atom is registered in the state manager.
*/
"state:register": {
/**
* The atom that was registered.
*/
atom: Atom<any, any>;
};
}