Storage adapters, comment stores, Nitro host
Ready-made EditorPorts bundles (the ports each storage serves), comment stores, a Nuxt-free Nitro host and a Vue composable ship inside this package as subpath entries (formerly the separate package @skaile/forge-editor-adapters; from 2.1.0 @skaile/forge-editor is the only dependency). A host picks where documents live without touching the editor. All adapters satisfy the same contract test (test/adapters/adapter-contract.test.ts).
bun add @skaile/forge-editor # plus drizzle-orm only for the three drizzle entries (PGlite or a Postgres driver for the Block index)
| Entry | Adapter | Storage | Needs |
|---|---|---|---|
@skaile/forge-editor/adapters/json | createJsonAdapter({ dir, users? }) | files under dir | nothing |
@skaile/forge-editor/adapters/drizzle | createDrizzleAdapter({ db, tables: editorTables() }) | SQLite via drizzle (bun:sqlite, better-sqlite3, libsql, D1) | drizzle-orm |
@skaile/forge-editor/adapters/forge | createForgeAdapter({ content, state, schema, authenticate, … }) | the host's Markdown files; state, assets and settings go to a state adapter | nothing |
@skaile/forge-editor/adapters/block-index | createMemoryBlockIndex() | the Block index (ports.index) in the process; rebuilt from the documents on start | nothing |
@skaile/forge-editor/adapters/block-index/drizzle | createDrizzleBlockIndex({ db, tables: blockIndexTables() }), bootstrapBlockIndexTables(db) | the Block index in Postgres or PGlite (editor_block_index, one tsvector per title and text, and editor_block_edges, one row per Edge; search runs in the database, scope as a SQL filter); same answers as the memory adapter (test/adapters/block-index-contract.test.ts) | drizzle-orm, a Postgres driver (@electric-sql/pglite, pg, …) |
@skaile/forge-editor/anchors | captureAnchor, capturePoint, resolveAnchor, mapAnchor | pure functions over ProseMirror documents | @tiptap/pm |
@skaile/forge-editor/comments/json | createJsonCommentStore({ file }) | one JSON file (forge's concept-discussions.json layout) | nothing |
@skaile/forge-editor/comments/drizzle | createDrizzleCommentStore({ db, tables: commentTables() }) | SQLite via drizzle | drizzle-orm |
@skaile/forge-editor/comments/server | handleCommentsRequest(store, { method, documentId, body }) | the REST handler a host routes its comments endpoint to | nothing |
@skaile/forge-editor/server/nitro | installEditorServer(nitroApp, { ports, collab, ai?, rateLimit?, schema? }) | mounts the editor's routes, the collaboration websocket and optional AI and comments routes on a Nitro app (comments: ports.comments) | h3 |
@skaile/forge-editor/adapters/vue | useEditorDocument(id, { backend?, user?, canComment?, collaboration? }) | the document a Vue editor surface binds to, through EditorBackend (documents, collaboration, comments): modelValue, dirty, save(), collaboration, comments | vue, yjs, @hocuspocus/provider |
@skaile/forge-editor/adapters/react | useEditorDocument(id, { backend?, user?, canComment?, collaboration? }) | the same for a React surface: modelValue, onChange, ready, dirty, save, reload, error, collaboration, comments, bind | react, yjs, @hocuspocus/provider |
@skaile/forge-editor/adapters/http | createHttpBackend({ fetch?, base?, probe? }) | the client port EditorBackend (createEditorContext({ backend })) over the editor's /api/editor and /api/ai routes, the collaboration websocket and the comments endpoint; the default backend of every editor context. probe: true asks GET /api/editor/capabilities once and drops the capabilities the server does not serve (see below) | @hocuspocus/provider, yjs, ofetch |
@skaile/forge-editor/adapters/direct-ai | createDirectAi({ presets, roles?, users?, fetch? }) | the ai capability of EditorBackend without a server: models, complete, generate and transcriptCleanup call OpenAI-compatible chat-completion endpoints straight from the page (see below); chat and extract answer 501 | nothing |
@skaile/forge-editor/adapters | types only |
Peers: drizzle-orm (^0.45.0, optional) is needed by ./adapters/drizzle, ./comments/drizzle and ./adapters/block-index/drizzle only; h3 (optional) by ./server/nitro; vue (optional), yjs, @hocuspocus/provider by ./adapters/vue. bun run check:editor-entries builds the adapter entries through its fixtures (adapters-node: ./adapters, ./adapters/json, ./adapters/forge, ./anchors, ./comments/server, ./comments/json; adapters-drizzle: the three drizzle entries; adapters-nitro: ./server/nitro; adapters-vue: ./adapters/vue; adapters-http: ./adapters/http, which holds the REST comments model; adapters-direct-ai: ./adapters/direct-ai, which reaches no AI SDK, server or HTTP client) and proves on the bundles that Vue is only reachable from ./adapters/vue, Nitro only from ./server/nitro, drizzle only from the two drizzle entries, and that no adapter entry reaches React (except ./adapters/react). Entries named ./server/nest and ./adapters/prisma are reserved for a later platform adoption and are not exported; the check fails if one of them appears in exports before it is designed.
The tier rule
Three tiers, one direction of dependency:
- The editor (
@skaile/forge-editor, everything exceptadapters/) and theEditorPortscontract (@skaile/forge-editor/server). It knows no storage and no host. - Adapters (
src/adapters/, the entries above): implementations of that contract and the wiring around it. They depend on the editor; they import no application code, and no entry imports another UI or server framework than the one it is named for. - Hosts: an app (Nuxt, a bare Nitro server, Vite, a Fastify or tRPC server) that supplies identity (
authenticate), a store and its own chrome. A host calls these entries; it never reaches intodist/internals or table names.
Adapter matrix
json | drizzle | forge | |
|---|---|---|---|
| Documents | <id>.json | editor_documents | the host's Markdown files |
| Collaboration state | <id>.ystate | editor_states | the state adapter you pass in |
| Settings, assets | files under settings/, assets/ | editor_settings, editor_assets | the state adapter |
Creates documents (PUT /api/editor/documents/:id) | yes | yes | no (the host owns its files) |
| Default identity | anonymous dev user outside production | same | none: the host supplies authenticate |
| Comment store | comments/json | comments/drizzle | comments/json or comments/drizzle |
| Nodes | one process | SQLite writers of the driver | one process |
All three pass the adapter contract test; both comment stores pass test/adapters/comment-store.test.ts. The JSON and forge adapters and the JSON comment store are single-node: no cross-process locking.
Browser-direct AI
By default the ai capability is a relay: the browser calls /api/ai/*, and the server holds the model key, applies the per-user rate limit (config.ai.rateLimit, enforced in the server handlers only) and calls the model. A host without a server, or one that points the editor at a local model, drops the relay: createDirectAi is an EditorBackend['ai'] that runs the same protocol modules as the handlers (runGenerate, runCompletion, runTranscriptCleanup, so prompts, limits and results are identical) and calls <endpoint>/chat/completions itself, over a plain fetch with a hand-written SSE reader (no AI SDK in the bundle). It goes beside the memory backend:
import { createDirectAi } from '@skaile/forge-editor/adapters/direct-ai'
import { createMemoryBackend } from '@skaile/forge-editor/adapters/memory'
const backend = {
...createMemoryBackend({ users }),
ai: createDirectAi({
presets: { local: { label: 'Lokales Modell', endpoint: 'http://localhost:11434/v1', model: 'llama3.1' } },
roles: { complete: 'local' }, // Model roles without an entry use the first preset
users: () => ['anna', 'ben'], // offered to tasks that write `@` mentions
}),
}
app.use(createEditor({ backend }))
endpoint is the OpenAI-compatible base URL, as for the server's ModelPresetConfig.endpoint; models() lists the Model presets without their keys, and a request may also carry its own { endpoint, model, apiKey } (the Model profiles users add in the settings). A refused model request (HTTP error, unreachable endpoint) rejects with a BackendError carrying the upstream status before anything streams, like the server's non-2xx answers; a failure inside the stream is an error event.
Limits to know before choosing it:
- The key is in the page. A
presets[].apiKeyis shipped to every visitor and readable in the network panel. Use it for local models, endpoints without a key, or keys the user brings; keep a key that must stay secret behind the server routes. - No rate limit. The per-user limit lives in the server handlers, so nothing throttles direct calls except the model provider itself.
chatandextractneed a server. The document chat (tool calls) andextract(transcription, OCR, Crossref) answerBackendError501. A host that needs them takesaifrom the HTTP backend instead (createHttpBackendof@skaile/forge-editor/adapters/http):{ ...createMemoryBackend(), ai: createHttpBackend().ai }, which sends every AI request to/api/ai.- CORS. The endpoint must allow the page's origin.
Which capabilities work in the page and which need a server is summarised in the locality table.
Your own store
A host that keeps comments in its own database implements CommentStore (@skaile/forge-editor/comments/server) and does not have to rewrite the rules. addComment, createThread, editComment, removeComment, setThreadStatus, updateAnchor and removeThread are the pure operations the JSON store is built from, and emptyDiscussion() is a document without discussion. Each takes the document's Discussion ({ annotations, threads }), changes it in place and throws a CommentStoreError (unknown or duplicate ids, inconsistent input) before it changes anything. A store loads the record, applies the operation and writes the record back:
import { addComment, createThread, emptyDiscussion, type CommentStore, type Discussion } from '@skaile/forge-editor/comments/server'
// `db`: the host's own storage, one `Discussion` per document id
const load = async (documentId: string): Promise<Discussion> => (await db.get(documentId)) ?? emptyDiscussion()
export const store: Pick<CommentStore, 'getDiscussion' | 'createThread' | 'addComment'> = {
getDiscussion: load,
async createThread(documentId, input) {
const record = await load(documentId)
const created = createThread(record, input)
await db.put(documentId, record)
return created
},
async addComment(documentId, threadId, comment) {
const record = await load(documentId)
const thread = addComment(record, threadId, comment)
await db.put(documentId, record)
return thread
},
}
The other CommentStore methods follow the same three steps; getThread, listThreads and removeDocuments need no operation. Serialise the read-modify-write per document (a revision column, a transaction) when several writers share the database.
Nitro host and composable
// server/plugins/editor.ts (bare Nitro, no Nuxt)
import { createJsonAdapter } from '@skaile/forge-editor/adapters/json'
import { createJsonCommentStore } from '@skaile/forge-editor/comments/json'
import { installEditorServer } from '@skaile/forge-editor/server/nitro'
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
installEditorServer(nitroApp, {
ports: {
...createJsonAdapter({ dir: '.data/documents' }),
comments: createJsonCommentStore({ file: '.data/comments.json' }), // optional: mounts /api/editor/comments/:id
},
collab: {}, // `false`: no websocket, documents still load and save over HTTP; `{ authenticateUpgrade: true }`: 401 at the upgrade
// ...serverConfig(defineEditorConfig({ ai: { apiKey: process.env.AI_KEY } })), // optional: mounts /api/ai/* with the defaults of the AI configuration guide, replaces `collab`/`ai`/`rateLimit`
})
})
installEditorServer is the one installer of both hosts: the Nuxt module calls it from its server plugin, a host without Nuxt calls it from its own; set experimental: { websocket: true } in the Nitro config for the collaboration websocket. It mounts serverRoutesOf(ports, { ai }) (src/server/routes.ts), all behind ports.identity.authenticate: the editor's client endpoints (/api/editor/{settings,users,tags,assets/…,themes/…}, each served only when its port is present), GET/PUT /api/editor/documents/:id (a document's TipTap JSON; PUT needs a documents port that also manages documents: putDocument), GET/POST /api/editor/comments/:id (handleCommentsRequest over ports.comments, mounted only when that port is present), /api/ai/* with ai, and /collab/:id (the Hocuspocus websocket) unless collab: false. It returns the EditorServer handle: forgetDocument(id), liveContent(id), enforceRateLimit(request) and resolveModel(ref, user) act on the collaboration server, AI models and rate limiter the routes use (a Nuxt host reaches it with useEditorServer()). The returned object (ports, collab, models, close()) is for tests and shutdown; Nitro's close hook already calls it.
<script setup lang="ts">
import { useEditorDocument } from '@skaile/forge-editor/adapters/vue'
import { commentsFeature } from '@skaile/forge-editor/comments/vue'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { EditorSurface } from '@skaile/forge-editor/vue'
import { ref } from 'vue'
const id = ref('welcome')
const doc = useEditorDocument(id, { user: { id: 'u1', name: 'Anna', color: '#e11d48' } }) // backend: the one `createEditor({ backend: createHttpBackend() })` installed
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), commentsFeature({ model: () => doc.comments.value! })] })
</script>
<template>
<button :disabled="!doc.dirty.value" @click="doc.save()">Save</button>
<EditorSurface v-if="doc.ready.value" :key="id" :preset="preset" v-bind="doc.bind.value" />
</template>
useEditorDocument(id, options) loads and saves through backend.documents (the HTTP backend: GET/PUT /api/editor/documents/:id; a missing document, null, starts empty and is created by the first save()) and returns modelValue, ready, dirty, save(), reload(), error, collaboration, comments (made by backend.comments: the REST comments model of /api/editor/comments/:id over HTTP), editor and bind (the surface's props and events). The backend is options.backend, else the one of the editor context (createEditor({ backend }), see the /adapters/http row above); the default context backend has neither collaboration nor comments, so a host that needs them installs createHttpBackend(). dirty ignores the block ids the editor assigns when it mounts content; they are saved with the next edit. With collaboration: true (or { token? }, the token the collaboration server's AccessControl.authenticate receives) the Y.Doc is the document: backend.collaboration.connect(id, token) opens it, the server loads and stores the content, nothing is fetched or saved by the composable, and modelValue stays undefined; a backend without the collaboration capability makes the call throw @skaile/forge-editor: useEditorDocument needs backend capability "collaboration"…. Mount the surface with :key="id" and v-if="doc.ready.value". The React twin, useEditorDocument(id, options) from @skaile/forge-editor/adapters/react, takes the same options and returns plain values (modelValue, onChange, ready, dirty, save, reload, error, collaboration, comments, editor, bind) and reads its backend from EditorContextProvider; bind spreads onto the surface as value, onChange, onReady and collaboration, and a null or undefined id keeps it idle (no document open yet).
Every adapter returns an EditorPorts bundle with only the ports it serves (json and drizzle: identity, documents with putDocument/deleteDocument/listDocumentIds, collabState, directory, assets, settings; forge: identity, documents and, when its state has them, collabState, assets, settings, themes). All take the same hooks: authenticate({ headers, token }) (the user behind a request or collaboration socket, null refuses; default: an anonymous development user outside NODE_ENV=production, nobody in production; the forge adapter has no default), authorize(user, documentId) (per-document access for collaboration, { readOnly } | null; omitted = every authenticated user edits every document) and users() (who can be @-mentioned). Host nodes for the collaboration schema are the server's schema option, not an adapter hook. Comment stores are the comments port: { ...createJsonAdapter({ dir }), comments: createJsonCommentStore({ file }) }.
The runnable app is examples/vue-nitro-json: bun run proto:vue-nitro-json serves it on port 3046, documents land in examples/vue-nitro-json/.data/documents/<id>.json, comments in examples/vue-nitro-json/.data/comments.json.