Three parameters. That's it.
Every Pikku function receives the same three arguments — no matter which protocol triggers it.
Your toolbox — database, logger, JWT, email, anything you register. Destructure only what you need.
{ db, logger, jwt }Typed, validated input — normalized from any protocol. Path params, body, query, message payload — all merged.
{ bookId, title }Session and optional protocol helpers. session works everywhere — protocol-specific fields like http or rpc only appear when relevant.
{ session, setSession }export const getOrderThreeParams = pikkuFunc({
func: async (
{ kysely, logger }, // services — your toolbox
{ orderId }, // data — typed input
{ session }, // wire — protocol context
) => {
logger.info(`Fetching order ${orderId}`);
const order = await kysely
.selectFrom('order')
.selectAll()
.where('orderId', '=', orderId)
.executeTakeFirstOrThrow();
return { order, viewer: session.userId };
},
});
Your toolbox, injected
Services are dependency-injected into every function. Register once, destructure anywhere.
Created once at startup, shared across all requests. Database connections, loggers, third-party clients.
Created fresh per request. Session loaders, audit contexts, per-request caches. Lazily instantiated only when destructured.
Only pull the services your function actually uses. Keeps code clean and makes dependencies explicit.
export const createSingletonServices = pikkuServices(
async (config, existingServices) => {
const variables =
existingServices?.variables ??
new TypedVariablesService(new LocalVariablesService());
const secrets =
existingServices?.secrets ??
new TypedSecretService(new LocalSecretService(variables));
const logger =
existingServices?.logger ?? new JsonConsoleLogger();
const schema =
existingServices?.schema ?? new CFWorkerSchemaService(logger);
const paymentService =
existingServices?.paymentService ?? new FakePaymentService();
// In CF Workers DATABASE_URL is injected by Fabric.
// In local dev (pikku dev) existingServices.kysely is the node:sqlite instance.
// In tests existingServices.kysely is provided by the test support layer.
let kysely: Kysely<DB> | undefined;
if (existingServices?.kysely) {
kysely = existingServices.kysely as Kysely<DB>;
} else {
const databaseUrl = await variables.get('DATABASE_URL');
if (databaseUrl) {
if (/^postgres(ql)?:\/\//.test(databaseUrl)) {
const [{ PostgresJSDialect }, postgres] =
await Promise.all([
import('kysely-postgres-js'),
import('postgres'),
]);
kysely = new Kysely<DB>({
dialect: new PostgresJSDialect({
postgres: postgres.default(databaseUrl),
}),
plugins: [new CamelCasePlugin()],
});
} else {
kysely = new Kysely<DB>({
dialect: new LibsqlWebDialect({ url: databaseUrl }),
plugins: [new CamelCasePlugin()],
});
}
} else {
throw new Error(
'kysely not provided: set DATABASE_URL or pass kysely via existingServices',
);
}
}
return {
...(existingServices ?? {}),
config,
variables,
secrets,
logger,
schema,
kysely,
paymentService,
audit: existingServices?.audit ?? new NoopAuditService(),
};
},
);
import { pikkuWireServices } from '../.pikku/pikku-types.gen.js';
export const createWireServices = pikkuWireServices(
async (_singletonServices, _wire) => {
// Created fresh per HTTP request, queue job, CLI command, or WebSocket lifetime.
// Pikku merges these with singletons so functions see one flat object.
// Add per-request services here — e.g. scoped loggers, transactions, etc.
return {};
},
);
Never accidentally break a client
Pikku hashes every function's input and output schema. Change a contract without bumping the version and the build fails — before it reaches production.
Every function's name + input schema + output schema = a contract hash. The CLI stores these in a versions.pikku.json manifest.
If you change a published schema without bumping the version, pikku versions check fails. Add it to CI and breaking changes never ship by accident.
Set version: 2 on the function, run pikku versions update, commit. Old and new versions coexist.
$ npx pikku versions check ✗ getItem — contract changed without version bump Input schema hash: a1b2c3d4 → f9e8d7c6 Output schema hash: i9j0k1l2 → z5y4x3w2 Run: npx pikku versions update after bumping to version 2
export const GetItemOutputV1 = z.object({
itemId: z.string(),
name: z.string(),
priceCents: z.number(),
});
export const getItemV1 = pikkuSessionlessFunc({
expose: true,
version: 1,
input: GetItemInput,
output: GetItemOutputV1,
func: async ({ kysely }, { itemId }) => {
const row = await kysely
.selectFrom('item')
.select(['itemId', 'name', 'priceCents'])
.where('itemId', '=', itemId)
.executeTakeFirstOrThrow();
return row;
},
});
// v2 — adds stock and imageUrl to the response
export const getItemV2 = pikkuSessionlessFunc({
expose: true,
version: 2,
input: GetItemInput,
output: GetItemOutput,
func: async ({ kysely }, { itemId }) => {
const row = await kysely
.selectFrom('item')
.innerJoin(
'category',
'category.categoryId',
'item.categoryId',
)
.select([
'item.itemId',
'item.name',
'item.slug',
'item.description',
'item.priceCents',
'item.stock',
'item.imageUrl',
'item.isActive',
'item.createdAt',
'item.updatedAt',
'category.categoryId',
'category.name as categoryName',
'category.slug as categorySlug',
])
.where('item.itemId', '=', itemId)
.executeTakeFirstOrThrow();
return {
...row,
category: {
categoryId: row.categoryId,
name: row.categoryName,
slug: row.categorySlug,
},
};
},
});
One session API, every transport
Whether the request arrives over HTTP, WebSocket, or CLI — your function reads and writes the session the same way.
Session populated from cookie, token, or connection state
Access session via the wire parameter — read userId, role, etc.
Call setSession() or clearSession() to update
Changes saved back to the transport — cookie, store, etc.
export const shopLogin = pikkuFunc({
auth: false,
func: async (
{ kysely },
{ email, password }: { email: string; password: string },
{ setSession },
) => {
const user = await kysely
.selectFrom('appUser')
.select(['userId', 'role', 'passwordHash'])
.where('email', '=', email.toLowerCase())
.executeTakeFirst();
if (
!user ||
!(await verifyPassword(password, user.passwordHash ?? ''))
) {
throw new Error('Invalid credentials');
}
setSession?.({ userId: user.userId, role: user.role });
return { userId: user.userId, role: user.role };
},
});
One function. Every protocol.
The same function handles HTTP requests, WebSocket messages, queue jobs, CLI commands, and MCP tools — zero duplication.
getBook(services, data, wire)export const getItem = pikkuSessionlessFunc({
expose: true,
description: 'Get a single item by ID.',
input: GetItemInput,
output: GetItemOutput,
func: async ({ kysely }, { itemId }) => {
const row = await kysely
.selectFrom('item')
.innerJoin(
'category',
'category.categoryId',
'item.categoryId',
)
.select([
'item.itemId',
'item.name',
'item.slug',
'item.description',
'item.priceCents',
'item.stock',
'item.imageUrl',
'item.isActive',
'item.createdAt',
'item.updatedAt',
'category.categoryId',
'category.name as categoryName',
'category.slug as categorySlug',
])
.where('item.itemId', '=', itemId)
.executeTakeFirst();
if (!row) throw new Error(`Item not found: ${itemId}`);
return {
...row,
category: {
categoryId: row.categoryId,
name: row.categoryName,
slug: row.categorySlug,
},
};
},
});
Start building in 5 minutes
One command to scaffold a project. Your first function will work across every protocol from day one.
MIT Licensed · Works with Express, Fastify, Lambda & Cloudflare