chore: server-and-serverless template

This commit is contained in:
e2e
2026-06-26 14:41:47 +02:00
commit facba843e5
202 changed files with 9060 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
import type { CoreServices, CoreSingletonServices, CoreConfig, CoreUserSession } from '@pikku/core'
import type { AuditLog } from '@pikku/core/services'
import type { Kysely } from 'kysely'
import type { DB } from './db.js'
import type { TypedSecretService } from '../.pikku/secrets/pikku-secrets.gen.js'
import type { TypedVariablesService } from '../.pikku/variables/pikku-variables.gen.js'
import type { auth } from './auth.js'
export interface UserSession extends CoreUserSession {
userId: string
}
export interface Config extends CoreConfig {
port: number
hostname: string
}
export interface SingletonServices extends CoreSingletonServices<Config> {
variables: TypedVariablesService
secrets: TypedSecretService
kysely: Kysely<DB>
// Resolved Better Auth instance, injected once by the generated pikkuServices
// wrapper from the `auth` factory. Typed as the factory's return so functions
// get better-auth's full server `api`/`handler` surface via DI.
auth: Awaited<ReturnType<typeof auth>>
// Per-invocation audit log returned from createWireServices (see services.ts).
// Promoted to CoreSingletonServices in @pikku/core; declared here until this
// template bumps to the release that ships it, after which it is inherited.
auditLog?: AuditLog
}
export interface Services extends CoreServices<SingletonServices> {}

View File

@@ -0,0 +1,33 @@
import { betterAuth } from 'better-auth'
import { pikkuBetterAuth } from '#pikku'
/**
* Better Auth configuration — email + password sign-in.
*
* `pikkuBetterAuth` has no side effects: the pikku CLI statically inspects this single
* exported `auth` const and generates the catch-all `/api/auth/**` HTTP wiring,
* the session-bridge middleware, and a `wireSecret` for `BETTER_AUTH_SECRET` (and
* one per social provider, if you add any) — so the auth routes and secret
* requirements flow through normal inspection into the deploy manifest.
*
* The factory runs once when singleton services are built, pulling the secret
* (and the database) off the injected `services`; the resolved instance is then
* available to every function as `services.auth`. Better Auth is given the app's
* own kysely: the CamelCasePlugin maps Better Auth's camelCase field names onto
* the snake_case columns created in db/sqlite/0001-init.sql, keeping the whole DB
* on one naming convention. To offer Google / GitHub / ... add a `socialProviders`
* entry (and a button on the login page) — the CLI will wire its secret too.
*/
export const auth = pikkuBetterAuth(async ({ kysely, secrets }) => {
const BETTER_AUTH_SECRET = await secrets.getSecret('BETTER_AUTH_SECRET')
return betterAuth({
secret: BETTER_AUTH_SECRET,
database: { db: kysely, type: 'sqlite' },
emailAndPassword: { enabled: true },
// Stateless session: CLI splits out betterAuthStatelessSession so non-auth
// units verify the signed cookie instead of bundling better-auth. pikku #737.
session: { cookieCache: { enabled: true } },
advanced: { database: { generateId: 'uuid' } },
})
})

View File

@@ -0,0 +1,6 @@
import { pikkuConfig } from '../.pikku/pikku-types.gen.js'
export const createConfig = pikkuConfig(async () => ({
port: parseInt(process.env.API_PORT || '4003', 10),
hostname: process.env.HOST || '0.0.0.0',
}))

View File

@@ -0,0 +1,29 @@
import type { Generated } from 'kysely'
// TypeScript is camelCase everywhere; the CamelCasePlugin (see services.ts) maps
// these to the snake_case columns in db/migrations. Keep this in sync with the
// migrations: TS camelCase here, snake_case in the .sql files. Columns with a SQL
// DEFAULT are wrapped in Generated<> so they're optional on insert.
// Better Auth owns the `user` table (and session/account/verification) and
// writes them through the SAME kysely the app uses (CamelCasePlugin maps its
// camelCase field names onto the snake_case columns in 0001-init.sql), so the
// app only ever READS from it. We model just the three columns the demo reads
// back (id / email / name) for the get-message / update-message join.
export interface UserTable {
id: string
email: string
name: string
}
export interface MessageStateTable {
id: number
message: string
updatedAt: Generated<string>
updatedByUserId: string | null
}
export interface DB {
user: UserTable
messageState: MessageStateTable
}

View File

@@ -0,0 +1,24 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const EdgeEchoInput = z.object({
message: z.string().trim().min(1).max(280),
})
export const EdgeEchoOutput = z.object({
echoed: z.string(),
/** True — this runs as a serverless edge Worker (no `deploy: 'server'`), so
* it has no stable pid to report, unlike the server-target functions. */
serverless: z.boolean(),
})
// No `deploy` field → serverless (edge Worker). Pairs with serverCounter /
// serverUptime to show both targets coexisting in one deployment.
export const edgeEcho = pikkuSessionlessFunc({
expose: true,
auth: false,
description: 'Echoes a message back from a serverless edge Worker.',
input: EdgeEchoInput,
output: EdgeEchoOutput,
func: async (_services, { message }) => ({ echoed: message, serverless: true }),
})

View File

@@ -0,0 +1,56 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
export const GetMessageInput = z.object({})
export const GetMessageOutput = z.object({
message: z.string(),
updatedAt: z.string(),
updatedBy: z
.object({
email: z.string().email(),
name: z.string().nullable(),
})
.nullable(),
})
export const getMessage = pikkuFunc({
expose: true,
readonly: true,
auth: true,
description: 'Returns the current starter message for the signed-in user.',
input: GetMessageInput,
output: GetMessageOutput,
func: async ({ kysely }) => {
const record = await kysely
.selectFrom('messageState')
.leftJoin('user', 'user.id', 'messageState.updatedByUserId')
.select([
'messageState.message as message',
'messageState.updatedAt as updatedAt',
'user.email as email',
'user.name as name',
])
.where('messageState.id', '=', 1)
.executeTakeFirst()
if (!record) {
return {
message: 'Starter message',
updatedAt: new Date().toISOString(),
updatedBy: null,
}
}
return {
message: record.message,
updatedAt: new Date(record.updatedAt).toISOString(),
updatedBy: record.email
? {
email: record.email,
name: record.name ?? null,
}
: null,
}
},
})

View File

@@ -0,0 +1,32 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
export const GetSessionInput = z.object({})
export const GetSessionOutput = z.object({
userId: z.string(),
email: z.string().email(),
name: z.string().nullable(),
})
export const getSession = pikkuFunc({
expose: true,
readonly: true,
auth: true,
description: 'Returns the current signed-in user.',
input: GetSessionInput,
output: GetSessionOutput,
func: async ({ kysely }, _input, { session }) => {
const user = await kysely
.selectFrom('user')
.select(['id', 'email', 'name'])
.where('id', '=', session!.userId)
.executeTakeFirstOrThrow()
return {
userId: user.id,
email: user.email,
name: user.name ?? null,
}
},
})

View File

@@ -0,0 +1,27 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
// In-process state held by the container instance.
let count = 0
export const ServerCounterInput = z.object({})
export const ServerCounterOutput = z.object({
/** Total times this container instance has served the counter. */
count: z.number().int(),
/** PID of the serving container — stable across calls to the same instance. */
pid: z.number().int(),
})
export const serverCounter = pikkuSessionlessFunc({
expose: true,
auth: false,
deploy: 'server',
description: 'Increments an in-memory counter held by the container instance.',
input: ServerCounterInput,
output: ServerCounterOutput,
func: async () => {
count += 1
return { count, pid: process.pid }
},
})

View File

@@ -0,0 +1,28 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const ServerUptimeInput = z.object({})
export const ServerUptimeOutput = z.object({
/** Seconds the container process has been running. */
uptimeSeconds: z.number(),
/** Node version the container runs — surfaces the server image's runtime. */
nodeVersion: z.string(),
/** Resident set size in MB at the time of the call. */
rssMb: z.number(),
})
export const serverUptime = pikkuSessionlessFunc({
expose: true,
auth: false,
readonly: true,
deploy: 'server',
description: 'Reports the serving container process uptime and runtime.',
input: ServerUptimeInput,
output: ServerUptimeOutput,
func: async () => ({
uptimeSeconds: Math.round(process.uptime()),
nodeVersion: process.version,
rssMb: Math.round((process.memoryUsage().rss / 1024 / 1024) * 10) / 10,
}),
})

View File

@@ -0,0 +1,57 @@
import { z } from 'zod'
import { pikkuFunc } from '#pikku'
import { GetMessageOutput } from './get-message.function.js'
export const UpdateMessageInput = z.object({
message: z.string().trim().min(1).max(160),
})
export const UpdateMessageOutput = GetMessageOutput
export const updateMessage = pikkuFunc({
expose: true,
auth: true,
description: 'Updates the current starter message.',
input: UpdateMessageInput,
output: UpdateMessageOutput,
func: async ({ kysely }, { message }, { session }) => {
const userId = session!.userId
await kysely
.insertInto('messageState')
.values({
id: 1,
message,
updatedByUserId: userId,
})
.onConflict((oc: any) =>
oc.column('id').doUpdateSet({
message,
updatedAt: new Date().toISOString(),
updatedByUserId: userId,
}),
)
.execute()
const record = await kysely
.selectFrom('messageState')
.innerJoin('user', 'user.id', 'messageState.updatedByUserId')
.select([
'messageState.message as message',
'messageState.updatedAt as updatedAt',
'user.email as email',
'user.name as name',
])
.where('messageState.id', '=', 1)
.executeTakeFirstOrThrow()
return {
message: record.message,
updatedAt: new Date(record.updatedAt).toISOString(),
updatedBy: {
email: record.email,
name: record.name ?? null,
},
}
},
})

View File

@@ -0,0 +1,45 @@
import { LocalEmailService, type EmailService, type SendEmailInput, type SendEmailResult } from '@pikku/core/services'
import { renderEmailTemplate, type EmailTemplateName } from '../../.pikku/email/pikku-emails.gen.js'
type GeneratedTemplateEmailServiceOptions = {
delegate?: EmailService
defaultLocale?: string
}
export class GeneratedTemplateEmailService implements EmailService {
private readonly delegate: EmailService
private readonly defaultLocale: string
constructor(options: GeneratedTemplateEmailServiceOptions = {}) {
this.delegate = options.delegate ?? new LocalEmailService()
this.defaultLocale = options.defaultLocale ?? 'en'
}
async send(input: SendEmailInput): Promise<SendEmailResult> {
if (!('template' in input) || !input.template) {
return this.delegate.send(input)
}
const rendered = renderEmailTemplate({
name: input.template.name as EmailTemplateName,
locale: (input.template.locale ?? this.defaultLocale) as Parameters<typeof renderEmailTemplate>[0]['locale'],
data: input.template.data ?? {},
})
return this.delegate.send({
to: input.to,
from: input.from,
cc: input.cc,
bcc: input.bcc,
replyTo: input.replyTo,
headers: {
...(input.headers ?? {}),
'x-pikku-email-template': String(input.template.name),
'x-pikku-email-hash': rendered.hash,
},
subject: rendered.subject,
html: rendered.html,
...(rendered.text ? { text: rendered.text } : {}),
})
}
}

View File

@@ -0,0 +1,18 @@
import { addHTTPMiddleware } from '@pikku/core/http'
import { cors } from '@pikku/core/middleware'
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
: [process.env.FRONTEND_URL, 'http://localhost:7104', 'http://127.0.0.1:7104'].filter(
(origin): origin is string => Boolean(origin),
)
addHTTPMiddleware('*', [
cors({
origin: corsOrigins,
credentials: true,
headers: ['Content-Type', 'Authorization', 'X-Auth-Return-Redirect'],
}),
])

View File

@@ -0,0 +1,68 @@
import {
JsonConsoleLogger,
LocalEmailService,
LocalSecretService,
LocalVariablesService,
NoopAuditService,
createInvocationAudit,
} from '@pikku/core/services'
import { createAuditedKysely } from '@pikku/kysely'
import { pikkuServices, pikkuWireServices } from '../.pikku/pikku-types.gen.js'
import { TypedSecretService } from '../.pikku/secrets/pikku-secrets.gen.js'
import { TypedVariablesService } from '../.pikku/variables/pikku-variables.gen.js'
import { CFWorkerSchemaService } from '@pikku/schema-cfworker'
import type { Kysely } from 'kysely'
import { GeneratedTemplateEmailService } from './lib/email-service.js'
import type { DB } from './db.js'
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 emailService = existingServices?.emailService ?? new GeneratedTemplateEmailService({
delegate: new LocalEmailService(),
})
// The durable audit sink. In a deployed stage fabric injects the platform's
// audit service; locally it falls back to a no-op so nothing is persisted.
const audit = existingServices?.audit ?? new NoopAuditService()
// kysely is injected by pikku dev (node:sqlite) or the CF Worker workflow (libsql).
// The template never constructs its own dialect — dialects are fabric/runtime
// concerns — so it must always be provided by the runtime.
if (!existingServices?.kysely) {
throw new Error('kysely service was not injected by the runtime (pikku dev / fabric)')
}
const kysely: Kysely<DB> = existingServices.kysely
return {
...(existingServices ?? {}),
config,
variables,
secrets,
schema,
logger,
emailService,
audit,
kysely,
}
})
export const createWireServices = pikkuWireServices(async (singletonServices, wire) => {
if (!singletonServices.audit) {
return {}
}
const auditLog = createInvocationAudit(singletonServices.audit, wire)
// `auditLog.config` is only set when this invocation opts into auditing
// (a function with `audit: true`). When it is, wrap kysely so every query is
// captured, and return the buffer so the runner flushes it on close. Otherwise
// leave the plain kysely untouched — no per-query overhead, no disabled-log warnings.
if (!auditLog.config) {
return {}
}
return {
auditLog,
kysely: createAuditedKysely(singletonServices.kysely, { audit: auditLog }),
}
})

View File

@@ -0,0 +1,11 @@
import { wireHTTP } from '#pikku'
import { serverUptime } from '../functions/server-uptime.function.js'
import { serverCounter } from '../functions/server-counter.function.js'
// Server-target functions need explicit HTTP routes. `expose: true` only
// registers the shared serverless /rpc/:rpcName endpoint, which the edge
// rpc-caller owns — so a server RPC invoked there 404s (not in its registry).
// A dedicated fetch route is "moved" onto the synthesized pikku-server-proxy
// unit, so the dispatcher forwards these paths to the container.
wireHTTP({ method: 'post', route: '/server/uptime', auth: false, func: serverUptime })
wireHTTP({ method: 'post', route: '/server/counter', auth: false, func: serverCounter })