Server API Reference
@korajs/server is the self-hosted sync server for Kora clients.
Imports
import {
createKoraServer,
createProductionServer,
KoraSyncServer,
MemoryServerStore,
SqliteServerStore,
PostgresServerStore,
createSqliteServerStore,
createPostgresServerStore,
NoAuthProvider,
TokenAuthProvider,
MixedAuthProvider,
KoraAuthProvider,
AwarenessRelay,
RETRIABLE_REJECTION_CODES,
isRetriableRejection,
} from '@korajs/server'Type-only exports for the validation and route-context surfaces are imported the same way:
import type {
OperationValidator,
OperationValidationContext,
OperationDecision,
OperationRejection,
ProductionServer,
ProductionHttpRouteContext,
RouteMutation,
RouteScopeOptions,
RouteApplyResult,
} from '@korajs/server'createKoraServer(config)
Creates a KoraSyncServer.
function createKoraServer(config: KoraSyncServerConfig): KoraSyncServerKoraSyncServerConfig
| Field | Type | Required | Default |
|---|---|---|---|
store | ServerStore | Yes | -- |
port | number | No | none (required for standalone start(); omit for attach mode via handleConnection()) |
host | string | No | '0.0.0.0' |
path | string | No | '/' |
auth | AuthProvider | No | NoAuthProvider behavior |
batchSize | number | No | 100 |
maxConnections | number | No | 0 (unlimited) |
schemaVersion | number | No | 1 |
maxOperationBytes | number | No | 262144 (256 KiB) |
maxOpsPerMinute | number | No | 600 |
validateOperation | OperationValidator | No | -- (all operations accepted) |
maxOperationBytes is the maximum serialized byte size of a single client operation accepted at sync ingest; operations larger than this are rejected before materialization. maxOpsPerMinute is the maximum operations accepted per connected client per minute (sliding window); operations beyond the limit are rejected with a retriable RATE_LIMIT rejection until the window resets. Both are enforced per connection across every connected client. validateOperation is covered under Operation Validation.
An operation outside the accepted uplink scope is rejected as non-retriable SCOPE_VIOLATION. Its sequence is acknowledged, the client moves it into durable rejected storage, and ingestion continues with later operations in the same batch. A temporary authorization workflow must refresh scopes before creating or explicitly resubmitting an authorized operation; the transport never loops the identical unauthorized bytes.
The built-in status snapshot and /__kora/metrics endpoint expose separate counters for received batches, total received operations, newly materialized operations, duplicates, and rejected operations (batchesReceived, operationsReceived, uniqueOperationsReceived, duplicateOperationsReceived, and rejectedOperations). The Prometheus names are kora_operation_batches_received_total, kora_operations_received_total, kora_unique_operations_received_total, kora_duplicate_operations_received_total, and kora_rejected_operations_total.
Example
import {
createKoraServer,
createPostgresServerStore,
TokenAuthProvider,
} from '@korajs/server'
const store = await createPostgresServerStore({
connectionString: process.env.DATABASE_URL!,
})
const auth = new TokenAuthProvider({
validate: async (token) => {
const payload = await verifyJWT(token)
if (!payload) return null
return {
userId: payload.sub,
scopes: {
todos: { userId: payload.sub },
},
}
},
})
const server = createKoraServer({ store, port: 3001, auth })
await server.start()createProductionServer(config)
Creates one HTTP server for static frontend assets, WebSocket sync, health checks, observability, dashboard, and backup endpoints.
import { createProductionServer, createSqliteServerStore } from '@korajs/server'
import schema from './src/schema'
const store = createSqliteServerStore({ filename: './.kora/kora-server.db' })
await store.setSchema(schema)
const server = createProductionServer({
store,
port: Number(process.env.PORT) || 3001,
staticDir: './dist',
syncPath: '/kora-sync',
crossOriginEmbedderPolicy: 'credentialless',
httpRoutes: [
// Example: mount @korajs/auth with createKoraAuthServer()
// { path: '/auth', handle: auth.handleRequest },
],
operationalAuth: {
adminToken: process.env.KORA_ADMIN_TOKEN,
metricsToken: process.env.KORA_METRICS_TOKEN,
backupToken: process.env.KORA_BACKUP_TOKEN,
},
})
await server.start()ProductionServerConfig
| Field | Type | Required | Default |
|---|---|---|---|
store | ServerStore | Yes | -- |
port | number | No | 3001 or process.env.PORT |
staticDir | string | No | './dist' |
syncPath | string | No | '/kora-sync' |
syncOptions | Omit<KoraSyncServerConfig, 'store' | 'port' | 'host' | 'path'> | No | -- |
crossOriginEmbedderPolicy | 'credentialless' | 'require-corp' | 'unsafe-none' | No | 'credentialless' |
httpRoutes | ProductionHttpRoute[] | No | -- |
operationalAuth | ProductionOperationalAuth | No | Public endpoints |
/health is always public for hosting platform health checks. Operational endpoints under /__kora/* are protected when the matching token is configured. Send tokens with Authorization: Bearer <token>.
httpRoutes are mounted before static file serving and are useful for auth routes, webhooks, and small app APIs without adding a separate HTTP framework.
crossOriginEmbedderPolicy controls the COEP response header for route and static responses. The default credentialless keeps common third-party embeds working while still allowing capable browsers to use cross-origin isolation; choose require-corp only when every embedded resource opts in with CORP/CORS headers.
| Token | Protects |
|---|---|
adminToken | /__kora, /__kora/status, /__kora/events |
metricsToken | /__kora/metrics; falls back to adminToken when omitted |
backupToken | /__kora/backup/export, /__kora/backup/import; falls back to adminToken when omitted |
ProductionServer
The handle returned by createProductionServer().
| Member | Type | Description |
|---|---|---|
start() | Promise<string> | Start listening. Resolves to the URL the server is available at. |
stop() | Promise<void> | Stop the server gracefully. |
kora | ProductionHttpRouteContext | Trusted, scoped data-plane access for server-side callers with no HTTP request (background jobs, scheduled tasks, seeding scripts). Same context handed to custom HTTP routes as request.kora. |
getLiveBlobRefs() | Promise<BlobRef[]> | Every blob reference still reachable from a live record across all collections that declare a blob field. Pass it to collectBlobGarbage(blobStore, refs) from @korajs/store to reclaim bytes no record points at any more. |
Route context (server.kora / request.kora)
ProductionHttpRouteContext runs mutations through the same validated pipeline as sync (Tier 2 constraints, referential integrity, materialization, and fan-out to connected clients), so server-side callers and custom HTTP routes cannot bypass validation, constraints, or tenant isolation.
| Method | Signature | Description |
|---|---|---|
apply | (mutation: RouteMutation, options?: RouteScopeOptions) => Promise<RouteApplyResult> | Apply a mutation through the validated pipeline and relay the resulting operation to connected clients. |
query | (collection: string, options?: CollectionQueryOptions & RouteScopeOptions) => Promise<MaterializedRecord[]> | Read materialized records from a collection, optionally scoped. |
findById | (collection: string, id: string, options?: RouteScopeOptions) => Promise<MaterializedRecord | null> | Read a single materialized record by id, optionally scoped. |
RouteMutation:
| Field | Type | Required | Description |
|---|---|---|---|
collection | string | Yes | Target collection. |
type | 'insert' | 'update' | 'delete' | Yes | Mutation type. |
recordId | string | No | Optional for inserts (a UUID v7 is generated when omitted); required for updates and deletes. |
data | Record<string, unknown> | null | No | Field values. For updates, only the changed fields. |
RouteScopeOptions:
| Field | Type | Description |
|---|---|---|
scope | Record<string, Record<string, unknown>> | Per-actor scope enforced exactly like a sync session's scope: apply() rejects a mutation whose resulting record falls outside it, and query() / findById() only return records inside it. Omit for genuinely public routes where no per-actor isolation applies. |
RouteApplyResult is a discriminated union on ok:
{ ok: true; operation: Operation; record: MaterializedRecord | null }{ ok: false; code: string; message: string; retriable: boolean }
Custom HTTP routes receive this context as request.kora on ProductionHttpRouteRequest, alongside method, path, body, headers, query, and ip.
KoraSyncServer
Main server class.
Methods
start(): Promise<void>: starts WebSocket server mode.stop(): Promise<void>: gracefully stops server and sessions.handleConnection(transport): string: attach a server transport manually.handleHttpRequest(request): Promise<HttpSyncResponse>: HTTP sync endpoint handler.getStatus(): Promise<ServerStatus>: returns runtime status.getConnectionCount(): number: returns active connection count.
Stores
All stores implement the ServerStore interface which extends the sync protocol's SyncStore with materialization support.
MemoryServerStore
In-memory only (testing/development). Data is lost when the process restarts.
const store = new MemoryServerStore()createSqliteServerStore(options) / SqliteServerStore
SQLite persistence for local or small deployments.
const store = createSqliteServerStore({ filename: './kora-server.db' })createPostgresServerStore(options) / PostgresServerStore
PostgreSQL persistence for production.
const store = await createPostgresServerStore({
connectionString: process.env.DATABASE_URL!,
})Delivery sequence (gap-free server-to-client sync)
Every store assigns each stored operation a monotonic delivery sequence in commit order. This is the substrate for the server-to-client delivery watermark that guarantees no operation is ever lost on its way to a client. Two ServerStore methods expose it (you rarely call them directly; the sync server uses them):
| Method | Description |
|---|---|
getMaxDeliverySequence(): Promise<number> | The highest delivery sequence currently stored (0 when empty). |
getOperationsAfterDelivery(afterDeliverySequence, limit): Promise<DeliveredOperation[]> | Operations with a delivery sequence above the cursor, in delivery order, up to limit. |
Operational notes:
- Automatic migration. On first startup after upgrading, each store adds a
delivery_seqcolumn and backfills existing operations deterministically (ordered by receipt time, then sequence number, then id). This runs once and needs no manual step. On SQLite and Postgres it is idempotent and safe to re-run. - Postgres and commit order. On Postgres the delivery sequence is assigned from a counter row locked inside the append transaction, so delivery order equals commit (visibility) order even across multiple server instances sharing one database. This serializes appends through one counter row, a deliberate correctness-over-throughput choice; it is not a bottleneck for typical sync workloads, but is worth knowing if you drive a single Postgres at very high sustained write rates.
- Concurrent cold start. Schema setup and the delivery-sequence backfill run under an advisory-locked transaction, so starting several server replicas at once against a fresh database is safe.
Materialized Collections
By default, the server stores data as an append-only operation log. For efficient queries (e.g., looking up records by field values), enable materialized collections by calling setSchema(). This creates actual SQL tables for each collection, with proper indexes, and dual-writes every synced operation to both the log and the collection table.
store.setSchema(schema)
Creates collection tables and indexes from your schema definition. If operations already exist in the log, backfills the materialized tables automatically.
import { defineSchema, t } from '@korajs/core'
const schema = defineSchema({
version: 1,
collections: {
todos: {
fields: {
title: t.string(),
completed: t.boolean().default(false),
userId: t.string(),
},
indexes: ['userId', 'completed'],
},
},
})
// Call after creating the store, before starting the server
await store.setSchema(schema)TIP
Always call setSchema() before starting the sync server. The schema enables materialized tables to be created and backfilled before clients connect.
store.queryCollection(collection, options?)
Query records from a materialized collection with filtering, ordering, and pagination. Returns an array of MaterializedRecord objects.
// Get all published forms
const forms = await store.queryCollection('forms', {
where: { status: 'published' },
orderBy: 'createdAt',
orderDirection: 'desc',
limit: 10,
offset: 0,
})CollectionQueryOptions
| Field | Type | Default | Description |
|---|---|---|---|
where | Record<string, unknown> | -- | Exact-match filters on field values |
orderBy | string | -- | Field name to sort by |
orderDirection | 'asc' | 'desc' | 'asc' | Sort direction |
limit | number | -- | Maximum records to return |
offset | number | -- | Records to skip (for pagination) |
includeDeleted | boolean | false | Include soft-deleted records |
store.findRecord(collection, id)
Find a single record by ID. Returns null if not found or deleted.
const form = await store.findRecord('forms', 'form-123')
if (form) {
console.log(form.title)
}store.countCollection(collection, where?)
Count records, optionally filtered.
// Total responses
const total = await store.countCollection('responses')
// Responses for a specific form
const formResponses = await store.countCollection('responses', {
formId: 'form-123',
})store.materializeCollection(collection)
Get all records from a collection. When schema is set, reads from the collection table. Otherwise falls back to replaying the operation log.
const allTodos = await store.materializeCollection('todos')WARNING
materializeCollection() returns ALL records. For large collections, use queryCollection() with limit and offset for pagination.
Authentication
NoAuthProvider
Accepts all connections. Every connection gets userId: 'anonymous'. Use for development/testing or apps that don't need auth.
const server = createKoraServer({ store })
// NoAuthProvider is the default when no auth is specifiedTokenAuthProvider
Validates tokens with your custom function. Returns null to reject a connection.
const auth = new TokenAuthProvider({
validate: async (token) => {
const user = await verifyToken(token)
return user ? { userId: user.id } : null
},
})MixedAuthProvider
Accepts both authenticated and anonymous connections. Authenticated users get full access; anonymous users get restricted access via scoped collections.
This is the recommended provider for apps with public-facing features, for example a form builder where authenticated users create forms but anyone can submit responses.
import { MixedAuthProvider } from '@korajs/server'
const auth = new MixedAuthProvider({
// Primary auth validates tokens for authenticated users
primary: authRoutes.toSyncAuthProvider(),
// Anonymous users can only sync the 'responses' collection
anonymousScopes: {
responses: {},
},
})
const server = new KoraSyncServer({ store, auth })On the client side, return an empty token for unauthenticated users (or use createKoraAuthSync):
import { createKoraAuthSync } from '@korajs/auth'
const app = createApp({
schema,
sync: {
url: 'wss://my-server.com/kora',
authClient: createKoraAuthSync({ authClient, schema }),
},
})Options
| Field | Type | Default | Description |
|---|---|---|---|
primary | AuthProvider | -- | Auth provider for authenticated users |
anonymousScopes | Record<string, Record<string, unknown>> | -- | Collections anonymous users can sync. Use {} for unrestricted access to a collection. |
anonymousPrefix | string | 'anon' | Prefix for generated anonymous user IDs |
See the Common Patterns guide for a complete walkthrough.
KoraAuthProvider
Bridges @korajs/auth with the sync server. Validates JWTs issued by TokenManager, checks user existence, updates device timestamps, and resolves sync scopes.
import { KoraAuthProvider } from '@korajs/server'
import { TokenManager } from '@korajs/auth/server'
const auth = new KoraAuthProvider({
tokenValidator: tokenManager,
userLookup: userStore,
deviceTracker: userStore, // optional
resolveScopes: async (userId) => ({
todos: { userId },
}),
})AuthContext
The return type from authenticate():
| Field | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Unique user identifier |
scopes | Record<string, Record<string, unknown>> | No | Per-collection sync scope filters |
downlinkScopes | Record<string, Record<string, unknown>> | No | Records this session may receive |
uplinkScopes | Record<string, Record<string, unknown>> | No | Operations this session may upload |
metadata | Record<string, unknown> | No | Arbitrary metadata (device info, email, etc.) |
scopes remains shorthand for both directions. Directional maps are server-authoritative and omitted collections deny access. The uplink check runs before validateOperation; accepted maps are returned in handshake diagnostics without auth claims or tokens.
return {
userId,
downlinkScopes: { submissions: { learnerId: userId } },
uplinkScopes: { submissions: { authorId: userId } },
}Operation Validation
The validateOperation config hook adjudicates untrusted client operations before they become authoritative. It runs at sync ingestion for every incoming client operation, after HLC ordering and the built-in guards (timestamp, rate, size), and before materialization. This is what lets Kora serve public / multi-tenant apps where the client is not trusted. Omit it and every operation is accepted.
import { createProductionServer } from '@korajs/server'
import type { OperationValidator } from '@korajs/server'
const validateOperation: OperationValidator = async (operation, context) => {
// Anonymous submitters can only insert into 'responses'
if (!context.auth && operation.collection !== 'responses') {
return { action: 'reject', code: 'SCOPE_VIOLATION', message: 'Not allowed' }
}
return { action: 'accept' }
}
const server = createProductionServer({
store,
syncOptions: { validateOperation },
})OperationValidator
type OperationValidator = (
operation: Operation,
context: OperationValidationContext,
) => Promise<OperationDecision> | OperationDecisionOperationValidationContext
The context passed to the validator.
| Field | Type | Description |
|---|---|---|
auth | AuthContext | null | The authenticated actor for the submitting session, or null for an anonymous / unauthenticated connection. |
kora | ProductionHttpRouteContext | Trusted, scoped data-plane access (the same context as server.kora / request.kora). Use kora.query / kora.findById to read current authoritative state while deciding, and kora.apply to author a derived server operation. Authoring a new operation is preferred over mutating the incoming one, which must stay immutable so content-addressing and convergence hold. |
OperationDecision
The verdict a validator returns for one incoming operation. A discriminated union on action:
| Variant | Shape | Meaning |
|---|---|---|
| accept | { action: 'accept' } | Let the operation materialize as-is and relay to connected clients. |
| reject | { action: 'reject'; code: string; message: string; retriable?: boolean } | Refuse it. The operation never enters the authoritative log; a structured rejection travels back to the submitter tied to the operation id, and the submitter keeps the op in a durable rejected store. retriable defaults from the shared taxonomy for the code. |
| ignore | { action: 'ignore' } | The server handled the operation out of band (for example the validator already authored a derived op via context.kora). No rejection is sent; the submitter treats it as handled and drops it from its pending queue. |
Rejection Taxonomy
The shared vocabulary for why the server refused an operation. The retriable flag answers whether resubmitting the identical operation may later succeed: true for transient conditions (for example a rate limit), false for permanent ones (a constraint violation, referential conflict, malformed mutation, or scope violation).
OperationRejection
| Field | Type | Description |
|---|---|---|
code | string | Stable, machine-readable reason code (for example CONSTRAINT_VIOLATION). |
message | string | Human-readable explanation with enough context to debug without reproduction. |
retriable | boolean | Whether resubmitting the identical operation may later succeed. |
RETRIABLE_REJECTION_CODES
const RETRIABLE_REJECTION_CODES: ReadonlySet<string>The set of reason codes whose underlying condition is transient. Everything not listed is treated as permanent (the safe default). Currently contains RATE_LIMIT, the code the session emits when a client exceeds its per-minute operation budget.
isRetriableRejection(code)
function isRetriableRejection(code: string): booleanReturns true when resubmitting the identical operation may later succeed (that is, when code is in RETRIABLE_REJECTION_CODES).
Awareness Relay
AwarenessRelay broadcasts ephemeral presence/awareness state between connected clients. It does not persist any data -- awareness is purely real-time.
AwarenessRelay
import { AwarenessRelay } from '@korajs/server'
const relay = new AwarenessRelay()The KoraSyncServer integrates the awareness relay automatically. When a client sends an awareness update, the server relays it to all other connected clients.
| Method | Description |
|---|---|
addClient(clientId, send) | Register a client connection for awareness broadcasts |
removeClient(clientId) | Remove a client and broadcast their departure |
handleUpdate(clientId, state) | Process an awareness state update from a client |
getStates() | Get all current awareness states |
Awareness messages are lightweight and bypass the operation log -- they are not persisted, not synced on reconnect, and do not affect the operation DAG. See the Presence guide for the full client-server flow.

