This guide creates a small HTTP Application, tests it through the portable Kernel host, and connects it to Node.js.
Create a project
npm create loutre@latest my-app
cd my-app
Choose Node.js, Bun, Cloudflare Workers, or AWS Lambda as your target.
Define an HTTP execution
Keep HTTP semantics in the HTTP Extension and register the resulting execution in a Module.
src/greetings/contract.ts:
import { http } from '@loutrejs/loutre/http'
import { z } from 'zod'
export const GreetingContract = http.contract({
greet: {
method: 'GET',
path: '/greetings/{name}',
request: {
params: {
name: z.string().min(1),
},
},
responses: {
ok: {
status: 200,
body: z.object({ message: z.string() }),
},
},
},
})
src/greetings/service.ts:
export class GreetingService {
greet(name: string) {
return { message: `Hello, ${name}!` }
}
}
src/greetings/controller.ts:
import { inject } from '@loutrejs/loutre'
import { http } from '@loutrejs/loutre/http'
import { GreetingContract } from './contract.js'
import { GreetingService } from './service.js'
export const GreetingHttp = http.implementation({
name: 'GreetingHttp',
contract: GreetingContract,
factory: (greetings = inject(GreetingService)) => ({
async greet(ctx) {
return ctx.response.ok({
body: greetings.greet(ctx.input.params.name),
})
},
}),
})
src/app.ts:
import { defineApplication, defineModule } from '@loutrejs/loutre'
import { GreetingHttp } from './greetings/controller.js'
import { GreetingService } from './greetings/service.js'
const AppModule = defineModule({
providers: [GreetingService],
executions: [GreetingHttp],
})
export default defineApplication({
modules: [AppModule],
})
defineApplication() builds the canonical Application Model. HTTP route details remain owned by the HTTP Extension; Core stores the execution contribution without protocol-specific branching.
For guidance on static versus dynamic Modules and shared references, see Static and dynamic Modules (Japanese).
Test through the portable Kernel host
import { bootstrapApplication } from '@loutrejs/loutre'
import { bindHttpServer } from '@loutrejs/loutre/http'
import { expect, it } from 'vitest'
import application from './app.js'
it('returns a greeting', async () => {
const app = await bootstrapApplication({
application,
capabilities: [bindHttpServer({ runtime: 'test' })],
})
try {
const response = await app.http.fetch(
new Request('http://localhost/greetings/Loutre'),
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
message: 'Hello, Loutre!',
})
} finally {
await app.close('test-complete')
}
})
bootstrapApplication() initializes the Kernel and exposes only Host APIs contributed by the Extensions present in the Application Model.
Connect to Node.js
src/main.ts:
import { nodeRuntime } from '@loutrejs/node'
import application from './app.js'
const app = await nodeRuntime.create({ application })
await app.serve({ port: 3000 })
Run the server and send a request:
npm run dev
curl http://localhost:3000/greetings/Loutre
{ "message": "Hello, Loutre!" }
The Application Definition remains portable. Runtime adapters own platform-specific listener and callback boundaries.
Add Environment
import { defineEnv, defineModule } from '@loutrejs/loutre'
import { z } from 'zod'
export class AppEnv extends defineEnv(
z.object({ PORT: z.coerce.number().int().positive().default(3000) }),
) {}
const AppModule = defineModule({
environment: [AppEnv],
executions: [GreetingHttp],
})
Node.js can then use the validated value after Application initialization:
const app = await nodeRuntime.create({ application })
await app.serve({ port: app.get(AppEnv).PORT })
Add a Task and Trigger
Task semantics are provided by @loutrejs/loutre/tasks.
import { defineApplication, defineModule } from '@loutrejs/loutre'
import { fixedDelay, task } from '@loutrejs/loutre/tasks'
export const cleanup = task<void, void>({
name: 'cleanup',
factory: () => async () => {
console.log('cleanup')
},
})
export const heartbeat = fixedDelay({
name: 'heartbeat',
delay: 5_000,
immediate: true,
task: cleanup,
})
const WorkerModule = defineModule({
executions: [heartbeat],
})
export default defineApplication({ modules: [WorkerModule] })
Registering the Trigger is enough: the Tasks Extension declares its referenced Task, and Core includes that Task in the canonical Application Model automatically.
Run a Task manually or start Trigger execution through the Extension-owned Host API:
import { nodeRuntime } from '@loutrejs/node'
import application, { cleanup } from './app.js'
const app = await nodeRuntime.create({ application })
await app.tasks.run(cleanup)
await app.tasks.start()
Queue consumers
Queue descriptors and consumers also come from @loutrejs/loutre/tasks. Drivers such as BullMQ are ordinary Providers declared in the same Module that owns the execution.
import { defineModule } from '@loutrejs/loutre'
import { consume, queue, task } from '@loutrejs/loutre/tasks'
import { bindBullMqQueue } from '@loutrejs/bullmq'
import { z } from 'zod'
const orders = queue({
name: 'orders',
payload: z.object({ id: z.string() }),
})
const processOrder = task<{ id: string }, void>({
name: 'orders.process',
factory:
() =>
async ({ id }) =>
console.log(id),
})
const orderConsumer = consume({
name: 'orders.consumer',
queue: orders,
task: processOrder,
})
const QueueModule = defineModule({
providers: [
bindBullMqQueue(orders, {
connection: { host: '127.0.0.1', port: 6379 },
}),
],
executions: [processOrder, orderConsumer],
})
Inspect the Application Model
npm exec loutre -- check --entry src/app.ts
npm exec loutre -- graph modules --entry src/app.ts
npm exec loutre -- graph di --entry src/app.ts --format mermaid
npm exec loutre -- graph executions --entry src/app.ts --format json
npm exec loutre -- doctor --entry src/app.ts --runtime node
The CLI loads the same compiled Application Model used by Runtime. It does not maintain a second graph compiler.
Generate OpenAPI
HTTP OpenAPI metadata is projected by the HTTP Extension:
npm exec loutre -- openapi --entry src/app.ts --output openapi.json
See Architecture for the Kernel and Execution Extension boundaries.
This guide creates a small HTTP Application, tests it through the portable Kernel host, and connects it to Node.js.
Create a project
npm create loutre@latest my-app cd my-appChoose Node.js, Bun, Cloudflare Workers, or AWS Lambda as your target.
Define an HTTP execution
Keep HTTP semantics in the HTTP Extension and register the resulting execution in a Module.
src/greetings/contract.ts:import { http } from '@loutrejs/loutre/http' import { z } from 'zod' export const GreetingContract = http.contract({ greet: { method: 'GET', path: '/greetings/{name}', request: { params: { name: z.string().min(1), }, }, responses: { ok: { status: 200, body: z.object({ message: z.string() }), }, }, }, })src/greetings/service.ts:export class GreetingService { greet(name: string) { return { message: `Hello, ${name}!` } } }src/greetings/controller.ts:import { inject } from '@loutrejs/loutre' import { http } from '@loutrejs/loutre/http' import { GreetingContract } from './contract.js' import { GreetingService } from './service.js' export const GreetingHttp = http.implementation({ name: 'GreetingHttp', contract: GreetingContract, factory: (greetings = inject(GreetingService)) => ({ async greet(ctx) { return ctx.response.ok({ body: greetings.greet(ctx.input.params.name), }) }, }), })src/app.ts:import { defineApplication, defineModule } from '@loutrejs/loutre' import { GreetingHttp } from './greetings/controller.js' import { GreetingService } from './greetings/service.js' const AppModule = defineModule({ providers: [GreetingService], executions: [GreetingHttp], }) export default defineApplication({ modules: [AppModule], })defineApplication()builds the canonical Application Model. HTTP route details remain owned by the HTTP Extension; Core stores the execution contribution without protocol-specific branching.For guidance on static versus dynamic Modules and shared references, see Static and dynamic Modules (Japanese).
Test through the portable Kernel host
import { bootstrapApplication } from '@loutrejs/loutre' import { bindHttpServer } from '@loutrejs/loutre/http' import { expect, it } from 'vitest' import application from './app.js' it('returns a greeting', async () => { const app = await bootstrapApplication({ application, capabilities: [bindHttpServer({ runtime: 'test' })], }) try { const response = await app.http.fetch( new Request('http://localhost/greetings/Loutre'), ) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ message: 'Hello, Loutre!', }) } finally { await app.close('test-complete') } })bootstrapApplication()initializes the Kernel and exposes only Host APIs contributed by the Extensions present in the Application Model.Connect to Node.js
src/main.ts:import { nodeRuntime } from '@loutrejs/node' import application from './app.js' const app = await nodeRuntime.create({ application }) await app.serve({ port: 3000 })Run the server and send a request:
{ "message": "Hello, Loutre!" }The Application Definition remains portable. Runtime adapters own platform-specific listener and callback boundaries.
Add Environment
import { defineEnv, defineModule } from '@loutrejs/loutre' import { z } from 'zod' export class AppEnv extends defineEnv( z.object({ PORT: z.coerce.number().int().positive().default(3000) }), ) {} const AppModule = defineModule({ environment: [AppEnv], executions: [GreetingHttp], })Node.js can then use the validated value after Application initialization:
const app = await nodeRuntime.create({ application }) await app.serve({ port: app.get(AppEnv).PORT })Add a Task and Trigger
Task semantics are provided by
@loutrejs/loutre/tasks.import { defineApplication, defineModule } from '@loutrejs/loutre' import { fixedDelay, task } from '@loutrejs/loutre/tasks' export const cleanup = task<void, void>({ name: 'cleanup', factory: () => async () => { console.log('cleanup') }, }) export const heartbeat = fixedDelay({ name: 'heartbeat', delay: 5_000, immediate: true, task: cleanup, }) const WorkerModule = defineModule({ executions: [heartbeat], }) export default defineApplication({ modules: [WorkerModule] })Registering the Trigger is enough: the Tasks Extension declares its referenced Task, and Core includes that Task in the canonical Application Model automatically.
Run a Task manually or start Trigger execution through the Extension-owned Host API:
import { nodeRuntime } from '@loutrejs/node' import application, { cleanup } from './app.js' const app = await nodeRuntime.create({ application }) await app.tasks.run(cleanup) await app.tasks.start()Queue consumers
Queue descriptors and consumers also come from
@loutrejs/loutre/tasks. Drivers such as BullMQ are ordinary Providers declared in the same Module that owns the execution.import { defineModule } from '@loutrejs/loutre' import { consume, queue, task } from '@loutrejs/loutre/tasks' import { bindBullMqQueue } from '@loutrejs/bullmq' import { z } from 'zod' const orders = queue({ name: 'orders', payload: z.object({ id: z.string() }), }) const processOrder = task<{ id: string }, void>({ name: 'orders.process', factory: () => async ({ id }) => console.log(id), }) const orderConsumer = consume({ name: 'orders.consumer', queue: orders, task: processOrder, }) const QueueModule = defineModule({ providers: [ bindBullMqQueue(orders, { connection: { host: '127.0.0.1', port: 6379 }, }), ], executions: [processOrder, orderConsumer], })Inspect the Application Model
npm exec loutre -- check --entry src/app.ts npm exec loutre -- graph modules --entry src/app.ts npm exec loutre -- graph di --entry src/app.ts --format mermaid npm exec loutre -- graph executions --entry src/app.ts --format json npm exec loutre -- doctor --entry src/app.ts --runtime nodeThe CLI loads the same compiled Application Model used by Runtime. It does not maintain a second graph compiler.
Generate OpenAPI
HTTP OpenAPI metadata is projected by the HTTP Extension:
npm exec loutre -- openapi --entry src/app.ts --output openapi.jsonSee Architecture for the Kernel and Execution Extension boundaries.