chore: server-and-serverless template
This commit is contained in:
32
packages/functions/src/application-types.d.ts
vendored
Normal file
32
packages/functions/src/application-types.d.ts
vendored
Normal 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> {}
|
||||
33
packages/functions/src/auth.ts
Normal file
33
packages/functions/src/auth.ts
Normal 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' } },
|
||||
})
|
||||
})
|
||||
6
packages/functions/src/config.ts
Normal file
6
packages/functions/src/config.ts
Normal 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',
|
||||
}))
|
||||
29
packages/functions/src/db.ts
Normal file
29
packages/functions/src/db.ts
Normal 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
|
||||
}
|
||||
24
packages/functions/src/functions/edge-echo.function.ts
Normal file
24
packages/functions/src/functions/edge-echo.function.ts
Normal 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 }),
|
||||
})
|
||||
56
packages/functions/src/functions/get-message.function.ts
Normal file
56
packages/functions/src/functions/get-message.function.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
32
packages/functions/src/functions/get-session.function.ts
Normal file
32
packages/functions/src/functions/get-session.function.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
27
packages/functions/src/functions/server-counter.function.ts
Normal file
27
packages/functions/src/functions/server-counter.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
28
packages/functions/src/functions/server-uptime.function.ts
Normal file
28
packages/functions/src/functions/server-uptime.function.ts
Normal 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,
|
||||
}),
|
||||
})
|
||||
57
packages/functions/src/functions/update-message.function.ts
Normal file
57
packages/functions/src/functions/update-message.function.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
45
packages/functions/src/lib/email-service.ts
Normal file
45
packages/functions/src/lib/email-service.ts
Normal 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 } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
18
packages/functions/src/middleware/cors.middleware.ts
Normal file
18
packages/functions/src/middleware/cors.middleware.ts
Normal 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'],
|
||||
}),
|
||||
])
|
||||
68
packages/functions/src/services.ts
Normal file
68
packages/functions/src/services.ts
Normal 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 }),
|
||||
}
|
||||
})
|
||||
11
packages/functions/src/wirings/server.http.ts
Normal file
11
packages/functions/src/wirings/server.http.ts
Normal 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 })
|
||||
Reference in New Issue
Block a user