@absolutejs/sync-yjs 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +95 -0
- package/dist/index.js.map +10 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @absolutejs/sync-yjs
|
|
2
|
+
|
|
3
|
+
A [Yjs](https://github.com/yjs/yjs)-backed collaborative-text CRDT for
|
|
4
|
+
[`@absolutejs/sync`](https://github.com/absolutejs/sync), behind the same
|
|
5
|
+
`CrdtText` / `TextCrdtAdapter` contract as the core's zero-dependency `rgaText`.
|
|
6
|
+
|
|
7
|
+
`@absolutejs/sync/crdt` ships a first-party RGA text CRDT that's great for
|
|
8
|
+
offline-merge and moderate collaboration. When you need production-scale
|
|
9
|
+
collaborative text — efficient deltas, no unbounded tombstone growth, the
|
|
10
|
+
interleaving guarantees the community relies on — Yjs is the staple. This adapter
|
|
11
|
+
lets you swap it in without touching your call sites.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @absolutejs/sync-yjs yjs
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`@absolutejs/sync` is a peer dependency (you already have it). `yjs` is a runtime
|
|
20
|
+
dependency you install alongside.
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { yjsText } from '@absolutejs/sync-yjs';
|
|
26
|
+
// ...instead of: import { rgaText } from '@absolutejs/sync/crdt';
|
|
27
|
+
|
|
28
|
+
// Client — a live doc per replica (e.g. per tab/device)
|
|
29
|
+
const doc = yjsText.create('tab-a', initialStateFromServer);
|
|
30
|
+
doc.setText(textarea.value); // reconciles via a minimal diff
|
|
31
|
+
socket.send(doc.state()); // broadcast the serialized (base64) state
|
|
32
|
+
doc.merge(incomingState); // fold a peer's state into local edits
|
|
33
|
+
doc.text(); // the merged visible text
|
|
34
|
+
|
|
35
|
+
// Server — merge-on-write so concurrent writes combine instead of clobbering
|
|
36
|
+
const next = yjsText.merge(storedState, incomingState);
|
|
37
|
+
// store `next`; yjsText.textOf(next) is the plain text
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The serialized state is a base64 string of a Yjs update — JSON-safe for the sync
|
|
41
|
+
engine's change feed and for storing as a row field. `merge` is
|
|
42
|
+
commutative/associative/idempotent, so the order writes arrive in doesn't matter.
|
|
43
|
+
|
|
44
|
+
## The contract
|
|
45
|
+
|
|
46
|
+
This package implements `TextCrdtAdapter<string>` from `@absolutejs/sync/crdt`:
|
|
47
|
+
|
|
48
|
+
| Member | What it does |
|
|
49
|
+
| --------------------------- | ------------------------------------------------------------- |
|
|
50
|
+
| `create(replica, initial?)` | A live `CrdtText` doc: `text/setText/merge/state`. |
|
|
51
|
+
| `merge(a, b)` | Combine two persisted states (server-side, no live instance). |
|
|
52
|
+
| `empty()` | The serialized state of an empty document. |
|
|
53
|
+
| `textOf(state)` | The plain text of a persisted state. |
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
CC BY-NC 4.0
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Yjs-backed collaborative-text CRDT for `@absolutejs/sync`.
|
|
3
|
+
*
|
|
4
|
+
* `@absolutejs/sync` ships a zero-dependency RGA text CRDT (`rgaText`) that is
|
|
5
|
+
* fine for offline-merge and moderate collaboration. Yjs is the community staple
|
|
6
|
+
* for production-scale collaborative text — it solves the real footguns (tombstone
|
|
7
|
+
* growth, efficient deltas, interleaving). This adapter wraps Yjs behind the exact
|
|
8
|
+
* same `CrdtText` / `TextCrdtAdapter` contract, so swapping `rgaText` for `yjsText`
|
|
9
|
+
* needs no other change at the call site.
|
|
10
|
+
*
|
|
11
|
+
* The serialized state is a **base64 string** of a Yjs update, so it stays
|
|
12
|
+
* JSON-safe for transport over the sync engine's change feed and storage as a row
|
|
13
|
+
* field. Merges are commutative/associative/idempotent (Yjs guarantees it), so a
|
|
14
|
+
* merge-on-write mutation combines concurrent edits without clobbering.
|
|
15
|
+
*/
|
|
16
|
+
import type { CrdtText, TextCrdtAdapter } from '@absolutejs/sync/crdt';
|
|
17
|
+
/** Create a live Yjs-backed collaborative-text doc for `replica`. */
|
|
18
|
+
export declare const createYjsText: (replica: string, initial?: string) => CrdtText<string>;
|
|
19
|
+
/**
|
|
20
|
+
* The Yjs collaborative-text backend as a {@link TextCrdtAdapter}. Drop-in for
|
|
21
|
+
* the first-party `rgaText`: `create` mints a live doc, `merge` combines two
|
|
22
|
+
* persisted states server-side, `empty`/`textOf` are conveniences.
|
|
23
|
+
*/
|
|
24
|
+
export declare const yjsText: TextCrdtAdapter<string>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import * as Y from "yjs";
|
|
4
|
+
var TEXT_KEY = "text";
|
|
5
|
+
var toBase64 = (bytes) => {
|
|
6
|
+
let binary = "";
|
|
7
|
+
for (const byte of bytes) {
|
|
8
|
+
binary += String.fromCharCode(byte);
|
|
9
|
+
}
|
|
10
|
+
return btoa(binary);
|
|
11
|
+
};
|
|
12
|
+
var fromBase64 = (base64) => {
|
|
13
|
+
const binary = atob(base64);
|
|
14
|
+
const bytes = new Uint8Array(binary.length);
|
|
15
|
+
for (let index = 0;index < binary.length; index += 1) {
|
|
16
|
+
bytes[index] = binary.charCodeAt(index);
|
|
17
|
+
}
|
|
18
|
+
return bytes;
|
|
19
|
+
};
|
|
20
|
+
var encode = (doc) => toBase64(Y.encodeStateAsUpdate(doc));
|
|
21
|
+
var apply = (doc, state) => {
|
|
22
|
+
if (state.length > 0) {
|
|
23
|
+
Y.applyUpdate(doc, fromBase64(state));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var clientIdFor = (replica) => {
|
|
27
|
+
let hash = 0;
|
|
28
|
+
for (let index = 0;index < replica.length; index += 1) {
|
|
29
|
+
hash = Math.imul(hash, 31) + replica.charCodeAt(index) | 0;
|
|
30
|
+
}
|
|
31
|
+
return hash >>> 0;
|
|
32
|
+
};
|
|
33
|
+
var docFrom = (replica, initial) => {
|
|
34
|
+
const doc = new Y.Doc;
|
|
35
|
+
doc.clientID = clientIdFor(replica);
|
|
36
|
+
if (initial !== undefined) {
|
|
37
|
+
apply(doc, initial);
|
|
38
|
+
}
|
|
39
|
+
return doc;
|
|
40
|
+
};
|
|
41
|
+
var reconcile = (text, next) => {
|
|
42
|
+
const current = text.toString();
|
|
43
|
+
if (current === next) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let prefix = 0;
|
|
47
|
+
const maxPrefix = Math.min(current.length, next.length);
|
|
48
|
+
while (prefix < maxPrefix && current[prefix] === next[prefix]) {
|
|
49
|
+
prefix += 1;
|
|
50
|
+
}
|
|
51
|
+
let suffix = 0;
|
|
52
|
+
while (suffix < maxPrefix - prefix && current[current.length - 1 - suffix] === next[next.length - 1 - suffix]) {
|
|
53
|
+
suffix += 1;
|
|
54
|
+
}
|
|
55
|
+
const removed = current.length - prefix - suffix;
|
|
56
|
+
const inserted = next.slice(prefix, next.length - suffix);
|
|
57
|
+
if (removed > 0) {
|
|
58
|
+
text.delete(prefix, removed);
|
|
59
|
+
}
|
|
60
|
+
if (inserted.length > 0) {
|
|
61
|
+
text.insert(prefix, inserted);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var createYjsText = (replica, initial) => {
|
|
65
|
+
const doc = docFrom(replica, initial);
|
|
66
|
+
const text = doc.getText(TEXT_KEY);
|
|
67
|
+
return {
|
|
68
|
+
merge: (state) => apply(doc, state),
|
|
69
|
+
setText: (next) => doc.transact(() => reconcile(text, next)),
|
|
70
|
+
state: () => encode(doc),
|
|
71
|
+
text: () => text.toString()
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
var yjsText = {
|
|
75
|
+
create: createYjsText,
|
|
76
|
+
empty: () => encode(new Y.Doc),
|
|
77
|
+
merge: (a, b) => {
|
|
78
|
+
const doc = new Y.Doc;
|
|
79
|
+
apply(doc, a);
|
|
80
|
+
apply(doc, b);
|
|
81
|
+
return encode(doc);
|
|
82
|
+
},
|
|
83
|
+
textOf: (state) => {
|
|
84
|
+
const doc = new Y.Doc;
|
|
85
|
+
apply(doc, state);
|
|
86
|
+
return doc.getText(TEXT_KEY).toString();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
export {
|
|
90
|
+
yjsText,
|
|
91
|
+
createYjsText
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
//# debugId=B68ED6342B46BD1064756E2164756E21
|
|
95
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * A Yjs-backed collaborative-text CRDT for `@absolutejs/sync`.\n *\n * `@absolutejs/sync` ships a zero-dependency RGA text CRDT (`rgaText`) that is\n * fine for offline-merge and moderate collaboration. Yjs is the community staple\n * for production-scale collaborative text — it solves the real footguns (tombstone\n * growth, efficient deltas, interleaving). This adapter wraps Yjs behind the exact\n * same `CrdtText` / `TextCrdtAdapter` contract, so swapping `rgaText` for `yjsText`\n * needs no other change at the call site.\n *\n * The serialized state is a **base64 string** of a Yjs update, so it stays\n * JSON-safe for transport over the sync engine's change feed and storage as a row\n * field. Merges are commutative/associative/idempotent (Yjs guarantees it), so a\n * merge-on-write mutation combines concurrent edits without clobbering.\n */\nimport type { CrdtText, TextCrdtAdapter } from '@absolutejs/sync/crdt';\nimport * as Y from 'yjs';\n\nconst TEXT_KEY = 'text';\n\n// Isomorphic base64 (browser + Bun both expose btoa/atob), so the adapter runs\n// unchanged on the client (live editing) and the server (merge-on-write).\nconst toBase64 = (bytes: Uint8Array): string => {\n\tlet binary = '';\n\tfor (const byte of bytes) {\n\t\tbinary += String.fromCharCode(byte);\n\t}\n\n\treturn btoa(binary);\n};\n\nconst fromBase64 = (base64: string): Uint8Array => {\n\tconst binary = atob(base64);\n\tconst bytes = new Uint8Array(binary.length);\n\tfor (let index = 0; index < binary.length; index += 1) {\n\t\tbytes[index] = binary.charCodeAt(index);\n\t}\n\n\treturn bytes;\n};\n\nconst encode = (doc: Y.Doc): string => toBase64(Y.encodeStateAsUpdate(doc));\n\nconst apply = (doc: Y.Doc, state: string) => {\n\tif (state.length > 0) {\n\t\tY.applyUpdate(doc, fromBase64(state));\n\t}\n};\n\n// A stable, deterministic Yjs clientID per replica id (Yjs needs distinct\n// clients to keep distinct identities; deriving it from `replica` also makes\n// merges reproducible instead of depending on a random per-instance id).\nconst clientIdFor = (replica: string): number => {\n\tlet hash = 0;\n\tfor (let index = 0; index < replica.length; index += 1) {\n\t\thash = (Math.imul(hash, 31) + replica.charCodeAt(index)) | 0;\n\t}\n\n\treturn hash >>> 0;\n};\n\nconst docFrom = (replica: string, initial?: string): Y.Doc => {\n\tconst doc = new Y.Doc();\n\tdoc.clientID = clientIdFor(replica);\n\tif (initial !== undefined) {\n\t\tapply(doc, initial);\n\t}\n\n\treturn doc;\n};\n\n/** Reconcile `text` to `next` by editing only the changed middle (keep the\n * common prefix/suffix), so two replicas editing different spots merge. */\nconst reconcile = (text: Y.Text, next: string) => {\n\tconst current = text.toString();\n\tif (current === next) {\n\t\treturn;\n\t}\n\tlet prefix = 0;\n\tconst maxPrefix = Math.min(current.length, next.length);\n\twhile (prefix < maxPrefix && current[prefix] === next[prefix]) {\n\t\tprefix += 1;\n\t}\n\tlet suffix = 0;\n\twhile (\n\t\tsuffix < maxPrefix - prefix &&\n\t\tcurrent[current.length - 1 - suffix] === next[next.length - 1 - suffix]\n\t) {\n\t\tsuffix += 1;\n\t}\n\tconst removed = current.length - prefix - suffix;\n\tconst inserted = next.slice(prefix, next.length - suffix);\n\tif (removed > 0) {\n\t\ttext.delete(prefix, removed);\n\t}\n\tif (inserted.length > 0) {\n\t\ttext.insert(prefix, inserted);\n\t}\n};\n\n/** Create a live Yjs-backed collaborative-text doc for `replica`. */\nexport const createYjsText = (\n\treplica: string,\n\tinitial?: string\n): CrdtText<string> => {\n\tconst doc = docFrom(replica, initial);\n\tconst text = doc.getText(TEXT_KEY);\n\n\treturn {\n\t\tmerge: (state) => apply(doc, state),\n\t\tsetText: (next) => doc.transact(() => reconcile(text, next)),\n\t\tstate: () => encode(doc),\n\t\ttext: () => text.toString()\n\t};\n};\n\n/**\n * The Yjs collaborative-text backend as a {@link TextCrdtAdapter}. Drop-in for\n * the first-party `rgaText`: `create` mints a live doc, `merge` combines two\n * persisted states server-side, `empty`/`textOf` are conveniences.\n */\nexport const yjsText: TextCrdtAdapter<string> = {\n\tcreate: createYjsText,\n\tempty: () => encode(new Y.Doc()),\n\tmerge: (a, b) => {\n\t\tconst doc = new Y.Doc();\n\t\tapply(doc, a);\n\t\tapply(doc, b);\n\n\t\treturn encode(doc);\n\t},\n\ttextOf: (state) => {\n\t\tconst doc = new Y.Doc();\n\t\tapply(doc, state);\n\n\t\treturn doc.getText(TEXT_KEY).toString();\n\t}\n};\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "B68ED6342B46BD1064756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@absolutejs/sync-yjs",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Yjs-backed collaborative-text CRDT adapter for @absolutejs/sync",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/absolutejs/sync-adapters.git",
|
|
8
|
+
"directory": "yjs"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "CC BY-NC 4.0",
|
|
15
|
+
"author": "Alex Kahn",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"absolutejs",
|
|
21
|
+
"sync",
|
|
22
|
+
"crdt",
|
|
23
|
+
"yjs",
|
|
24
|
+
"collaborative",
|
|
25
|
+
"multiplayer"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=bun --external @absolutejs/sync --external yjs && tsc --project tsconfig.build.json",
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"format": "prettier --write \"./**/*.{ts,json,md}\"",
|
|
32
|
+
"release": "bun run format && bun run build && bun publish"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"yjs": "^13.6.30"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@absolutejs/sync": ">= 0.10.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@absolutejs/sync": "^0.10.0",
|
|
42
|
+
"@types/bun": "1.3.14",
|
|
43
|
+
"prettier": "3.5.3",
|
|
44
|
+
"typescript": "5.8.3"
|
|
45
|
+
},
|
|
46
|
+
"exports": {
|
|
47
|
+
".": {
|
|
48
|
+
"types": "./dist/index.d.ts",
|
|
49
|
+
"import": "./dist/index.js",
|
|
50
|
+
"default": "./dist/index.js"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"dist",
|
|
55
|
+
"README.md"
|
|
56
|
+
]
|
|
57
|
+
}
|