Skip to main content

Server API

import { /* … */ } from 'copy-ink/server'

Server-only. Importing this entry from a client component is an error.

Setup

setupCopyInk(config, options?)

Registers the project's config and returns the runtime. Call it at module scope from a module your root layout imports, so it runs once per process before anything renders.

copy-ink.setup.ts
import { setupCopyInk } from 'copy-ink/server'
import config from './copy-ink.config'

export default setupCopyInk(config)
OptionTypeDefaultDescription
rootstringprocess.cwd()Project root
cachebooleantrueCache parsed documents per process
sessionsSessionStorein-memorySee Sessions
fsContentFsNode'sFilesystem adapter, for tests and non-Node hosts
requestPath() => Promise<string | null>Next headersOverride route resolution
editorSession() => Promise<EditorSession | null>cookie lookupOverride session resolution

getRuntime() / peekRuntime() / resetRuntime()

getRuntime returns the registered runtime and throws NOT_SET_UP when there is none. peekRuntime returns null instead. resetRuntime clears it — for tests.

PATH_HEADER

The header name the middleware stamps the pathname onto. Exported so a custom host can set it itself.

Reading content

getCopy(options?)

const copy = await getCopy({ scope: 'about', locale: 'de' })
OptionTypeDefault
scopestringcurrent route
localestringroute's locale

Returns a CopyReader:

MemberTypeDescription
get(field)stringCoerced; '' when missing
raw(field)unknownThe parsed value, including typed nodes
has(field)boolean
all()ContentDataThe whole parsed document
scopestringResolved scope
localestringResolved locale

Without an explicit scope this reads request headers, which makes the page dynamic. Pass one to keep a page statically prerenderable.

getItem(collection, slug, options?)

Returns a CollectionItem or null, with the same default-locale fallback as <Copy.Item>. Options: { locale }.

getList(collection, options?)

Returns CollectionItem[], ordered the way <Copy.List> orders and with drafts excluded.

OptionType
localestring
limitnumber
orderBystring
direction'asc' | 'desc'
filter(item: CollectionItem) => boolean
includeDraftsboolean

getSlugs(collection, options?)

getList(...).map(item => item.slug), for generateStaticParams.

getMetadata(options?) and generateMetadata()

export { generateMetadata } from 'copy-ink/server'

getMetadata reads a route's meta: block and returns { title, description, openGraph }. generateMetadata is the zero-argument form for re-export. See Page metadata.

Route handler

createCopyInkHandler(runtime?)

app/api/copy-ink/[...copyInk]/route.ts
import { createCopyInkHandler } from 'copy-ink/server'
import copyInk from '@/copy-ink.setup'

export const { GET, POST } = createCopyInkHandler(copyInk)
export const runtime = 'nodejs'

Returns { GET, POST }. Pass the runtime. A route handler is its own entry point with its own module graph, so the root layout importing the setup module does not register anything here — and the failure surfaces at request time, not at build. copy-ink doctor reports the no-argument form as an error.

The routes it serves are listed in the HTTP API.

safeReturnPath(candidate)

Normalises a ?next= value to a same-origin path, falling back to /. Exported because a custom sign-in page needs the same guarantee.

Sessions

SessionStore

interface SessionStore {
create(user: CopyInkUser, token: string): Promise<EditorSession>
get(id: string): Promise<EditorSession | null>
destroy(id: string): Promise<void>
size(): Promise<number>
}

Which store you get

COPY_INK_SESSION_SECRET setSignedSessionStore — the session is a signed cookie, held nowhere
UnsetMemorySessionStore — one process, hung off globalThis
sessions passed to setupCopyInkYours, whatever the environment says

MemorySessionStore is hung off globalThis so Server Components and Route Handlers — which Next builds into separate module graphs — share one store per process. That is the most it can do: a second instance, or a serverless function that cold-started, has its own memory and its own empty store, so a client is signed out mid-edit.

SignedSessionStore

COPY_INK_SESSION_SECRET=$(openssl rand -base64 32)

Set that and sign-in survives anywhere the same secret is set — which is the whole of the serverless problem, gone.

It works because of something worth stating plainly: the forge access token is never read after sign-in. Commits are made with the GitHub App installation's own credentials, so what a session actually has to carry is identity, and identity is safe to hand back to the browser signed. The payload is the user plus an expiry, HMAC-SHA256 over both, verified in constant time.

The trade is explicit: this store sets token to ''. A backend that needs to act as the signed-in user rather than as the App has to keep the token server-side, and needs a stateful store:

setupCopyInk(config, { sessions: yourRedisBackedStore })

A secret shorter than 32 characters is refused rather than quietly weakening everything above it.

SESSION_SECRET_ENV, sessionStoreFromEnv(env?)

The env var name, and the helper setupCopyInk uses to pick a store — it returns a SignedSessionStore when the secret is set and null otherwise.

The cookie name, and the reducer that strips the forge token before a session is serialised into a response. The token never leaves the server.

Services

getServices(runtime) / setServices(partial) / resetServices()

import { setServices } from 'copy-ink/server'

setServices({ auth: myAuthProvider, backend: myBackend })

getServices builds the auth provider and backend from config and environment, memoised. setServices overrides either half — for tests, or for a custom provider. resetServices clears the overrides and the cache.

Auth providers

ExportDescription
GitHubAuthProviderGitHub OAuth
gitHubAuthFromEnv()Builds one from COPY_INK_GITHUB_CLIENT_ID / _SECRET, or null
LocalAuthProviderSigns straight in. Development only
LOCAL_AUTH_ID'local'
isAllowed(user, allowlist)Login-or-email allowlist check

Backends

ExportDescription
GitHubBackendCommits through the GitHub API
LocalBackendWrites to the working tree
GitHubAppTokenProviderMints installation tokens from an App's private key
StaticTokenProviderWraps a personal access token
tokenSourceFromEnv()Picks whichever of the two the environment supports
buildFileChanges(changes, ctx)Turns a changeset into file writes
buildCommitMessage(input)The content: update … message, with attribution
interface ContentBackend {
read(path: string): Promise<Uint8Array>
list(prefix: string): Promise<string[]>
headSha(): Promise<string | null>
commit(
changes: FileChange[],
baseSha: string | null,
message: string,
author?: CommitAuthor,
): Promise<CommitResult>
}

Content store

ExportDescription
ContentStoreReads, caches and invalidates content documents
nodeFsThe Node filesystem adapter
ContentFsThe interface a custom adapter implements

Also exported from copy-ink

CopyInkError, isCopyInkError, and the CopyInkErrorCode union — see Errors.