Add API for propagating cursor locations (#61)

This commit is contained in:
Andras Schmelczer 2025-06-08 20:20:52 +01:00 committed by GitHub
parent f97193e287
commit e8b9bf40c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 1930 additions and 2229 deletions

View file

@ -1,39 +1,39 @@
{
"name": "vault-link-obsidian-plugin",
"version": "0.3.15",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "jest",
"version": "node version-bump.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.27",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.3.0",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.8.7",
"resolve-url-loader": "^5.0.0",
"sass": "^1.89.0",
"sass-loader": "^16.0.5",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14",
"ts-jest": "^29.3.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
}
}
"name": "vault-link-obsidian-plugin",
"version": "0.3.15",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "jest",
"version": "node version-bump.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.30",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.3.0",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.8.7",
"resolve-url-loader": "^5.0.0",
"sass": "^1.89.1",
"sass-loader": "^16.0.5",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14",
"ts-jest": "^29.3.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -7,6 +7,7 @@ import type {
} from "sync-client";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor";
export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(
@ -78,26 +79,19 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
if (view?.file?.path === path) {
const text = view.editor.getValue();
const cursors = view.editor
.listSelections()
.flatMap(({ anchor, head }, i) => [
const cursors = getCursorsFromEditor(view.editor).flatMap(
({ id, start: anchor, end: head }) => [
{
id: 2 * i,
characterPosition: lineAndColumnToPosition(
text,
anchor.line,
anchor.ch
)
id: 2 * id,
characterPosition: anchor
},
{
id: 2 * i + 1,
characterPosition: lineAndColumnToPosition(
text,
head.line,
head.ch
)
id: 2 * id + 1,
characterPosition: head
}
]);
]
);
const result = updater({
text,

View file

@ -0,0 +1,9 @@
export function getRandomColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash << 5) - hash + name.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
const normalised = hash / 0x7fffffff;
return `hsl(${Math.abs(normalised * 360)}, 55%, 55%)`; // HSL color
}

View file

@ -1,24 +1,36 @@
import type {
Editor,
EventRef,
MarkdownFileInfo,
MarkdownView,
TAbstractFile,
Workspace,
WorkspaceLeaf
} from "obsidian";
import type { MarkdownView } from "obsidian";
import { Platform, Plugin, TFile } from "obsidian";
import "../manifest.json";
import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar";
import { LogsView } from "./views/logs/logs-view";
import { StatusDescription } from "./views/status-description/status-description";
import type { CursorSpan, RelativePath } from "sync-client";
import { SyncClient, rateLimit, DEFAULT_SETTINGS } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab";
import { registerConsoleForLogging } from "./utils/register-console-for-logging";
import { updateEditorStatusDisplay } from "./views/editor-sync-line/editor-sync-line";
import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme";
import {
remoteCursorsPlugin,
setCursors
} from "./views/cursors/remote-cursors-plugin";
import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor";
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;
export default class VaultLinkPlugin extends Plugin {
private readonly disposables: (() => void)[] = [];
private readonly disposables: (() => unknown)[] = [];
private settingsTab: SyncSettingsTab | undefined;
private client!: SyncClient;
private readonly rateLimitedUpdatesPerFile = new Map<
@ -61,18 +73,36 @@ export default class VaultLinkPlugin extends Plugin {
this.registerView(
HistoryView.TYPE,
(leaf) => new HistoryView(leaf, this.client)
(leaf) => new HistoryView(this.client, leaf)
);
this.registerView(
LogsView.TYPE,
(leaf) => new LogsView(this.client, leaf)
);
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
this.client.addRemoteCursorsUpdateListener((cursors) => {
setCursors(cursors, this.app);
});
const cursorListener = new LocalCursorUpdateListener(
this.client,
this.app.workspace
);
this.disposables.push(() => {
cursorListener.dispose();
});
this.app.workspace.updateOptions();
this.addRibbonIcon(
HistoryView.ICON,
"Open VaultLink events",
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
);
this.addRibbonIcon(
LogsView.ICON,
"Open VaultLink logs",
@ -181,7 +211,7 @@ export default class VaultLinkPlugin extends Plugin {
this.client.syncLocallyUpdatedFile({
relativePath: path
}),
250
MIN_WAIT_BETWEEN_UPDATES_IN_MS
)
);
}

View file

@ -0,0 +1,17 @@
import type { Editor } from "obsidian";
import { lineAndColumnToPosition } from "../../utils/line-and-column-to-position";
export interface Cursor {
id: number;
start: number;
end: number;
}
export function getCursorsFromEditor(editor: Editor): Cursor[] {
const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({
id: i,
start: lineAndColumnToPosition(text, anchor.line, anchor.ch),
end: lineAndColumnToPosition(text, head.line, head.ch)
}));
}

View file

@ -0,0 +1,58 @@
import type { Workspace } from "obsidian";
import { EventRef, Editor, MarkdownView, MarkdownFileInfo } from "obsidian";
import type { Logger, SyncClient } from "sync-client";
import type { Cursor } from "./get-cursors-from-editor";
import { getCursorsFromEditor } from "./get-cursors-from-editor";
export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout;
private lastCursorState: Record<string, Cursor[]> = {};
public constructor(
private readonly client: SyncClient,
private readonly workspace: Workspace
) {
this.eventHandle = setInterval(() => {
this.updateAllCursors();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
}
public dispose(): void {
clearInterval(this.eventHandle);
}
private updateAllCursors(): void {
const currentCursors = this.getAllCursors();
if (
JSON.stringify(this.lastCursorState) ===
JSON.stringify(currentCursors)
) {
return;
}
this.lastCursorState = currentCursors;
this.client
.updateLocalCursors(currentCursors)
.catch((error: unknown) => {
this.client.logger.error(
`Failed to update local cursors: ${error}`
);
});
}
private getAllCursors(): Record<string, Cursor[]> {
const cursors: Record<string, Cursor[]> = {};
this.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
const { file } = view;
if (!file) {
return;
}
cursors[file.path] = getCursorsFromEditor(view.editor);
});
return cursors;
}
}

View file

@ -0,0 +1,63 @@
import { EditorView } from "@codemirror/view";
const CARET_WIDTH = 2;
const DOT_RADIUS = 4;
export const remoteCursorsTheme = EditorView.baseTheme({
".selection-caret": {
position: "relative"
},
".selection-caret > *": {
position: "absolute",
backgroundColor: "inherit"
},
".selection-caret > .stick": {
left: 0,
top: 0,
transform: "translateX(-50%)",
width: `${CARET_WIDTH}px`,
height: "100%",
display: "block",
borderRadius: `${CARET_WIDTH / 2}px`,
animation: "blink-stick 1s steps(1) infinite"
},
"@keyframes blink-stick": {
"0%, 100%": { opacity: 1 },
"50%": { opacity: 0 }
},
".selection-caret > .dot": {
borderRadius: "50%",
width: `${DOT_RADIUS * 2}px`,
height: `${DOT_RADIUS * 2}px`,
top: `-${DOT_RADIUS}px`,
left: `-${DOT_RADIUS}px`,
transition: "transform .3s ease-in-out",
transformOrigin: "bottom center",
boxSizing: "border-box"
},
".selection-caret:hover > .dot": {
transform: "scale(0)"
},
".selection-caret > .info": {
top: "-1.3em",
left: `-${CARET_WIDTH / 2}px`,
fontSize: "0.9em",
userSelect: "none",
color: "white",
padding: "0 2px",
transition: "opacity .3s ease-in-out",
opacity: 0,
whiteSpace: "nowrap",
borderRadius: "3px 3px 3px 0"
},
".selection-caret:hover > .info": {
opacity: 1
}
});

View file

@ -0,0 +1,46 @@
import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state";
import {
ViewUpdate,
ViewPlugin,
Decoration,
WidgetType
} from "@codemirror/view";
import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view";
export class RemoteCursorWidget extends WidgetType {
public constructor(
private readonly color: string,
private readonly name: string
) {
super();
}
public toDOM(editor: EditorView): HTMLElement {
return editor.contentDOM.createEl(
"span",
{
cls: "selection-caret",
attr: {
style: `background-color: ${this.color}; border-color: ${this.color}`
}
},
(span) => {
span.createEl("div", {
cls: "stick"
});
span.createEl("div", {
cls: "dot"
});
span.createEl("div", {
cls: "info",
text: this.name
});
}
);
}
public eq(other: RemoteCursorWidget): boolean {
return other.color === this.color && other.name === this.name;
}
}

View file

@ -0,0 +1,134 @@
import type { Range } from "@codemirror/state";
import { RangeSet, Annotation, AnnotationType } from "@codemirror/state";
import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view";
import type {
PluginValue,
DecorationSet,
EditorView,
ViewUpdate
} from "@codemirror/view";
import { RemoteCursorWidget } from "./remote-cursor-widget";
import type { ClientCursors, CursorSpan } from "sync-client";
import type { App } from "obsidian";
import { MarkdownView } from "obsidian";
let cursors: {
name: string;
path: string;
span: CursorSpan;
}[] = [];
import { StateEffect } from "@codemirror/state";
import { getRandomColor } from "src/utils/get-random-color";
const forceUpdate = StateEffect.define();
export class RemoteCursorsPluginValue implements PluginValue {
public decorations: DecorationSet = RangeSet.of([]);
public update(update: ViewUpdate): void {
const decorations: Range<Decoration>[] = [];
cursors.forEach(({ name, span: { start, end } }) => {
const color = getRandomColor(name);
const startLine = update.view.state.doc.lineAt(start);
const endLine = update.view.state.doc.lineAt(end);
const attributes = {
style: `background-color: ${color};`
};
if (startLine.number === endLine.number) {
// selected content in a single line.
decorations.push({
from: start,
to: end,
value: Decoration.mark({
attributes
})
});
} else {
// selected content in multiple lines
// first, render text-selection in the first line
decorations.push({
from: start,
to: startLine.from + startLine.length,
value: Decoration.mark({
attributes
})
});
// render text-selection in the lines between the first and last line
for (let i = startLine.number + 1; i < endLine.number; i++) {
const currentLine = update.view.state.doc.line(i);
decorations.push({
from: currentLine.from,
to: currentLine.to,
value: Decoration.mark({
attributes
})
});
}
// render text-selection in the last line
decorations.push({
from: endLine.from,
to: end,
value: Decoration.mark({
attributes
})
});
}
decorations.push({
from: end,
to: end,
value: Decoration.widget({
side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection
block: false,
widget: new RemoteCursorWidget(color, name)
})
});
});
this.decorations = Decoration.set(decorations, true);
}
}
export const remoteCursorsPlugin = ViewPlugin.fromClass(
RemoteCursorsPluginValue,
{
decorations: (v) => v.decorations
}
);
export function setCursors(clients: ClientCursors[], app: App): void {
cursors = clients.flatMap((client) => {
const clientCursors = client.cursors;
return Object.keys(clientCursors).flatMap((path) => {
const spans = clientCursors[path];
return spans
? spans.map((span) => ({
name: client.userName,
path,
span
}))
: [];
});
});
app.workspace
.getLeavesOfType("markdown")
.map((leaf) => leaf.view)
.filter((view) => view instanceof MarkdownView)
.forEach((view) => {
// @ts-expect-error, not typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const editor = view.editor.cm as EditorView;
editor.dispatch({
effects: [forceUpdate.of(null)]
});
});
}

View file

@ -18,8 +18,8 @@ export class HistoryView extends ItemView {
>();
public constructor(
leaf: WorkspaceLeaf,
private readonly client: SyncClient
private readonly client: SyncClient,
leaf: WorkspaceLeaf
) {
super(leaf);
this.icon = HistoryView.ICON;

View file

@ -11,7 +11,7 @@ export class StatusDescription {
private lastRemaining: number | undefined;
private lastConnectionState: NetworkConnectionStatus | undefined;
private statusChangeListeners: (() => void)[] = [];
private statusChangeListeners: (() => unknown)[] = [];
public constructor(private readonly syncClient: SyncClient) {
void this.updateConnectionState();

View file

@ -12,7 +12,16 @@ module.exports = (env, argv) => ({
ignored: "**/node_modules"
},
externals: {
obsidian: "commonjs obsidian"
obsidian: "commonjs obsidian",
electron: "commonjs electron",
"@codemirror/autocomplete": "commonjs @codemirror/autocomplete",
"@codemirror/collab": "commonjs @codemirror/collab",
"@codemirror/commands": "commonjs @codemirror/commands",
"@codemirror/language": "commonjs @codemirror/language",
"@codemirror/lint": "commonjs @codemirror/lint",
"@codemirror/search": "commonjs @codemirror/search",
"@codemirror/state": "commonjs @codemirror/state",
"@codemirror/view": "commonjs @codemirror/view"
},
optimization: {
minimizer: [

View file

@ -12,11 +12,11 @@
],
"devDependencies": {
"concurrently": "^9.1.2",
"eslint": "9.23.0",
"eslint": "9.28.0",
"eslint-plugin-unused-imports": "^4.1.4",
"npm-check-updates": "^17.1.16",
"npm-check-updates": "^18.0.1",
"prettier": "^3.5.3",
"typescript-eslint": "8.32.1"
"typescript-eslint": "8.33.1"
}
},
"../backend/sync_lib/pkg": {
@ -43,6 +43,7 @@
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.25.9",
@ -204,6 +205,7 @@
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -630,9 +632,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -655,9 +657,9 @@
}
},
"node_modules/@eslint/core": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
"integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -692,13 +694,16 @@
}
},
"node_modules/@eslint/js": {
"version": "9.23.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz",
"integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==",
"version": "9.28.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz",
"integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@ -712,13 +717,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz",
"integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.12.0",
"@eslint/core": "^0.14.0",
"levn": "^0.4.1"
},
"engines": {
@ -1620,76 +1625,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@redocly/config": {
"version": "0.22.1",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.1.tgz",
"integrity": "sha512-1CqQfiG456v9ZgYBG9xRQHnpXjt8WoSnDwdkX6gxktuK69v2037hTAR1eh0DGIqpZ1p4k82cGH8yTNwt7/pI9g==",
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.0",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.0.tgz",
"integrity": "sha512-Ji00EiLQRXq0pJIz5pAjGF9MfQvQVsQehc6uIis6sqat8tG/zh25Zi64w6HVGEDgJEzUeq/CuUlD0emu3Hdaqw==",
"license": "MIT",
"dependencies": {
"@redocly/ajv": "^8.11.2",
"@redocly/config": "^0.22.0",
"colorette": "^1.2.0",
"https-proxy-agent": "^7.0.5",
"js-levenshtein": "^1.1.6",
"js-yaml": "^4.1.0",
"minimatch": "^5.0.1",
"pluralize": "^8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -1857,9 +1792,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.27.tgz",
"integrity": "sha512-5fF+eu5mwihV2BeVtX5vijhdaZOfkQTATrePEaXTcKqI16LhJ7gi2/Vhd9OZM0UojcdmiOCVg5rrax+i1MdoQQ==",
"version": "22.15.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz",
"integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1901,17 +1836,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz",
"integrity": "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz",
"integrity": "sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.32.1",
"@typescript-eslint/type-utils": "8.32.1",
"@typescript-eslint/utils": "8.32.1",
"@typescript-eslint/visitor-keys": "8.32.1",
"@typescript-eslint/scope-manager": "8.33.1",
"@typescript-eslint/type-utils": "8.33.1",
"@typescript-eslint/utils": "8.33.1",
"@typescript-eslint/visitor-keys": "8.33.1",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@ -1925,7 +1860,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
"@typescript-eslint/parser": "^8.33.1",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
@ -1941,16 +1876,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.1.tgz",
"integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.1.tgz",
"integrity": "sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.32.1",
"@typescript-eslint/types": "8.32.1",
"@typescript-eslint/typescript-estree": "8.32.1",
"@typescript-eslint/visitor-keys": "8.32.1",
"@typescript-eslint/scope-manager": "8.33.1",
"@typescript-eslint/types": "8.33.1",
"@typescript-eslint/typescript-estree": "8.33.1",
"@typescript-eslint/visitor-keys": "8.33.1",
"debug": "^4.3.4"
},
"engines": {
@ -1965,15 +1900,37 @@
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz",
"integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==",
"node_modules/@typescript-eslint/project-service": {
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.1.tgz",
"integrity": "sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.32.1",
"@typescript-eslint/visitor-keys": "8.32.1"
"@typescript-eslint/tsconfig-utils": "^8.33.1",
"@typescript-eslint/types": "^8.33.1",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.1.tgz",
"integrity": "sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.33.1",
"@typescript-eslint/visitor-keys": "8.33.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1983,15 +1940,32 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.1.tgz",
"integrity": "sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.1.tgz",
"integrity": "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.33.1.tgz",
"integrity": "sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.32.1",
"@typescript-eslint/utils": "8.32.1",
"@typescript-eslint/typescript-estree": "8.33.1",
"@typescript-eslint/utils": "8.33.1",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@ -2008,9 +1982,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz",
"integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.1.tgz",
"integrity": "sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg==",
"dev": true,
"license": "MIT",
"engines": {
@ -2022,14 +1996,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz",
"integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.1.tgz",
"integrity": "sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.32.1",
"@typescript-eslint/visitor-keys": "8.32.1",
"@typescript-eslint/project-service": "8.33.1",
"@typescript-eslint/tsconfig-utils": "8.33.1",
"@typescript-eslint/types": "8.33.1",
"@typescript-eslint/visitor-keys": "8.33.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@ -2075,16 +2051,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz",
"integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.33.1.tgz",
"integrity": "sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.32.1",
"@typescript-eslint/types": "8.32.1",
"@typescript-eslint/typescript-estree": "8.32.1"
"@typescript-eslint/scope-manager": "8.33.1",
"@typescript-eslint/types": "8.33.1",
"@typescript-eslint/typescript-estree": "8.33.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2099,13 +2075,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz",
"integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.1.tgz",
"integrity": "sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.32.1",
"@typescript-eslint/types": "8.33.1",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
@ -2375,15 +2351,6 @@
"node": ">=8.9"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -2453,15 +2420,6 @@
"ajv": "^6.9.1"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -2522,6 +2480,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/async": {
@ -2882,12 +2841,6 @@
"node": ">=8"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"license": "MIT"
},
"node_modules/char-regex": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@ -3015,12 +2968,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@ -3169,6 +3116,7 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -3417,20 +3365,20 @@
}
},
"node_modules/eslint": {
"version": "9.23.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz",
"integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==",
"version": "9.28.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz",
"integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.2",
"@eslint/config-helpers": "^0.2.0",
"@eslint/core": "^0.12.0",
"@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.1",
"@eslint/core": "^0.14.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.23.0",
"@eslint/plugin-kit": "^0.2.7",
"@eslint/js": "9.28.0",
"@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@ -3671,6 +3619,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@ -4146,19 +4095,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -4246,18 +4182,6 @@
"node": ">=0.8.19"
}
},
"node_modules/index-to-position": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.0.0.tgz",
"integrity": "sha512-sCO7uaLVhRJ25vz1o8s9IFM3nVS4DkuQnyjMwiQPKvQuBYBDmb8H7zx8ki7nVh4HJQOdVWebyvLE0qt+clruxA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -5076,25 +5000,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@ -5515,6 +5432,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -5595,9 +5513,9 @@
}
},
"node_modules/npm-check-updates": {
"version": "17.1.16",
"resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-17.1.16.tgz",
"integrity": "sha512-9nohkfjLRzLfsLVGbO34eXBejvrOOTuw5tvNammH73KEFG5XlFoi3G2TgjTExHtnrKWCbZ+mTT+dbNeSjASIPw==",
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-18.0.1.tgz",
"integrity": "sha512-MO7mLp/8nm6kZNLLyPgz4gHmr9tLoU+pWPLdXuGAx+oZydBHkHWN0ibTonsrfwC2WEQNIQxuZagYwB67JQpAuw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -5676,82 +5594,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-fetch": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.0.tgz",
"integrity": "sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.0.15"
}
},
"node_modules/openapi-typescript": {
"version": "7.6.1",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.6.1.tgz",
"integrity": "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag==",
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.28.0",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.1.0",
"supports-color": "^9.4.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/openapi-typescript-helpers": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz",
"integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==",
"license": "MIT"
},
"node_modules/openapi-typescript/node_modules/parse-json": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.2.0.tgz",
"integrity": "sha512-eONBZy4hm2AgxjNFd8a4nyDJnzUAH0g34xSQAwWEVGCjdZ4ZL7dKZBfq267GWP/JaS9zW62Xs2FeAdDvpHHJGQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.0.0",
"type-fest": "^4.37.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-typescript/node_modules/supports-color": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz",
"integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/openapi-typescript/node_modules/type-fest": {
"version": "4.38.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz",
"integrity": "sha512-2dBz5D5ycHIoliLYLi0Q2V7KRaDlH0uWIvmk7TYlAg5slqwiPv1ezJdZm1QEM0xgk29oYWMCbIG7E6gHpvChlg==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -5913,6 +5755,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@ -6007,15 +5850,6 @@
"node": ">=8"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@ -6333,6 +6167,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -6493,9 +6328,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.89.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.0.tgz",
"integrity": "sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ==",
"version": "1.89.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.1.tgz",
"integrity": "sha512-eMLLkl+qz7tx/0cJ9wI+w09GQ2zodTkcE/aVfywwdlRcI3EO19xGnbmJwg/JMIm+5MxVJ6outddLZ4Von4E++Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7276,6 +7111,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -7286,15 +7122,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.1.tgz",
"integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==",
"version": "8.33.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.33.1.tgz",
"integrity": "sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.32.1",
"@typescript-eslint/parser": "8.32.1",
"@typescript-eslint/utils": "8.32.1"
"@typescript-eslint/eslint-plugin": "8.33.1",
"@typescript-eslint/parser": "8.33.1",
"@typescript-eslint/utils": "8.33.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7366,12 +7202,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"license": "MIT"
},
"node_modules/url": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
@ -7491,14 +7321,15 @@
}
},
"node_modules/webpack": {
"version": "5.98.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"version": "5.99.9",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz",
"integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1",
@ -7515,7 +7346,7 @@
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.0",
"schema-utils": "^4.3.2",
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1",
@ -7684,9 +7515,9 @@
"license": "MIT"
},
"node_modules/webpack/node_modules/schema-utils": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz",
"integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -7814,12 +7645,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"license": "Apache-2.0"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@ -7843,6 +7668,7 @@
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@ -7867,7 +7693,7 @@
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.27",
"@types/node": "^22.15.30",
"css-loader": "^7.1.2",
"date-fns": "^4.1.0",
"file-loader": "^6.2.0",
@ -7876,7 +7702,7 @@
"mini-css-extract-plugin": "^2.9.2",
"obsidian": "1.8.7",
"resolve-url-loader": "^5.0.0",
"sass": "^1.89.0",
"sass": "^1.89.1",
"sass-loader": "^16.0.5",
"sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14",
@ -7886,7 +7712,7 @@
"typescript": "5.8.3",
"url": "^0.11.4",
"virtual-scroller": "^1.13.1",
"webpack": "^5.98.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
},
@ -7895,21 +7721,19 @@
"dependencies": {
"byte-base64": "^1.1.0",
"minimatch": "^10.0.1",
"openapi-fetch": "0.14.0",
"openapi-typescript": "7.6.1",
"p-queue": "^8.1.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.27",
"@types/node": "^22.15.30",
"jest": "^29.7.0",
"sync_lib": "file:../../backend/sync_lib/pkg",
"ts-jest": "^29.3.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"webpack": "^5.98.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1",
"ws": "^8.18.2"
@ -7945,14 +7769,14 @@
"test-client": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^22.15.27",
"@types/node": "^22.15.30",
"bufferutil": "^4.0.9",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"uuid": "^11.1.0",
"webpack": "^5.98.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View file

@ -21,10 +21,10 @@
},
"devDependencies": {
"concurrently": "^9.1.2",
"eslint": "9.23.0",
"eslint": "9.28.0",
"eslint-plugin-unused-imports": "^4.1.4",
"npm-check-updates": "^17.1.16",
"npm-check-updates": "^18.0.1",
"prettier": "^3.5.3",
"typescript-eslint": "8.32.1"
"typescript-eslint": "8.33.1"
}
}

View file

@ -15,23 +15,21 @@
"dependencies": {
"byte-base64": "^1.1.0",
"minimatch": "^10.0.1",
"openapi-fetch": "0.14.0",
"openapi-typescript": "7.6.1",
"p-queue": "^8.1.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.27",
"@types/node": "^22.15.30",
"jest": "^29.7.0",
"sync_lib": "file:../../backend/sync_lib/pkg",
"ts-jest": "^29.3.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"webpack": "^5.98.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1",
"ws": "^8.18.2"
}
}
}

View file

@ -19,7 +19,8 @@ export type {
Cursor
} from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status";
export { DocumentUpdateStatus } from "./types/document-update-status";
export { SyncClient } from "./sync-client";

View file

@ -8,6 +8,7 @@ export interface SyncSettings {
isSyncEnabled: boolean;
maxFileSizeMB: number;
ignorePatterns: string[];
webSocketRetryIntervalMs: number;
}
export const DEFAULT_SETTINGS: SyncSettings = {
@ -17,7 +18,8 @@ export const DEFAULT_SETTINGS: SyncSettings = {
syncConcurrency: 1,
isSyncEnabled: false,
maxFileSizeMB: 10,
ignorePatterns: []
ignorePatterns: [],
webSocketRetryIntervalMs: 3500
};
export class Settings {

View file

@ -51,7 +51,10 @@ export class ConnectionStatus {
logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch {
return async (input: RequestInfo | URL): Promise<Response> => {
return async (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> => {
while (!this.canFetch) {
await this.until;
}
@ -63,7 +66,7 @@ export class ConnectionStatus {
? input.clone()
: input;
const fetchPromise = fetch(_input);
const fetchPromise = fetch(_input, init);
// We only want to catch rejections from `this.until`
let result: symbol | Response | undefined = undefined;

View file

@ -1,16 +1,21 @@
import type { Client } from "openapi-fetch";
import createClient from "openapi-fetch";
import type { components, paths } from "./types"; // generated by openapi-typescript
import type {
DocumentId,
RelativePath,
VaultUpdateId
} from "../persistence/database";
import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings";
import type { ConnectionStatus } from "./connection-status";
import { sleep } from "../utils/sleep";
import { SyncResetError } from "./sync-reset-error";
import type { SerializedError } from "./types/SerializedError";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import type { DocumentVersion } from "./types/DocumentVersion";
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
import type { PingResponse } from "./types/PingResponse";
import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
export interface CheckConnectionResult {
isSuccessful: boolean;
@ -19,47 +24,28 @@ export interface CheckConnectionResult {
export class SyncService {
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
private client: Client<paths>;
private pingClient: Client<paths>;
private readonly client: typeof globalThis.fetch;
private readonly pingClient: typeof globalThis.fetch;
public constructor(
private readonly deviceId: string,
private readonly connectionStatus: ConnectionStatus,
private readonly settings: Settings,
private readonly logger: Logger,
private readonly fetchImplementation: typeof globalThis.fetch = globalThis.fetch
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
) {
[this.client, this.pingClient] = this.createClient(
this.settings.getSettings().remoteUri
// ensure that if it's called a method, `this` won't be bound to the instance
const unboundFetch: typeof globalThis.fetch = async (...args) =>
fetchImplementation(...args);
this.client = this.connectionStatus.getFetchImplementation(
this.logger,
unboundFetch
);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (newSettings.remoteUri === oldSettings.remoteUri) {
return;
}
[this.client, this.pingClient] = this.createClient(
newSettings.remoteUri
);
});
this.pingClient = unboundFetch;
}
private get deviceIdHeader(): string {
// @ts-expect-error, injected by webpack
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
const platform =
typeof navigator !== "undefined"
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
: typeof process !== "undefined"
? process.platform
: "unknown";
return `vault-link/${packageVersion} (${this.deviceId}; ${platform})`;
}
private static formatError(
error: components["schemas"]["SerializedError"]
): string {
private static formatError(error: SerializedError): string {
let result = error.message;
if (error.causes.length > 0) {
const causes = error.causes.join(", ");
@ -77,47 +63,39 @@ export class SyncService {
documentId?: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<components["schemas"]["DocumentVersionWithoutContent"]> {
const { vaultName } = this.settings.getSettings();
}): Promise<DocumentVersionWithoutContent> {
return this.withRetries(async () => {
const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath);
formData.append("device_id", this.deviceId);
formData.append("content", new Blob([contentBytes]));
const response = await this.client.POST(
"/vaults/{vault_id}/documents",
{
params: {
path: {
vault_id: vaultName
},
header: {
"device-id": this.deviceIdHeader
}
},
// eslint-disable-next-line
body: formData as any // FormData is not supported by openapi-fetch
}
);
const response = await this.client(this.getUrl("/documents"), {
method: "POST",
body: formData,
headers: this.getDefaultHeaders()
});
if (!response.data) {
const result: SerializedError | DocumentVersionWithoutContent =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentVersionWithoutContent;
if ("errorType" in result) {
throw new Error(
`Failed to create document: ${SyncService.formatError(response.error)}`
`Failed to create document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Created document ${JSON.stringify(response.data)} with id ${
response.data.documentId
`Created document ${JSON.stringify(result)} with id ${
result.documentId
}`
);
return response.data;
return result;
});
}
@ -131,9 +109,7 @@ export class SyncService {
documentId: DocumentId;
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<components["schemas"]["DocumentUpdateResponse"]> {
const { vaultName } = this.settings.getSettings();
}): Promise<DocumentUpdateResponse> {
return this.withRetries(async () => {
this.logger.debug(
`Updating document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
@ -141,39 +117,35 @@ export class SyncService {
const formData = new FormData();
formData.append("parent_version_id", parentVersionId.toString());
formData.append("relative_path", relativePath);
formData.append("device_id", this.deviceId);
formData.append("content", new Blob([contentBytes]));
const response = await this.client.PUT(
"/vaults/{vault_id}/documents/{document_id}",
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
params: {
path: {
vault_id: vaultName,
document_id: documentId
},
header: {
"device-id": this.deviceIdHeader
}
},
// eslint-disable-next-line
body: formData as any // FormData is not supported by openapi-fetch
method: "PUT",
body: formData,
headers: this.getDefaultHeaders()
}
);
if (!response.data) {
const result: SerializedError | DocumentUpdateResponse =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentUpdateResponse;
if ("errorType" in result) {
throw new Error(
`Failed to update document: ${SyncService.formatError(response.error)}`
`Failed to update document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Updated document ${JSON.stringify(response.data)} with id ${
response.data.documentId
}`
`Updated document ${JSON.stringify(result)} with id ${
result.documentId
}}`
);
return response.data;
return result;
});
}
@ -183,39 +155,39 @@ export class SyncService {
}: {
documentId: DocumentId;
relativePath: RelativePath;
}): Promise<components["schemas"]["DocumentVersionWithoutContent"]> {
}): Promise<DocumentVersionWithoutContent> {
return this.withRetries(async () => {
const { vaultName } = this.settings.getSettings();
const response = await this.client.DELETE(
"/vaults/{vault_id}/documents/{document_id}",
const request: DeleteDocumentVersion = {
relativePath
};
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
params: {
path: {
vault_id: vaultName,
document_id: documentId
},
header: {
"device-id": this.deviceIdHeader
}
},
body: {
relativePath,
deviceId: this.deviceId
method: "DELETE",
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
...this.getDefaultHeaders()
}
}
);
if (response.error) {
throw new Error(`Failed to delete document`);
const result: SerializedError | DocumentVersionWithoutContent =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentVersionWithoutContent;
if ("errorType" in result) {
throw new Error(
`Failed to delete document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Deleted document ${relativePath} with id ${documentId}`
);
return response.data;
return result;
});
}
@ -223,100 +195,77 @@ export class SyncService {
documentId
}: {
documentId: DocumentId;
}): Promise<components["schemas"]["DocumentVersion"]> {
const { vaultName } = this.settings.getSettings();
}): Promise<DocumentVersion> {
return this.withRetries(async () => {
const response = await this.client.GET(
"/vaults/{vault_id}/documents/{document_id}",
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
params: {
path: {
vault_id: vaultName,
document_id: documentId
}
}
headers: this.getDefaultHeaders()
}
);
if (!response.data) {
const result: SerializedError | DocumentVersion =
(await response.json()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if ("errorType" in result) {
throw new Error(
`Failed to get document: ${SyncService.formatError(response.error)}`
`Failed to get document: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Get document ${response.data.relativePath} with id ${response.data.documentId}`
`Get document ${result.relativePath} with id ${result.documentId}`
);
return response.data;
return result;
});
}
public async getAll(
since?: VaultUpdateId
): Promise<components["schemas"]["FetchLatestDocumentsResponse"]> {
): Promise<FetchLatestDocumentsResponse> {
return this.withRetries(async () => {
const { vaultName } = this.settings.getSettings();
const url = new URL(this.getUrl("/documents"));
if (since !== undefined) {
url.searchParams.append("since", since.toString());
}
const response = await this.client(url.toString(), {
headers: this.getDefaultHeaders()
});
const response = await this.client.GET(
"/vaults/{vault_id}/documents",
{
params: {
path: {
vault_id: vaultName
},
query: {
since_update_id: since
}
}
}
);
const result: SerializedError | FetchLatestDocumentsResponse =
(await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| FetchLatestDocumentsResponse;
const { error } = response;
if (error) {
if ("errorType" in result) {
throw new Error(
`Failed to get documents: ${SyncService.formatError(response.error)}`
`Failed to get documents: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Got ${response.data.latestDocuments.length} document metadata`
`Got ${result.latestDocuments.length} document metadata`
);
return response.data;
return result;
});
}
public async checkConnection(): Promise<CheckConnectionResult> {
const { vaultName } = this.settings.getSettings();
try {
const response = await this.pingClient.GET(
"/vaults/{vault_id}/ping",
{
params: {
header: {
authorization: `Bearer ${this.settings.getSettings().token}`
},
path: {
vault_id: vaultName
}
}
}
);
const response = await this.pingClient(this.getUrl("/ping"), {
headers: this.getDefaultHeaders()
});
const result: PingResponse | SerializedError =
(await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(
`Ping response: ${JSON.stringify(response.data)}`
);
if (!response.data) {
if ("errorType" in result) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(response.error)}`
`Failed to ping server: ${SyncService.formatError(result)}`
);
}
const result = response.data;
if (result.isAuthenticated) {
return {
isSuccessful: true,
@ -336,29 +285,17 @@ export class SyncService {
}
}
/**
* Create a client and a ping client for the given remote URI.
*/
private createClient(remoteUri: string): [Client<paths>, Client<paths>] {
return [
createClient<paths>({
baseUrl: remoteUri,
fetch: this.connectionStatus.getFetchImplementation(
this.logger,
this.fetchImplementation
),
headers: {
authorization: `Bearer ${this.settings.getSettings().token}`
}
}),
createClient<paths>({
baseUrl: remoteUri,
fetch: this.fetchImplementation,
headers: {
authorization: `Bearer ${this.settings.getSettings().token}`
}
})
];
private getUrl(path: string): string {
const { vaultName, remoteUri } = this.settings.getSettings();
const safeRemoteUri = remoteUri.replace(/\/+$/, "");
return `${safeRemoteUri}/vaults/${vaultName}${path}`;
}
private getDefaultHeaders(): Record<string, string> {
return {
"device-id": this.deviceId,
authorization: `Bearer ${this.settings.getSettings().token}`
};
}
private async withRetries<T>(fn: () => Promise<T>): Promise<T> {

View file

@ -1,655 +0,0 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/vaults/{vault_id}/documents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
since_update_id?: number | null;
};
header?: never;
path: {
vault_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["FetchLatestDocumentsResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
put?: never;
post: {
parameters: {
query?: never;
header: {
"device-id": string;
};
path: {
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["CreateDocumentVersionMultipart"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentVersionWithoutContent"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/json": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: {
parameters: {
query?: never;
header: {
"device-id": string;
};
path: {
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateDocumentVersion"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentVersionWithoutContent"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentVersion"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
put: {
parameters: {
query?: never;
header: {
"device-id": string;
};
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["UpdateDocumentVersionMultipart"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentUpdateResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete: {
parameters: {
query?: never;
header: {
"device-id": string;
};
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DeleteDocumentVersion"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentVersionWithoutContent"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}/json": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: {
parameters: {
query?: never;
header: {
"device-id": string;
};
path: {
document_id: string;
vault_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateDocumentVersion"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentUpdateResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}/versions/{version_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
vault_id: string;
vault_update_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentVersion"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/documents/{document_id}/versions/{version_id}/content": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
vault_id: string;
vault_update_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description byte stream */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": unknown;
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/vaults/{vault_id}/ping": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: {
authorization?: string;
};
path: {
vault_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PingResponse"];
};
};
default: {
headers: {
[name: string]: unknown;
};
content: {
/** @example {
* "causes": [],
* "message": "An error has occurred"
* } */
"application/json": components["schemas"]["SerializedError"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
Array_of_uint8: number[];
CreateDocumentPathParams: {
vault_id: string;
};
CreateDocumentVersion: {
contentBase64: string;
deviceId?: string | null;
/**
* Format: uuid
* @description The client can decide the document id (if it wishes to) in order to help with syncing. If the client does not provide a document id, the server will generate one. If the client provides a document id it must not already exist in the database.
*/
documentId?: string | null;
relativePath: string;
};
CreateDocumentVersionMultipart: {
content: components["schemas"]["Array_of_uint8"];
device_id?: string | null;
/** Format: uuid */
document_id?: string | null;
relative_path: string;
};
DeleteDocumentPathParams: {
/** Format: uuid */
document_id: string;
vault_id: string;
};
DeleteDocumentVersion: {
deviceId?: string | null;
relativePath: string;
};
/** @description Response to an update document request. */
DocumentUpdateResponse:
| {
/** Format: uint64 */
contentSize: number;
deviceId: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** @enum {string} */
type: "FastForwardUpdate";
/** Format: date-time */
updatedDate: string;
userId: string;
/** Format: int64 */
vaultUpdateId: number;
}
| {
contentBase64: string;
deviceId: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** @enum {string} */
type: "MergingUpdate";
/** Format: date-time */
updatedDate: string;
userId: string;
/** Format: int64 */
vaultUpdateId: number;
};
DocumentVersion: {
contentBase64: string;
deviceId: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** Format: date-time */
updatedDate: string;
userId: string;
/** Format: int64 */
vaultUpdateId: number;
};
DocumentVersionWithoutContent: {
/** Format: uint64 */
contentSize: number;
deviceId: string;
/** Format: uuid */
documentId: string;
isDeleted: boolean;
relativePath: string;
/** Format: date-time */
updatedDate: string;
userId: string;
/** Format: int64 */
vaultUpdateId: number;
};
FetchDocumentVersionContentPathParams: {
/** Format: uuid */
document_id: string;
vault_id: string;
/** Format: int64 */
vault_update_id: number;
};
FetchDocumentVersionPathParams: {
/** Format: uuid */
document_id: string;
vault_id: string;
/** Format: int64 */
vault_update_id: number;
};
FetchLatestDocumentVersionPathParams: {
/** Format: uuid */
document_id: string;
vault_id: string;
};
FetchLatestDocumentsPathParams: {
vault_id: string;
};
/** @description Response to a fetch latest documents request. */
FetchLatestDocumentsResponse: {
/**
* Format: int64
* @description The update ID of the latest document in the response.
*/
lastUpdateId: number;
latestDocuments: components["schemas"]["DocumentVersionWithoutContent"][];
};
PingPathParams: {
vault_id: string;
};
/** @description Response to a ping request. */
PingResponse: {
/** @description Whether the client is authenticated based on the sent Authorization header. */
isAuthenticated: boolean;
/** @description Semantic version of the server. */
serverVersion: string;
};
QueryParams: {
/** Format: int64 */
since_update_id?: number | null;
};
SerializedError: {
causes: string[];
message: string;
};
UpdateDocumentPathParams: {
/** Format: uuid */
document_id: string;
vault_id: string;
};
UpdateDocumentVersion: {
contentBase64: string;
deviceId?: string | null;
/** Format: int64 */
parentVersionId: number;
relativePath: string;
};
UpdateDocumentVersionMultipart: {
content: components["schemas"]["Array_of_uint8"];
deviceId?: string | null;
/** Format: int64 */
parentVersionId: number;
relativePath: string;
};
WebsocketPathParams: {
vault_id: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;

View file

@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan";
export interface ClientCursors {
userName: string;
deviceId: string;
cursors: Partial<Record<string, CursorSpan[]>>;
}

View file

@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CreateDocumentVersion {
/**
* The client can decide the document id (if it wishes to) in order
* to help with syncing. If the client does not provide a document id,
* the server will generate one. If the client provides a document id
* it must not already exist in the database.
*/
document_id: string | null;
relative_path: string;
content: number[];
}

View file

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan";
export interface CursorPositionFromClient {
documentToCursors: Partial<Record<string, CursorSpan[]>>;
}

View file

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientCursors } from "./ClientCursors";
export interface CursorPositionFromServer {
clients: ClientCursors[];
}

View file

@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CursorSpan {
start: number;
end: number;
}

View file

@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DeleteDocumentVersion {
relativePath: string;
}

View file

@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersion } from "./DocumentVersion";
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to an update document request.
*/
export type DocumentUpdateResponse =
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
| ({ type: "MergingUpdate" } & DocumentVersion);

View file

@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersion {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
contentBase64: string;
isDeleted: boolean;
userId: string;
deviceId: string;
}

View file

@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface DocumentVersionWithoutContent {
vaultUpdateId: number;
documentId: string;
relativePath: string;
updatedDate: string;
isDeleted: boolean;
userId: string;
deviceId: string;
contentSize: number;
}

View file

@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a fetch latest documents request.
*/
export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[];
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint;
}

View file

@ -0,0 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Response to a ping request.
*/
export interface PingResponse {
/**
* Semantic version of the server.
*/
serverVersion: string;
/**
* Whether the client is authenticated based on the sent Authorization
* header.
*/
isAuthenticated: boolean;
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface SerializedError {
errorType: string;
message: string;
causes: string[];
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface UpdateDocumentVersion {
parent_version_id: bigint;
relative_path: string;
content: number[];
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
import type { WebSocketHandshake } from "./WebSocketHandshake";
export type WebSocketClientMessage =
| ({ type: "handshake" } & WebSocketHandshake)
| ({ type: "cursorPositions" } & CursorPositionFromClient);

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface WebSocketHandshake {
token: string;
deviceId: string;
lastSeenVaultUpdateId: number | null;
}

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
export type WebSocketServerMessage =
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
| ({ type: "cursorPositions" } & CursorPositionFromServer);

View file

@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export interface WebSocketVaultUpdate {
documents: DocumentVersionWithoutContent[];
isInitialSync: boolean;
}

View file

@ -0,0 +1,209 @@
import type { Database } from "../persistence/database";
import type { Logger } from "../tracing/logger";
import type { Settings, SyncSettings } from "../persistence/settings";
import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
import type { Syncer } from "../sync-operations/syncer";
import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
import type { ClientCursors } from "./types/ClientCursors";
export class WebSocketManager {
private readonly webSocketStatusChangeListeners: (() => unknown)[] = [];
private readonly remoteCursorsUpdateListeners: ((
cursors: ClientCursors[]
) => unknown)[] = [];
private refreshWebSocketInterval: NodeJS.Timeout | undefined;
private webSocket: WebSocket | undefined;
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
public constructor(
private readonly deviceId: string,
private readonly logger: Logger,
private readonly database: Database,
private readonly settings: Settings,
private readonly syncer: Syncer,
webSocketImplementation?: typeof globalThis.WebSocket
) {
if (webSocketImplementation) {
this.webSocketFactoryImplementation = webSocketImplementation;
} else {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
) {
// eslint-disable-next-line
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
} else {
this.webSocketFactoryImplementation = WebSocket;
}
}
this.updateWebSocket(settings.getSettings());
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (
newSettings.remoteUri !== oldSettings.remoteUri ||
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.token !== oldSettings.token ||
newSettings.isSyncEnabled !== oldSettings.isSyncEnabled
) {
this.updateWebSocket(newSettings);
}
});
this.setWebSocketRefreshInterval();
}
public get isWebSocketConnected(): boolean {
return (
this.webSocket?.readyState ===
this.webSocketFactoryImplementation.OPEN
);
}
public addWebSocketStatusChangeListener(listener: () => void): void {
this.webSocketStatusChangeListeners.push(listener);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: ClientCursors[]) => void
): void {
this.remoteCursorsUpdateListeners.push(listener);
}
public async reset(): Promise<void> {
this.setWebSocketRefreshInterval();
this.updateWebSocket(this.settings.getSettings());
}
public stop(): void {
clearInterval(this.refreshWebSocketInterval);
try {
this.webSocket?.close();
} catch (e) {
this.logger.warn(`Failed to close WebSocket: ${e}`);
}
}
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
if (!this.isWebSocketConnected) {
this.logger.warn(
"WebSocket is not connected, cannot send cursor positions"
);
return;
}
const message: WebSocketClientMessage = {
type: "cursorPositions",
...cursorPositions
};
this.webSocket?.send(JSON.stringify(message));
this.logger.info(
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
);
}
private updateWebSocket(settings: SyncSettings): void {
try {
this.webSocket?.close();
} catch (e) {
this.logger.warn(`Failed to close WebSocket: ${e}`);
}
if (!settings.isSyncEnabled) {
this.webSocket = undefined;
return;
}
const wsUri = new URL(settings.remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
this.webSocket.onmessage = async (event): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse(event.data) as WebSocketServerMessage;
if (message.type === "vaultUpdate") {
try {
await Promise.all(
message.documents.map(async (document) =>
this.syncer.syncRemotelyUpdatedFile(document)
)
);
if (message.isInitialSync && message.documents.length > 0) {
this.database.setLastSeenUpdateId(
message.documents
.map((document) => document.vaultUpdateId)
.reduce((a, b) => Math.max(a, b))
);
}
} catch (e) {
this.logger.error(
`Failed to sync remotely updated file: ${e}`
);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (message.type === "cursorPositions") {
this.logger.info(
`Received cursor positions for ${JSON.stringify(message.clients)}`
);
this.remoteCursorsUpdateListeners.forEach((listener) => {
listener(
message.clients.filter(
(client) => client.deviceId !== this.deviceId
)
);
});
} else {
this.logger.warn(
`Received unknown message type: ${JSON.stringify(message)}`
);
}
};
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.webSocket.onopen = (): void => {
this.logger.info("WebSocket connection opened");
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
const message: WebSocketClientMessage = {
type: "handshake",
deviceId: this.deviceId,
token: settings.token,
lastSeenVaultUpdateId: this.database.getLastSeenUpdateId()
};
this.webSocket?.send(JSON.stringify(message));
};
this.webSocket.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
};
}
private setWebSocketRefreshInterval(): void {
this.refreshWebSocketInterval = setInterval(() => {
if (
this.webSocket?.readyState ===
this.webSocketFactoryImplementation.CLOSED
) {
this.logger.info("WebSocket is closed, reconnecting...");
this.updateWebSocket(this.settings.getSettings());
}
}, this.settings.getSettings().webSocketRetryIntervalMs);
}
}

View file

@ -15,9 +15,12 @@ import { FileOperations } from "./file-operations/file-operations";
import { ConnectionStatus } from "./services/connection-status";
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
import { rateLimit } from "./utils/rate-limit";
import { v4 as uuidv4 } from "uuid";
import type { NetworkConnectionStatus } from "./types/network-connection-status";
import { DocumentUpdateStatus } from "./types/document-update-status";
import { WebSocketManager } from "./services/websocket-manager";
import { createClientId } from "./utils/create-client-id";
import type { CursorSpan } from "./services/types/CursorSpan";
import type { ClientCursors } from "./services/types/ClientCursors";
export class SyncClient {
private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000;
@ -29,6 +32,7 @@ export class SyncClient {
private readonly database: Database,
private readonly syncer: Syncer,
private readonly syncService: SyncService,
private readonly webSocketManager: WebSocketManager,
private readonly _logger: Logger,
private readonly connectionStatus: ConnectionStatus
) {
@ -68,7 +72,10 @@ export class SyncClient {
nativeLineEndings?: string;
}): Promise<SyncClient> {
const logger = new Logger();
logger.info("Initialising SyncClient");
const deviceId = createClientId();
logger.info(`Initialising SyncClient with client id ${deviceId}`);
const history = new SyncHistory(logger);
@ -104,7 +111,6 @@ export class SyncClient {
await rateLimitedSave(state);
}
);
const deviceId = uuidv4();
const connectionStatus = new ConnectionStatus(settings, logger);
const syncService = new SyncService(
@ -121,6 +127,7 @@ export class SyncClient {
fs,
nativeLineEndings
);
const unrestrictedSyncer = new UnrestrictedSyncer(
logger,
database,
@ -129,6 +136,7 @@ export class SyncClient {
fileOperations,
history
);
const syncer = new Syncer(
deviceId,
logger,
@ -136,7 +144,15 @@ export class SyncClient {
settings,
syncService,
fileOperations,
unrestrictedSyncer,
unrestrictedSyncer
);
const webSocketManager = new WebSocketManager(
deviceId,
logger,
database,
settings,
syncer,
webSocket
);
@ -146,6 +162,7 @@ export class SyncClient {
database,
syncer,
syncService,
webSocketManager,
logger,
connectionStatus
);
@ -160,7 +177,7 @@ export class SyncClient {
return {
isSuccessful: server.isSuccessful,
serverMessage: server.message,
isWebSocketConnected: this.syncer.isWebSocketConnected
isWebSocketConnected: this.webSocketManager.isWebSocketConnected
};
}
@ -179,7 +196,7 @@ export class SyncClient {
}
public stop(): void {
this.syncer.stop();
this.webSocketManager.stop();
}
public async waitAndStop(): Promise<void> {
@ -194,6 +211,7 @@ export class SyncClient {
this.stop();
this.connectionStatus.startReset();
await this.syncer.reset();
await this.webSocketManager.reset();
this.history.reset();
this.database.reset();
this._logger.reset();
@ -229,7 +247,7 @@ export class SyncClient {
}
public addWebSocketStatusChangeListener(listener: () => void): void {
this.syncer.addWebSocketStatusChangeListener(listener);
this.webSocketManager.addWebSocketStatusChangeListener(listener);
}
public async syncLocallyCreatedFile(
@ -257,6 +275,18 @@ export class SyncClient {
});
}
public async updateLocalCursors(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
this.webSocketManager.updateLocalCursors({ documentToCursors });
}
public addRemoteCursorsUpdateListener(
listener: (cursors: ClientCursors[]) => void
): void {
this.webSocketManager.addRemoteCursorsUpdateListener(listener);
}
public getDocumentSyncingStatus(
relativePath: RelativePath
): DocumentUpdateStatus {

View file

@ -9,7 +9,6 @@ import type { Logger } from "../tracing/logger";
import PQueue from "p-queue";
import { hash } from "../utils/hash";
import { v4 as uuidv4 } from "uuid";
import type { components } from "../services/types";
import type { Settings, SyncSettings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "../utils/find-matching-file";
@ -17,27 +16,16 @@ import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error";
import { Locks } from "../utils/locks";
interface WebsocketVaultUpdate {
documents: components["schemas"]["DocumentVersionWithoutContent"][];
isInitialSync: boolean;
}
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
export class Syncer {
private readonly remoteDocumentsLock: Locks<DocumentId>;
private readonly remainingOperationsListeners: ((
remainingOperations: number
) => void)[] = [];
private readonly webSocketStatusChangeListeners: (() => void)[] = [];
private readonly syncQueue: PQueue;
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
private refreshApplyRemoteChangesWebSocketInterval:
| NodeJS.Timeout
| undefined;
private applyRemoteChangesWebSocket: WebSocket | undefined;
private readonly webSocketImplementation: typeof globalThis.WebSocket;
// eslint-disable-next-line @typescript-eslint/max-params
public constructor(
@ -47,41 +35,15 @@ export class Syncer {
private readonly settings: Settings,
private readonly syncService: SyncService,
private readonly operations: FileOperations,
private readonly internalSyncer: UnrestrictedSyncer,
webSocketImplementation?: typeof globalThis.WebSocket
private readonly internalSyncer: UnrestrictedSyncer
) {
this.syncQueue = new PQueue({
concurrency: settings.getSettings().syncConcurrency
});
if (webSocketImplementation) {
this.webSocketImplementation = webSocketImplementation;
} else {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
) {
// eslint-disable-next-line
this.webSocketImplementation = require("ws"); // polyfill for WebSocket in Node.js
} else {
this.webSocketImplementation = WebSocket;
}
}
this.updateWebSocket(settings.getSettings());
this.remoteDocumentsLock = new Locks<DocumentId>(this.logger);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (
newSettings.remoteUri !== oldSettings.remoteUri ||
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.token !== oldSettings.token ||
newSettings.isSyncEnabled !== oldSettings.isSyncEnabled
) {
this.updateWebSocket(newSettings);
}
if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) {
this.syncQueue.concurrency = newSettings.syncConcurrency;
}
@ -92,15 +54,6 @@ export class Syncer {
listener(this.syncQueue.size);
});
});
this.setWebSocketRefreshInterval();
}
public get isWebSocketConnected(): boolean {
return (
this.applyRemoteChangesWebSocket?.readyState ===
this.webSocketImplementation.OPEN
);
}
public addRemainingOperationsListener(
@ -109,10 +62,6 @@ export class Syncer {
this.remainingOperationsListeners.push(listener);
}
public addWebSocketStatusChangeListener(listener: () => void): void {
this.webSocketStatusChangeListeners.push(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
@ -303,106 +252,10 @@ export class Syncer {
public async reset(): Promise<void> {
await this.waitUntilFinished();
this.setWebSocketRefreshInterval();
this.updateWebSocket(this.settings.getSettings());
}
public stop(): void {
clearInterval(this.refreshApplyRemoteChangesWebSocketInterval);
try {
this.applyRemoteChangesWebSocket?.close();
} catch (e) {
this.logger.warn(`Failed to close WebSocket: ${e}`);
}
}
private updateWebSocket(settings: SyncSettings): void {
try {
this.applyRemoteChangesWebSocket?.close();
} catch (e) {
this.logger.warn(`Failed to close WebSocket: ${e}`);
}
if (!settings.isSyncEnabled) {
this.applyRemoteChangesWebSocket = undefined;
return;
}
const wsUri = new URL(settings.remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
this.applyRemoteChangesWebSocket = new this.webSocketImplementation(
wsUri
);
this.applyRemoteChangesWebSocket.onmessage = async (
event
): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse(event.data) as WebsocketVaultUpdate;
try {
await Promise.all(
message.documents.map(async (document) =>
this.syncRemotelyUpdatedFile(document)
)
);
if (message.isInitialSync && message.documents.length > 0) {
this.database.setLastSeenUpdateId(
message.documents
.map((document) => document.vaultUpdateId)
.reduce((a, b) => Math.max(a, b))
);
}
} catch (e) {
this.logger.error(`Failed to sync remotely updated file: ${e}`);
}
};
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.applyRemoteChangesWebSocket.onopen = (): void => {
this.logger.info("WebSocket connection opened");
this.applyRemoteChangesWebSocket?.send(
JSON.stringify({
deviceId: this.deviceId,
token: settings.token,
lastSeenVaultUpdateId: this.database.getLastSeenUpdateId()
})
);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
};
this.applyRemoteChangesWebSocket.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
};
}
private setWebSocketRefreshInterval(): void {
this.refreshApplyRemoteChangesWebSocketInterval = setInterval(() => {
if (
this.applyRemoteChangesWebSocket?.readyState ===
this.webSocketImplementation.OPEN
) {
return;
}
this.updateWebSocket(this.settings.getSettings());
}, 5000);
}
private async syncRemotelyUpdatedFile(
remoteVersion: components["schemas"]["DocumentVersionWithoutContent"]
public async syncRemotelyUpdatedFile(
remoteVersion: DocumentVersionWithoutContent
): Promise<void> {
let document = this.database.getDocumentByDocumentId(
remoteVersion.documentId

View file

@ -17,7 +17,6 @@ import type {
} from "../tracing/sync-history";
import { SyncStatus, SyncType } from "../tracing/sync-history";
import { EMPTY_HASH, hash } from "../utils/hash";
import type { components } from "../services/types";
import { deserialize } from "../utils/deserialize";
import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
@ -25,6 +24,9 @@ import { createPromise } from "../utils/create-promise";
import { FileNotFoundError } from "../file-operations/file-not-found-error";
import { SyncResetError } from "../services/sync-reset-error";
import { globsToRegexes } from "../utils/globs-to-regexes";
import type { DocumentVersion } from "../services/types/DocumentVersion";
import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
export class UnrestrictedSyncer {
private ignorePatterns: RegExp[];
@ -172,10 +174,8 @@ export class UnrestrictedSyncer {
document.metadata.hash === contentHash && oldPath === undefined
);
let response:
| components["schemas"]["DocumentVersion"]
| components["schemas"]["DocumentUpdateResponse"]
| undefined = undefined;
let response: DocumentVersion | DocumentUpdateResponse | undefined =
undefined;
if (areThereLocalChanges) {
response = await this.syncService.put({
@ -332,7 +332,7 @@ export class UnrestrictedSyncer {
}
public async unrestrictedSyncRemotelyUpdatedFile(
remoteVersion: components["schemas"]["DocumentVersionWithoutContent"],
remoteVersion: DocumentVersionWithoutContent,
document?: DocumentRecord
): Promise<void> {
const updateDetails: SyncCreateDetails = {

View file

@ -0,0 +1,15 @@
import { v4 as uuidv4 } from "uuid";
export function createClientId(): string {
// @ts-expect-error, injected by webpack
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
const platform =
typeof navigator !== "undefined"
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
: typeof process !== "undefined"
? process.platform
: "unknown";
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`;
}

View file

@ -1,3 +1,7 @@
/**
* A type-safe utility function to create a Promise with resolve and reject functions.
* @returns A tuple containing a Promise, a resolve function, and a reject function.
*/
export function createPromise<T = void>(): [
Promise<T>,
(value: T) => void,

View file

@ -5,7 +5,7 @@ import type { Logger } from "../tracing/logger";
// Locks are granted in a first-in-first-out order.
export class Locks<T> {
private readonly locked = new Set<T>();
private readonly waiters = new Map<T, (() => void)[]>();
private readonly waiters = new Map<T, (() => unknown)[]>();
public constructor(private readonly logger: Logger) {}

View file

@ -11,14 +11,14 @@
"test": "jest"
},
"devDependencies": {
"@types/node": "^22.15.27",
"@types/node": "^22.15.30",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"typescript": "5.8.3",
"uuid": "^11.1.0",
"webpack": "^5.98.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1",
"bufferutil": "^4.0.9"
}
}
}