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
9 changes: 8 additions & 1 deletion packages/collab-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"access": "public",
"exports": "./dist/index.js"
},
"scripts": {
"gen:ref": "typedoc"
},
"dependencies": {
"@stepwisehq/prosemirror-collab-commit": "^1.0.0"
},
Expand All @@ -15,7 +18,11 @@
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"prosemirror-model": "^1.25.3",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.4"
"prosemirror-transform": "^1.10.4",
"typedoc": "^0.28.19",
"typedoc-plugin-frontmatter": "^1.3.1",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^6.0.3"
},
"peerDependencies": {
"prosemirror-model": "^1.0.0",
Expand Down
67 changes: 66 additions & 1 deletion packages/collab-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,48 @@ export interface CommitsListener {
}

export interface CollabClientConfig {
/**
* Sends local commits to a remote server to be merged into the remote document state.
* The endpoint this function hits is defined by you, and should call the
* CollabAuthority's {@link https://pitter-patter.dev/docs/collab/reference/collab-server/classes/CollabAuthority#receivecommit | receiveCommit}
* function.
*
* @param commit - the latest prosemirror commit made by the local user
*/
sendCommit: (commit: Commit) => Promise<void>;
/**
* A listener for remote commits.
*
* Currently the only built-in option is the {@link LongPollListener}.
*/
listener: CommitsListener;
// Todo: The example in this doc rely's on some react context, how to show it otherwise
// It feels useful to show that you use receiveCommitTransaction to merge the editor
// state, but that might just be because I wouldn't know how to do it otherwise.
// See the doc in the Presence client config's receiveIndicators for an alternative approach.
Comment on lines +38 to +41

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this TODO now yeah?

/**
* Receives an array of commits and merges them into your local editor state.
*
* @example
* ```
* import receiveCommitTransaction from "@stepwisehq/prosemirror-collab-commit/collab-commit";
*
* receiveCommits: (commits) => {
* view.dispatch(
* view.state.apply(
* commits.reduce((acc, commit) => acc.apply(receiveCommitTransaction(acc, commit)), prev)
* )
* )
* },
* ```
*/
receiveCommits: (commits: Commit[]) => void;
}

/**
* The client that manages sending local editor state changes to the remote server and merging
* remote changes into local editor state.
*/
export class CollabClient {
private sending: null | string = null;

Expand All @@ -38,6 +75,9 @@ export class CollabClient {
this.listener = config.listener;
}

/**
* Send local editor state changes to the remote server.
*/
async send(editorState: EditorState) {
const commit = sendableCommit(editorState);
if (!commit) return;
Expand All @@ -57,11 +97,18 @@ export class CollabClient {
}
}

/**
* Updates the desired portion of the client's `CollabClientConfig`. For example, this can
* be used to update the auth headers used by `sendCommit`.
*/
update(config: Partial<Omit<CollabClientConfig, "listener">>) {
if (config.sendCommit) this.sendCommit = config.sendCommit;
if (config.receiveCommits) this.receiveCommits = config.receiveCommits;
}

/**
* Start listening for remote commits. This function should only be called once.
*/
async listen(editorState: EditorState, signal?: AbortSignal) {
for await (const newCommits of this.listener.listen(editorState, {
signal,
Expand All @@ -73,15 +120,30 @@ export class CollabClient {
}

export interface LongPollListenerOptions {
timeout?: number;
// Todo: the timeout option is not currently used in the LongPollListner. Add support for it.
// timeout?: number;
/**
* Any headers that need to be included in requests to your long polling endpoint. Defaults to an empty object.
*/
headers?: HeadersInit;
/**
* The fetch method to use when making requests. Defaults to the global fetch method.
*/
fetch?: typeof globalThis.fetch;
}

/**
* A CommitsListener that polls an endpoint for remote updates to a document. Intended to be used
* with an remote long polling endpoint that calls a Collab Authority's {@link https://pitter-patter.dev/docs/collab/reference/collab-server/classes/CollabAuthority#listenforcommit | listenForCommit}
* function to efficiently listen for updates.
*/
export class LongPollListener {
private headers: HeadersInit;
private fetch: typeof globalThis.fetch;

/**
* @param url - the url that polling requests will be sent to
*/
constructor(
private url: URL,
options: LongPollListenerOptions = {},
Expand All @@ -90,6 +152,9 @@ export class LongPollListener {
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
}

/**
* Update the headers sent with long polling requests.
*/
update(headers: HeadersInit) {
this.headers = headers;
}
Expand Down
24 changes: 24 additions & 0 deletions packages/collab-client/typedoc-plugin-frontmatter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MarkdownPageEvent } from "typedoc-plugin-markdown";

/**
* @param {import('typedoc-plugin-markdown').MarkdownApplication} app
*/
export function load(app) {
app.renderer.on(
MarkdownPageEvent.BEGIN,
/** @param {import('typedoc-plugin-markdown').MarkdownPageEvent} page */
(page) => {
page.frontmatter = {
title: page.model?.name,
};
},
);

app.renderer.on(
MarkdownPageEvent.END,
/** @param {import('typedoc-plugin-markdown').MarkdownPageEvent} page */
(page) => {
page.contents = page.contents.replace(/(\[.*?\]\(.*?)\.md(\))/g, "$1$2");
},
);
}
20 changes: 20 additions & 0 deletions packages/collab-client/typedoc.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** @type {import('typedoc').TypeDocOptions & import('typedoc-plugin-markdown').PluginOptions} */
const config = {
entryPoints: ["./src/index.ts"],
plugin: [
"typedoc-plugin-markdown",
"typedoc-plugin-frontmatter",
"./typedoc-plugin-frontmatter.mjs",
],
out: "../docs/content/docs/collab/reference/collab-client",
readme: "none",
cleanOutputDir: true,
hideBreadcrumbs: true,
hidePageHeader: true,
useCodeBlocks: true,
expandObjects: true,
expandParameters: true,
publicPath: "/docs/collab/reference/collab-client",
};

export default config;
9 changes: 8 additions & 1 deletion packages/collab-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"access": "public",
"exports": "./dist/index.js"
},
"scripts": {
"gen:ref": "typedoc"
},
"dependencies": {
"@stepwisehq/prosemirror-collab-commit": "^1.0.3",
"redis": "^5.8.2"
Expand All @@ -16,7 +19,11 @@
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"prosemirror-model": "^1.25.3",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.4"
"prosemirror-transform": "^1.10.4",
"typedoc": "^0.28.19",
"typedoc-plugin-frontmatter": "^1.3.1",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^6.0.3"
},
"peerDependencies": {
"prosemirror-model": "^1.0.0",
Expand Down
62 changes: 61 additions & 1 deletion packages/collab-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ export interface CommitListener {
abort: () => Promise<void>;
}

/**
* The config for creating a CollabAuthority. Parameters that perform database operations should use the provided transaction
* or if a transaction is not provided, start a transaction and perform all database operations inside it.
*/
export interface CollabAuthorityConfig<Transaction> {
schema: Schema;
/**
* This function should start a transaction on your database, execute the provided callback with it, and commit the transaction.
*/
runWithTransaction: <Result>(callback: (tr: Transaction) => Promise<Result>) => Promise<Result>;
/**
* Retrieves a document from your database by docId.
*/
getDoc: (
tr: Transaction | null,
docId: string,
Expand All @@ -36,19 +46,32 @@ export interface CollabAuthorityConfig<Transaction> {
version: number;
lastUpdatedTimestamp: number;
}>;
/**
* Given a docId and commitRef, retrieves the associated commit's steps and version from your database
* and returns a joined CommitJSON object.
*/
getCommit: (
tr: Transaction | null,
docId: string,
commitRef: string,
) => Promise<CommitJSON | null>;
/**
* For the provided docId, retrieves all commits from the database with a version number strictly greater than the provided `version`.
*/
getCommits: (tr: Transaction | null, docId: string, version: number) => Promise<CommitJSON[]>;
/**
* Saves a document along with its docId, version, and lastUpdatedTimestamp to your database.
*/
saveDoc: (
tr: Transaction | null,
docId: string,
docJSON: NodeJSON,
version: number,
lastUpdatedTimestamp: number,
) => Promise<void>;
/**
* Saves a commit along with its version and ref to your database.
*/
saveCommit: (
tr: Transaction | null,
docId: string,
Expand All @@ -58,12 +81,26 @@ export interface CollabAuthorityConfig<Transaction> {
[key: string]: unknown;
}[],
) => Promise<void>;
/**
* The broadcast manager that will be used to listen for and send document updates.
*
* Currently the only built-in option is the {@link RedisBroadcastManager}.
*/
broadcastManager: {
broadcastCommit: (docId: string, commit: CommitJSON) => Promise<void>;
createCommitListener: (docId: string, version: number) => Promise<CommitListener>;
};
}

/**
* The CollabAuthority manages most of Pitter Patter's server side collaborative editing operations.
*
* You create endpoints that call the appropriate CollabAuthority functions to integrate with
* a CollabClient.
*
* A CollabAuthority is designed to be stateless, so you can create a new one on every server,
* lambda, or cloud function instance.
*/
export class CollabAuthority<Transaction> {
private schema: CollabAuthorityConfig<Transaction>["schema"];
private runWithTransaction: CollabAuthorityConfig<Transaction>["runWithTransaction"];
Expand Down Expand Up @@ -99,6 +136,10 @@ export class CollabAuthority<Transaction> {
throw new TooMuchContentionError();
}

/**
* Receives a commit from a CollabClient and merges it into the remote
* editor state.
*/
async receiveCommit(docId: string, commitJSON: CommitJSON) {
const appliedCommitJSON = await this.runWithTransactionRetries(async (tr) => {
// If we've already received this commit, skip it
Expand Down Expand Up @@ -139,6 +180,10 @@ export class CollabAuthority<Transaction> {
await this.broadcastManager.broadcastCommit(docId, appliedCommitJSON);
}

/**
* Listens for remote changes to a document's editor state and returns when changes
* are found or after a timeout specified in the CollabAuthority's `broadcastManager`.
*/
async listenForCommit(docId: string, version: number) {
// Create listner to notify if commits are made. After this await, the listener is registered with
// the notification service and will be notified if a commit is made.
Expand All @@ -162,11 +207,26 @@ export class CollabAuthority<Transaction> {
}
}

interface RedisBroadcastManagerConfig {
export interface RedisBroadcastManagerConfig {
/**
* the url for your Redis cluster
*/
redisUrl: string;
/**
* the maximum time the broadcast manager should listen for changes
* to a document before returning an empty result
*/
timeout?: number;
}

/**
* A broadcast manager that uses a Redis cluster as a message broker via Redis's pub/sub.
*
* When a client connects it specifies the document id to listen to.
*
* When changes are submitted to a document all listeners for that document id are notified
* that there is an update.
*/
export class RedisBroadcastManager {
private pub: RedisClientType;
private sub: RedisClientType;
Expand Down
24 changes: 24 additions & 0 deletions packages/collab-server/typedoc-plugin-frontmatter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MarkdownPageEvent } from "typedoc-plugin-markdown";

/**
* @param {import('typedoc-plugin-markdown').MarkdownApplication} app
*/
export function load(app) {
app.renderer.on(
MarkdownPageEvent.BEGIN,
/** @param {import('typedoc-plugin-markdown').MarkdownPageEvent} page */
(page) => {
page.frontmatter = {
title: page.model?.name,
};
},
);

app.renderer.on(
MarkdownPageEvent.END,
/** @param {import('typedoc-plugin-markdown').MarkdownPageEvent} page */
(page) => {
page.contents = page.contents.replace(/(\[.*?\]\(.*?)\.md(\))/g, "$1$2");
},
);
}
20 changes: 20 additions & 0 deletions packages/collab-server/typedoc.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** @type {import('typedoc').TypeDocOptions & import('typedoc-plugin-markdown').PluginOptions} */
const config = {
entryPoints: ["./src/index.ts"],
plugin: [
"typedoc-plugin-markdown",
"typedoc-plugin-frontmatter",
"./typedoc-plugin-frontmatter.mjs",
],
out: "../docs/content/docs/collab/reference/collab-server",
readme: "none",
cleanOutputDir: true,
hideBreadcrumbs: true,
hidePageHeader: true,
useCodeBlocks: true,
expandObjects: true,
expandParameters: true,
publicPath: "/docs/collab/reference/collab-server",
};

export default config;
Loading