@fn-code-blocks/core
v0.1.5
Published
The SDK, CLI, build tools, and runtime servers for **Fn-Code-Blocks** — a functional programming framework for building API services with typed, language-agnostic function composition.
Readme
@fn-code-blocks/core
The SDK, CLI, build tools, and runtime servers for Fn-Code-Blocks — a functional programming framework for building API services with typed, language-agnostic function composition.
Write functions in TypeScript and Python. Compose them into API routes. The language a function is written in is an implementation detail.
// A route composing functions — languages are invisible
const user = await fn.fetch_user({ id: 123 });
const inventory = await fn.check_inventory({ sku: "A1" });
const [pricing, risk] = await Promise.all([
fn.fetch_pricing({ sku: "A1" }),
fn.assess_risk({ user_id: 123, amount: 500 }),
]);Quick start
npx create-fn-code-blocks my-project
code my-project
# Click "Reopen in Container" when VS Code prompts
npm run dev
# Visit http://localhost:3000/helloPrerequisites
Your local machine needs only:
- Docker Desktop — runs the development environment
- VS Code — editor
- VS Code extension: Dev Containers (ID:
ms-vscode-remote.remote-containers) - Node.js — only for the initial
npx create-fn-code-blocksscaffold command
After scaffolding, Node.js is no longer needed locally. Everything runs inside the Dev Container with exact version parity to production (Node 24, Python 3.12).
Three concepts
| Concept | What it is | Where it lives |
|---|---|---|
| Function | (input, ctx) → output. Typed, testable, language-agnostic. | src/functions/ |
| Route | Composes functions into a workflow endpoint. | src/routes/ |
| Config | Global env, logging transports, project settings. | fn-code-blocks.config.ts |
Architecture
Three services run in Docker. The framework manages all of them.
Client → Orchestrator :3000 → TS Runtime :3001
→ Python Runtime :3002- Client → Orchestrator: Standard HTTP REST
- Orchestrator → Runtimes:
POST /invoke/:fnName(internal HTTP) - Runtimes → Log Collector: Persistent WebSocket log streams
Writing functions
Each function lives in its own folder under src/functions/. The language is determined by the entry point file.
TypeScript
// src/functions/fetch_user/index.ts
import type { Context } from "@fn-code-blocks/core/context";
export interface Input {
id: number;
}
export interface Output {
name: string;
email: string;
}
export default async function fetch_user(input: Input, ctx: Context): Promise<Output> {
ctx.trace.info("Fetching user", { id: input.id });
// ... your logic
return { name: "Alice", email: "[email protected]" };
}Python
# src/functions/analyze_data/index.py
from typing import TypedDict
from fn_code_blocks import Context
class Input(TypedDict):
dataset: str
class Output(TypedDict):
result: str
score: float
def main(input: Input, ctx: Context) -> Output:
ctx.trace.info("Analyzing dataset", {"dataset": input["dataset"]})
# ... your logic
return {"result": "positive", "score": 0.95}Also create an empty
__init__.pyin the function folder.
Rules
- One entry point per folder:
index.tsORindex.py - Must export
InputandOutputtypes - Folder names use
snake_case - Must include trace logging and tests
Writing routes
Routes compose functions into workflow endpoints. A route handler receives exactly five things: input, params, fn, ctx, respond.
// src/routes/orders.ts
import { defineRoute } from "@fn-code-blocks/core";
import { Hono } from "hono";
const orders = new Hono();
orders.post("/", defineRoute(async ({ input, fn, ctx, respond }) => {
ctx.trace.info("Processing order", { item: input.sku });
const inventory = await fn.check_inventory({ sku: input.sku });
if (!inventory.available) {
return respond.error(400, "Out of stock");
}
const order = await fn.create_order({
sku: input.sku,
quantity: input.quantity,
});
return respond.json(201, order);
}));
export default orders;Routes auto-mount by filename: orders.ts → /orders.
The rule: If it can't be expressed with fn.*, if/else, await, and respond.*, it belongs in a function.
Configuration
All project-specific decisions live in fn-code-blocks.config.ts:
import { defineConfig, ConsoleTransport } from "@fn-code-blocks/core";
export default defineConfig({
// Orchestrator port (default: 3000)
port: 3000,
// Header to extract trace IDs from incoming requests
traceIdHeader: "x-trace-id",
// Global environment — available to every route and function via ctx.env
env: {
SERVICE_NAME: "my-project",
REGION: process.env.REGION ?? "eu-west-1",
DATABASE_URL: process.env.DATABASE_URL ?? "postgres://localhost:5432/dev",
API_KEY: process.env.API_KEY ?? "",
},
// Keys to redact in trace logs (values replaced with [REDACTED])
redactKeys: ["API_KEY", "DATABASE_URL"],
// Logging — wire transports explicitly
logging(registry) {
registry.addTransport("log", new ConsoleTransport());
registry.addTransport("trace", new ConsoleTransport());
},
// Runtime-specific configuration (optional)
runtimes: {
python: {
systemDeps: [], // Extra apt packages for Docker image
extraDeps: [], // Extra pip packages
},
typescript: {
extraDeps: [], // Extra npm packages
},
},
});Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
| port | number | 3000 | Orchestrator HTTP port |
| traceIdHeader | string | "x-trace-id" | Request header to extract trace IDs from |
| env | Record<string, string> | {} | Global environment passed to all routes and functions via ctx.env |
| redactKeys | string[] | [] | Env keys whose values are replaced with [REDACTED] in trace logs |
| logging | (registry) => void | — | Wire logging transports for "log" and "trace" channels |
| runtimes | object | — | Per-runtime system deps and extra packages |
Environment passing
Functions receive ctx.env containing the global env from config. You can override per-call:
// Default — function gets full global env
await fn.fetch_user({ id: 123 });
// Filtered — function only sees what you pass
await fn.call_third_party(
{ query: "..." },
{ env: { SERVICE_NAME: ctx.env.SERVICE_NAME } }
);
// Injected — function sees global env plus extra values
await fn.process_order(
{ order_id: 456 },
{ env: { ...ctx.env, TENANT_ID: "acme-corp" } }
);Logging
Two channels, each wired independently:
| Channel | Purpose | Use for |
|---|---|---|
| trace | Diagnostic visibility | Every decision point, external call, data transformation |
| log | Operational output | Business events, metrics, alerts |
ctx.trace.info("Calling payment API", { amount, currency });
ctx.log.error("Payment declined", { reason });Custom transports
Implement the LogTransport interface:
import type { LogTransport, LogEntry } from "@fn-code-blocks/core";
const myTransport: LogTransport = {
write(entry: LogEntry) {
// Send to your logging service
},
};
export default defineConfig({
logging(registry) {
registry.addTransport("log", myTransport);
registry.addTransport("trace", myTransport);
},
});Pipe to pino
import pino from "pino";
import { defineConfig, type LogTransport, type LogEntry } from "@fn-code-blocks/core";
const logger = pino({ level: "debug" });
const pinoTransport: LogTransport = {
write(entry: LogEntry) {
logger.child({ channel: entry.channel, fn: entry.fn, traceId: entry.traceId })
[entry.level](entry.args[0] ?? {}, entry.message);
},
};
export default defineConfig({
logging(registry) {
registry.addTransport("log", pinoTransport);
registry.addTransport("trace", pinoTransport);
},
});Silence trace in production
logging(registry) {
registry.addTransport("log", new ConsoleTransport());
if (process.env.NODE_ENV !== "production") {
registry.addTransport("trace", new ConsoleTransport());
}
},Testing
Functions are tested with zero infrastructure. Each language has a createTestContext helper that returns a Context backed by in-memory transports.
TypeScript
import { describe, it, expect } from "vitest";
import { createTestContext } from "@fn-code-blocks/core/testing";
import my_function from "./index";
describe("my_function", () => {
it("works", async () => {
const { ctx, traceSink, logSink } = createTestContext("my_function");
const result = await my_function({ key: "value" }, ctx);
expect(result.field).toBe("expected");
expect(traceSink.entries).toHaveLength(2);
expect(logSink.filter((e) => e.level === "error")).toHaveLength(0);
});
});Python
from fn_code_blocks import create_test_context
from .index import main
def test_my_function():
ctx, trace_sink, log_sink = create_test_context("my_function")
result = main({"key": "value"}, ctx)
assert result["field"] == "expected"
assert len(trace_sink.entries) == 2CLI commands
All commands run inside the Dev Container terminal:
| Command | What it does |
|---|---|
| npm run dev | Start all services with hot reload |
| npm test | Run all tests (TS, Python) |
| npm test -- --fn my_function | Test a single function (auto-detects language) |
| npm run test:ts | TypeScript tests only |
| npm run test:py | Python tests only |
| npm run build | Rebuild manifests |
| npm run docker:build | Build production Docker images |
| npm run docker:up | Start production containers |
Error handling
The fn proxy throws typed errors that routes can catch:
import { FnTimeoutError } from "@fn-code-blocks/core/errors";
defineRoute(async ({ fn, respond }) => {
try {
const user = await fn.fetch_user({ id: input.user_id });
return respond.json(200, user);
} catch (err) {
if (err instanceof FnTimeoutError) {
return respond.error(504, "Function timed out");
}
throw err; // re-throw — framework returns 500
}
});| Error class | When |
|---|---|
| FnExecutionError | Function threw an exception |
| FnTimeoutError | Invocation exceeded deadline |
| FnServiceError | Runtime unreachable |
| FnNotFoundError | Function not in manifest |
Production Docker
npm run docker # Generate Dockerfiles + docker-compose.yml
npm run docker:build # Build production images
npm run docker:up # Start production containersEach production image contains only the runtime it needs. Language versions match the Dev Container exactly.
Project structure
my-project/
├── fn-code-blocks.config.ts # Configuration
├── package.json # Single dependency: @fn-code-blocks/core
├── .devcontainer/ # Dev Container (auto-generated)
├── .vscode/launch.json # Debug configurations
├── src/
│ ├── functions/ # Your functions
│ │ ├── fetch_user/index.ts
│ │ ├── analyze_data/index.py
│ │ └── shared/ # Cross-function utilities
│ └── routes/ # Your routes
│ └── orders.ts
└── .fn-code-blocks/ # Generated (gitignored)Exports
// Main entry
import { defineConfig, defineRoute, ConsoleTransport } from "@fn-code-blocks/core";
// Context type (for function signatures)
import type { Context } from "@fn-code-blocks/core/context";
// Testing
import { createTestContext } from "@fn-code-blocks/core/testing";
// Logger
import { LoggerRegistry, MemoryTransport } from "@fn-code-blocks/core/logger";
import type { LogEntry, LogLevel, LogChannel, LogTransport } from "@fn-code-blocks/core/logger";
// Errors
import { FnExecutionError, FnTimeoutError, FnServiceError, FnNotFoundError } from "@fn-code-blocks/core/errors";License
MIT
