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.
import { setupCopyInk } from 'copy-ink/server'
import config from './copy-ink.config'
export default setupCopyInk(config)
| Option | Type | Default | Description |
|---|---|---|---|
root | string | process.cwd() | Project root |
cache | boolean | true | Cache parsed documents per process |
sessions | SessionStore | in-memory | See Sessions |
fs | ContentFs | Node's | Filesystem adapter, for tests and non-Node hosts |
requestPath | () => Promise<string | null> | Next headers | Override route resolution |
editorSession | () => Promise<EditorSession | null> | cookie lookup | Override 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' })
| Option | Type | Default |
|---|---|---|
scope | string | current route |
locale | string | route's locale |
Returns a CopyReader:
| Member | Type | Description |
|---|---|---|
get(field) | string | Coerced; '' when missing |
raw(field) | unknown | The parsed value, including typed nodes |
has(field) | boolean | |
all() | ContentData | The whole parsed document |
scope | string | Resolved scope |
locale | string | Resolved 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.
| Option | Type |
|---|---|
locale | string |
limit | number |
orderBy | string |
direction | 'asc' | 'desc' |
filter | (item: CollectionItem) => boolean |
includeDrafts | boolean |
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?)
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 set | SignedSessionStore — the session is a signed cookie, held nowhere |
| Unset | MemorySessionStore — one process, hung off globalThis |
sessions passed to setupCopyInk | Yours, 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.
SESSION_COOKIE, toPublicSession(session)
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
| Export | Description |
|---|---|
GitHubAuthProvider | GitHub OAuth |
gitHubAuthFromEnv() | Builds one from COPY_INK_GITHUB_CLIENT_ID / _SECRET, or null |
LocalAuthProvider | Signs straight in. Development only |
LOCAL_AUTH_ID | 'local' |
isAllowed(user, allowlist) | Login-or-email allowlist check |
Backends
| Export | Description |
|---|---|
GitHubBackend | Commits through the GitHub API |
LocalBackend | Writes to the working tree |
GitHubAppTokenProvider | Mints installation tokens from an App's private key |
StaticTokenProvider | Wraps 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
| Export | Description |
|---|---|
ContentStore | Reads, caches and invalidates content documents |
nodeFs | The Node filesystem adapter |
ContentFs | The interface a custom adapter implements |
Also exported from copy-ink
CopyInkError, isCopyInkError, and the CopyInkErrorCode union — see
Errors.