@nextgensoftwares/folio-server-node
The reference Folio document server for Node 22+: the REST sync protocol on node:http (no framework), collaboration over WebSocket (ws), persistence behind a DocStorage interface (files + an append-only log by default), brotli/gzip, keep-alive, optional HTTP/2. Guide: Real networks.
import { toProseMirrorSchema } from '@nextgensoftwares/folio-editor';
import { createFolioServer, FileStorage } from '@nextgensoftwares/folio-server-node';
const app = createFolioServer({
schema: toProseMirrorSchema(hostSchema), // the same schema as the clients
storage: new FileStorage('/var/lib/folio'),
cors: ['https://app.example.com'],
authorize: (req, docId, action) => canAccess(req, docId, action),
});
await app.listen(8787, '0.0.0.0');The server's schema must be the clients' full schema. Build it from the standard schema plus every enabled plugin's Node-safe /schema entry (no React, no DOM), so the server, live collab and the editors agree:
import { standardSchema } from '@nextgensoftwares/folio-model';
import { schema as code } from '@nextgensoftwares/folio-plugin-code/schema';
import { schema as layers } from '@nextgensoftwares/folio-plugin-layers/schema';
import { schema as toc } from '@nextgensoftwares/folio-plugin-toc/schema';
// ...one per plugin the editor uses (every @nextgensoftwares/folio-plugin-* has `/schema`)
const hostSchema = [code, layers, toc].reduce((s, part) => s.extend(part), standardSchema);A push that uses a node, mark or attribute the server's schema lacks is refused with 422 { rejected: 'unsupported', error }; the error names the types (e.g. node type "drawing"), the client stops with that message (SyncStatus.reason / CollabStatus.reason === 'unsupported', no auto-resync) instead of reporting a divergence. PUT /docs/:id refuses such documents with 400 and the same message. node tools/check-schema-entries.mjs (after pnpm build) checks every /schema entry stays Node-safe.
Server
| Export | Signature | Description |
|---|---|---|
createFolioServer | (opts: FolioServerOptions) => FolioServer | REST routes + WebSocket collab + storage; listen(port?, host?), close() (snapshots dirty documents) |
type FolioServerOptions | schema, storage? (MemoryStorage), cors? (true or origins), collab? (false or CollabOptions), tls? ({ key, cert }: HTTP/2 with HTTP/1.1 fallback), warmManifests? (true), exposeStats? (memory in /health; dev only), fallback? (host routes), plus RouteOptions and PersistOptions | |
type FolioServer | { server, documents, collab?, listen, close } | |
createRoutes | (docs, opts?: RouteOptions) => (req, res) => Promise<boolean> | the protocol routes alone, for mounting in another node:http app; resolves false when the URL isn't Folio's |
type RouteOptions | authorize?, allowCreate? (false), maxBodyBytes? (4 MB), maxPullSteps? (2000), chunkCache? ('private' or 'public'), compressedCacheBytes? (64 MB), onCommit?(docId) | |
type DocRouteOptions | the document create/list routes' options (also part of RouteOptions): allowCreate? (false: PUT /docs/:id creates a document from a whole snapshot, ?title=, gzip accepted), maxDocumentBytes? (64 MB decompressed), allowList? (true: GET /docs, each document still passes authorize(req, id, 'read')) | |
type Authorize | (req, docId, action: Action) => boolean | Promise<boolean> | auth hook; false = 403 |
type Action | 'read' | 'write' | 'create' | 'collab' | |
class CollabGateway | new (docs, opts?: CollabOptions) | /docs/:id/collab upgrades to a CollabRoom; observe(docId) broadcasts REST pushes; heartbeats; close() |
type CollabOptions | authorize?, maxMessageBytes? (4 MB), heartbeatMs? (30 s), room?(docId) (history, step limits) |
Routes: GET /docs/:id/manifest[?first=1], GET /docs/:id/chunks/:hash, GET /docs/:id/pages/:n, POST /docs/:id/steps, GET /docs/:id/steps?since=N, PUT /docs/:id/layout, PUT /docs/:id (create, when allowCreate), GET /health, WebSocket /docs/:id/collab.
Documents and storage
| Export | Signature | Description |
|---|---|---|
class PersistentDocumentServer | new (schema, storage: DocStorage, opts?: PersistOptions) | MemoryDocumentServer (same validation, chunking, pulls) that appends every accepted push to storage before acknowledging it; load(docId), create(docId, doc?), snapshot(docId), close(), lastPersistMs |
type PersistOptions | ServerOptions + snapshotEvery (2000 steps), snapshotIdleMs (10 s), retainSteps (20,000), retainStepBytes (8 MB) | |
docWire | (doc: PMNode) => string | compact JSON of a whole document (reuses the per-block wire memo) |
interface DocStorage | list, load, create, append (synchronous, durable before the ack), snapshot (async), saveLayout?, close? | |
type StoredDoc | { snapshot: { version, doc }, log: StepEntry[], logFrom, layout?, meta? } | |
type DocMeta | display metadata given at creation ({ title? }), stored on StoredDoc.meta; DocStorage.create(docId, doc, meta?) | |
class MemoryStorage | the contract in memory (tests, demos) | |
class FileStorage | new (dir, opts?: FileStorageOptions) | snapshot.json (atomic rename) + steps.log (JSON lines, fsynced) + layout.json per document; a torn last line is dropped on boot, a corrupt middle line refuses to load |
type FileStorageOptions | fsync (true), compactLogBytes (8 MB) | |
isValidDocId | (id: string) => boolean | letters, digits, ., _, -, up to 128, not only dots |
Validation and HTTP helpers
| Export | Description |
|---|---|
parsePush(body) / type PushBody | shape check of POST /steps (400 on anything else); the steps go through validatePush |
parseLayout(body) | bounded, typed LayoutReport |
class HttpFail | client error with a status, safe to show |
negotiate(req) / type Encoding | 'br' | 'gzip' | 'identity' from Accept-Encoding (honours q=0) |
compress(body, enc) | brotli (quality 5) or gzip on zlib's threadpool |
class CompressedCache | byte-bounded LRU of compressed immutable bodies |
readBody(req, limit) | request body as text, 413 beyond limit |
send(req, res, status, body, opts?: SendOptions) / type SendOptions | JSON response, compressed when accepted and ≥ 1 KB |
Comments
Off unless createFolioServer({ comments: { identify, ... } }). Routes under /docs/:id/comments; every change is also sent to the document's collaborators as a collab event. Guide: Comments plugin.
| Export | Description |
|---|---|
createCommentRoutes(opts) / type CommentRoutesOptions | the routes for any node:http server: identify (required; the author always comes from here), the client's policy options (readers, canEdit...), authorize, backend, exists, onChange, onMention, maxBodyBytes. Writes per document are serialized; 401 anonymous, 403 policy/hook, 404 unknown thread or document |
type CommentAuthorize | (req, docId, action, { user, op?, thread? }) => boolean | Promise<boolean>: an extra check after the policy |
type CommentBackend, MemoryCommentBackend, FileCommentBackend(dir) | where threads persist (<dir>/<docId>.comments.json, written atomically) |
insecureHeaderIdentity(header?) | demo only: trusts a JSON user in X-Folio-User |