Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions docs/content/docs/features/server-processing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,44 @@ path: /docs/server-side-processing

While you can use the `BlockNoteEditor` on the client side, you can also use `ServerBlockNoteEditor` from `@blocknote/server-util` to process BlockNote documents on the server.

For example, use the following code to convert a BlockNote document to HTML on the server:
Install `@blocknote/server-util`:

```bash
pnpm add @blocknote/server-util
```

For HTML or Markdown processing, pass a document to the editor. The included JSDOM factory provides a ready-to-use implementation:

```tsx
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import { getDocument } from "@blocknote/server-util/dom";

const editor = ServerBlockNoteEditor.create();
const html = await editor.blocksToFullHTML(blocks);
const document = getDocument();
const editor = ServerBlockNoteEditor.create({}, document);
const html = await editor.blocksToFullHTML([
{ type: "paragraph", content: "Hello" },
]);
```

`ServerBlockNoteEditor.create` takes the same BlockNoteEditorOptions as `useCreateBlockNote` and `BlockNoteEditor.create` ([see docs](/docs/getting-started)),
so you can pass the same configuration (for example, your custom schema) to your server-side BlockNote editor as on the client.

## DOM lifetime and isolation

`ServerBlockNoteEditor.create(options, document)` accepts a document from a compatible DOM implementation. Share one document across all editors in a rendering environment. If no document is passed, the editor falls back to the global `window` and `document`. Importing the factory or creating a document does not install globals.

React and custom block renderers need global DOM access. On first DOM use, the editor binds the supplied document and its `defaultView` persistently if neither global exists, reuses a matching pair, and rejects partial or conflicting environments. It never swaps or disposes them. Use a document attached to a window, not a detached document whose `defaultView` is `null`.

Keep the DOM and these globals alive for the lifetime of the rendering environment. Do not restore previous globals or close the DOM after an export: React can still read the DOM from scheduled work after an export resolves and its temporary root is unmounted. The export promise means the output is ready, not that asynchronous work started by custom components has finished.

Nested exports and exports from different editors share this stable DOM without swapping globals. JSON, ProseMirror, and Yjs conversions do not require DOM setup.

Installing DOM globals affects browser-environment detection in other libraries. If your application cannot accept this, initialize the DOM, editor, schema, and providers inside a dedicated worker or process, and send document data into it. Use the same DOM implementation for related APIs (for example, DOM events and their abort signals); mixing Node and DOM implementations can cause incompatibilities. Terminate the worker or process to dispose the rendering environment, rather than tearing down the DOM while React can still run.

### Custom DOM implementations

Pass your implementation's document directly as the second argument to `ServerBlockNoteEditor.create`. No adapter or acquisition/release hooks are needed. The main `@blocknote/server-util` entry point does not load JSDOM.

## Functions for converting blocks

`ServerBlockNoteEditor` exposes the same functions for converting blocks as the client side editor ([HTML](/docs/features/import/html), [Markdown](/docs/features/import/markdown)):
Expand Down Expand Up @@ -53,6 +79,8 @@ const html = await editor.withReactContext(
);
```

Serialize `withReactContext` calls on the same editor, because its context renderer is editor-local mutable state. Use separate editor instances for concurrent context-dependent exports. Custom React blocks continue to use the same callback-ref API as on the client.

## Next.js App Router

If you're using `@blocknote/server-util` in a Next.js App Router API route (Route Handler), you need to add the BlockNote packages to `serverExternalPackages` in your `next.config.ts`:
Expand Down
14 changes: 14 additions & 0 deletions packages/server-util/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# BlockNote server utilities

Process BlockNote documents on the server with `ServerBlockNoteEditor`.

HTML/Markdown operations and React context rendering require a
persistent DOM. Pass `getDocument()` from `@blocknote/server-util/dom`
as the second argument to `ServerBlockNoteEditor.create(options, document)`,
or provide a document from another compatible implementation. Share one
document across editors. If omitted, the editor uses the global DOM. Imports
do not install globals, and the main entry point does not load JSDOM. JSON,
ProseMirror, and Yjs conversions need no DOM setup.

See [server-side processing](https://www.blocknotejs.org/docs/features/server-processing)
for setup, React context, concurrency, and worker isolation.
5 changes: 5 additions & 0 deletions packages/server-util/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
"types": "./types/src/index.d.ts",
"import": "./dist/blocknote-server-util.js",
"default": "./dist/blocknote-server-util.js"
},
"./dom": {
"types": "./types/src/dom.d.ts",
"import": "./dist/blocknote-server-util-dom.js",
"default": "./dist/blocknote-server-util-dom.js"
}
},
"scripts": {
Expand Down
66 changes: 66 additions & 0 deletions packages/server-util/src/context/DOMEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { ServerBlockNoteEditor } from "./ServerBlockNoteEditor.js";

describe("application-owned DOM environment", () => {
it.each(["window", "document"] as const)(
"fails clearly when %s is missing without installing globals",
async (global) => {
const testWindow = globalThis.window;
const testDocument = globalThis.document;
vi.stubGlobal(global, undefined);
try {
const editor = ServerBlockNoteEditor.create();
await expect(editor.blocksToFullHTML([])).rejects.toThrow();
await expect(editor.blocksToHTMLLossy([])).rejects.toThrow();
await expect(editor.blocksToMarkdownLossy([])).rejects.toThrow();
await expect(editor.tryParseHTMLToBlocks("Hello")).rejects.toThrow();
await expect(
editor.tryParseMarkdownToBlocks("Hello"),
).rejects.toThrow();
await expect(
editor.withReactContext(
({ children }) => children,
async () => "unused",
),
).rejects.toThrow();
expect(globalThis[global]).toBeUndefined();
expect(
global === "window" ? globalThis.document : globalThis.window,
).toBe(global === "window" ? testDocument : testWindow);
} finally {
vi.unstubAllGlobals();
}
},
);

it("allows JSON and Yjs conversions without any DOM globals", () => {
vi.stubGlobal("window", undefined);
vi.stubGlobal("document", undefined);
try {
const editor = ServerBlockNoteEditor.create();
const blocks = [{ type: "paragraph", content: "No DOM" }] as const;
const node = editor._blocksToProsemirrorNode([...blocks]);
expect(editor._prosemirrorNodeToBlocks(node)[0].type).toBe("paragraph");
const doc = editor.blocksToYDoc([...blocks]);
expect(editor.yDocToBlocks(doc)[0].type).toBe("paragraph");
doc.destroy();
expect(globalThis.window).toBeUndefined();
expect(globalThis.document).toBeUndefined();
} finally {
vi.unstubAllGlobals();
}
});

it("rejects mismatched DOM globals without replacing either", async () => {
const unrelatedDocument = document.implementation.createHTMLDocument();
vi.stubGlobal("document", unrelatedDocument);
try {
await expect(
ServerBlockNoteEditor.create().blocksToHTMLLossy([]),
).rejects.toThrow();
expect(globalThis.document).toBe(unrelatedDocument);
} finally {
vi.unstubAllGlobals();
}
});
});
94 changes: 47 additions & 47 deletions packages/server-util/src/context/ServerBlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,36 @@ import {

import { BlockNoteViewRaw } from "@blocknote/react";
import { Node } from "@tiptap/pm/model";
import * as jsdom from "jsdom";
import * as React from "react";
import { createElement } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type * as Y from "yjs";

function getDOMDocument(document?: Document): Document {
document ??= globalThis.document;
const window = document?.defaultView;
if (!window) {
throw new Error(
"ServerBlockNoteEditor requires a document with a window (defaultView). Pass a document, such as getDocument() from @blocknote/server-util/dom, or define matching window and document globals.",
);
}

// React can access globals after an export returns. Bind once, never swap.
if (
typeof globalThis.window === "undefined" &&
typeof globalThis.document === "undefined"
) {
Object.assign(globalThis, { window, document });
}
if (globalThis.window !== window || globalThis.document !== document) {
throw new Error(
"ServerBlockNoteEditor found partial or conflicting DOM globals. Use one document consistently, or remove partial or conflicting globals before initializing the rendering environment.",
);
}
return document;
}

/**
* Use the ServerBlockNoteEditor to interact with BlockNote documents in a server (nodejs) environment.
*/
Expand All @@ -46,50 +69,30 @@ export class ServerBlockNoteEditor<
public readonly editor: BlockNoteEditor<BSchema, ISchema, SSchema>;

/**
* We currently use a JSDOM instance to mock document and window methods
*
* A possible improvement could be to make this:
* a) pluggable so other shims can be used as well
* b) obsolete, but for this all blocks should be React based and we need to remove all references to document / window
* from the core / react package. (and even then, it's likely some custom blocks would still use document / window methods)
*/
private jsdom = new jsdom.JSDOM();

/**
* Calls a function with mocking window and document using JSDOM
*
* We could make this obsolete by passing in a document / window object to the render / serialize methods of Blocks
* Calls a function with the supplied document, falling back to the global DOM.
* Nested and concurrent exports use the same globals without swapping them.
*/
public async _withJSDOM<T>(fn: () => Promise<T>) {
const prevWindow = globalThis.window;
const prevDocument = globalThis.document;
globalThis.document = this.jsdom.window.document;
(globalThis as any).window = this.jsdom.window;
(globalThis as any).window.__TEST_OPTIONS = (
prevWindow as any
)?.__TEST_OPTIONS;
try {
return await fn();
} finally {
globalThis.document = prevDocument;
globalThis.window = prevWindow;
}
public async _withDOM<T>(fn: (document: Document) => Promise<T>) {
return fn(getDOMDocument(this.document));
}

public static create<
BSchema extends BlockSchema = DefaultBlockSchema,
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
SSchema extends StyleSchema = DefaultStyleSchema,
>(options: Partial<BlockNoteEditorOptions<BSchema, ISchema, SSchema>> = {}) {
return new ServerBlockNoteEditor(options) as ServerBlockNoteEditor<
BSchema,
ISchema,
SSchema
>;
>(
options: Partial<BlockNoteEditorOptions<BSchema, ISchema, SSchema>> = {},
document?: Document,
) {
return new ServerBlockNoteEditor(
options,
document,
) as ServerBlockNoteEditor<BSchema, ISchema, SSchema>;
}

protected constructor(
options: Partial<BlockNoteEditorOptions<any, any, any>>,
private readonly document?: Document,
) {
this.editor = BlockNoteEditor.create(options) as any;
}
Expand Down Expand Up @@ -186,14 +189,14 @@ export class ServerBlockNoteEditor<
public async blocksToHTMLLossy(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
): Promise<string> {
return this._withJSDOM(async () => {
return this._withDOM(async (document) => {
const exporter = createExternalHTMLExporter(
this.editor.pmSchema,
this.editor,
);

return exporter.exportBlocks(blocks, {
document: this.jsdom.window.document,
document,
});
});
}
Expand All @@ -210,14 +213,14 @@ export class ServerBlockNoteEditor<
public async blocksToFullHTML(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
): Promise<string> {
return this._withJSDOM(async () => {
return this._withDOM(async (document) => {
const exporter = createInternalHTMLSerializer(
this.editor.pmSchema,
this.editor,
);

return exporter.serializeBlocks(blocks, {
document: this.jsdom.window.document,
document,
});
});
}
Expand All @@ -232,7 +235,7 @@ export class ServerBlockNoteEditor<
public async tryParseHTMLToBlocks(
html: string,
): Promise<Block<BSchema, ISchema, SSchema>[]> {
return this._withJSDOM(async () => {
return this._withDOM(async () => {
return this.editor.tryParseHTMLToBlocks(html);
});
}
Expand All @@ -248,9 +251,9 @@ export class ServerBlockNoteEditor<
public async blocksToMarkdownLossy(
blocks: PartialBlock<BSchema, ISchema, SSchema>[],
): Promise<string> {
return this._withJSDOM(async () => {
return this._withDOM(async (document) => {
return blocksToMarkdown(blocks, this.editor.pmSchema, this.editor, {
document: this.jsdom.window.document,
document,
});
});
}
Expand All @@ -265,7 +268,7 @@ export class ServerBlockNoteEditor<
public async tryParseMarkdownToBlocks(
markdown: string,
): Promise<Block<BSchema, ISchema, SSchema>[]> {
return this._withJSDOM(async () => {
return this._withDOM(async () => {
return this.editor.tryParseMarkdownToBlocks(markdown);
});
}
Expand All @@ -285,10 +288,8 @@ export class ServerBlockNoteEditor<
);
*/
public async withReactContext<T>(comp: React.FC<any>, fn: () => Promise<T>) {
return this._withJSDOM(async () => {
const tmpRoot = createRoot(
this.jsdom.window.document.createElement("div"),
);
return this._withDOM(async (document) => {
const tmpRoot = createRoot(document.createElement("div"));

flushSync(() => {
tmpRoot.render(
Expand All @@ -314,7 +315,6 @@ export class ServerBlockNoteEditor<
return await fn();
} finally {
tmpRoot.unmount();
await new Promise((resolve) => setTimeout(resolve, 3));
}
});
}
Expand Down
Loading
Loading