Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Yjs Electric provider example #1508

Merged
merged 47 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c5f562b
Yjs-codemirror base example
balegas Aug 9, 2024
87fbdb9
Changed schema
balegas Aug 9, 2024
3a5003a
formatted
balegas Aug 9, 2024
8bb837e
Improvements
balegas Aug 10, 2024
8654612
Code cleanup
balegas Aug 13, 2024
ddeaccd
Compaction endpoint
balegas Aug 13, 2024
344d1cc
Moved project
balegas Aug 13, 2024
77a9a4f
revert wrong change
balegas Aug 13, 2024
bbad084
Added idb persistence. Disabled until handling multitab and resume fr…
balegas Aug 13, 2024
3ae2dc3
Schema change
balegas Aug 14, 2024
06a7c53
avoid loading yjs in server
balegas Aug 14, 2024
d5b7d6b
Compaction on the server and cleanup
balegas Aug 14, 2024
cc91d27
Added Nextjs ESLint
balegas Aug 14, 2024
64e5777
Cherry picked broadcast impl from y-websocket
balegas Aug 14, 2024
1a657c5
Added connection pool
balegas Aug 15, 2024
68e41a1
Updated codemirror deps
balegas Aug 19, 2024
9ae4130
use shape resume as ShapeOptions instead of building the URL
balegas Aug 19, 2024
6f9b19f
Removed all Electric swag
balegas Aug 19, 2024
3e3b327
Further cleanup
balegas Aug 23, 2024
3213f41
Fixes persistence issue
balegas Aug 26, 2024
180615a
maitnain server-side ydoc for rendering client page with initial stat…
balegas Aug 28, 2024
c0e4563
Changes after Electric breaking changes
balegas Nov 9, 2024
9c4c8a1
use bytea instead of string
balegas Nov 11, 2024
aecdc00
Improved parsing
balegas Nov 12, 2024
cdad781
Removed code related with persistence and broadcasting
balegas Nov 12, 2024
e5c03bd
Cleanup
balegas Nov 12, 2024
6f11529
Fixed dynamic rendering issue
balegas Nov 12, 2024
f9faa58
added deployment stack and required changes
balegas Nov 29, 2024
9cea54c
Converted provider to typescript
balegas Dec 2, 2024
718f9a8
removed unused logo
balegas Dec 3, 2024
9c72588
sst config with lambda/neon and standalone servers
balegas Dec 3, 2024
6d943f9
emit sync event when up-to-date
balegas Dec 3, 2024
f8a3e15
Missing awaits
balegas Dec 4, 2024
1a5da59
SST looking good
balegas Dec 5, 2024
fbec8d6
Open a single connection instead of a pool on write; fixes to sst
balegas Dec 5, 2024
985ad84
Hybrid implementation with server and serverless
balegas Dec 6, 2024
824ff8f
Fix writes in dev mode
balegas Dec 8, 2024
a2f2ecb
Added idx db persistence. Needs more work
balegas Dec 8, 2024
9aa5e4c
Addressed review; improvements to awareness handling.
balegas Dec 8, 2024
03844c6
Lazy decode and ignore late awareness messages
balegas Dec 8, 2024
c631133
lint; removed experimental build options; persistence bug
balegas Dec 9, 2024
34786d9
some cleanup
balegas Dec 9, 2024
ac4f0fc
last few bits
balegas Dec 9, 2024
dc4e5aa
final domain
balegas Dec 9, 2024
6bf8121
Move to folder
balegas Dec 9, 2024
8db19a3
changes after rebase
balegas Dec 9, 2024
88b4920
rebase conflict
balegas Dec 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

# sst
.sst
2 changes: 1 addition & 1 deletion examples/nextjs-example/app/shape-proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ export async function GET(request: Request) {
})
}
return resp
}
}
2 changes: 2 additions & 0 deletions examples/yjs/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/build/**
sst.config.ts
44 changes: 44 additions & 0 deletions examples/yjs/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
`eslint:recommended`,
`plugin:@typescript-eslint/recommended`,
`plugin:prettier/recommended`,
'plugin:@next/next/recommended',
],
parserOptions: {
ecmaVersion: 2022,
requireConfigFile: false,
sourceType: `module`,
ecmaFeatures: {
jsx: true,
},
},
parser: `@typescript-eslint/parser`,
plugins: [`prettier`],
rules: {
quotes: [`error`, `backtick`],
"no-unused-vars": `off`,
"@typescript-eslint/no-unused-vars": [
`error`,
{
argsIgnorePattern: `^_`,
varsIgnorePattern: `^_`,
caughtErrorsIgnorePattern: `^_`,
},
],
"@next/next/no-img-element": "off"
},
ignorePatterns: [
`**/node_modules/**`,
`**/dist/**`,
`tsup.config.ts`,
`vitest.config.ts`,
`.eslintrc.js`,
`**/*.css`,
],
};
10 changes: 10 additions & 0 deletions examples/yjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
dist
.env.local

# Turborepo
.turbo

# next.js
/.next/
/out/
next-env.d.ts
5 changes: 5 additions & 0 deletions examples/yjs/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"semi": false,
"tabWidth": 2
}
31 changes: 31 additions & 0 deletions examples/yjs/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
FROM node:lts-alpine AS base

# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app

RUN npm install -g pnpm

COPY pnpm-*.yaml ./
COPY package.json ./
COPY tsconfig.build.json ./
COPY packages/typescript-client packages/typescript-client/
COPY packages/react-hooks packages/react-hooks/
COPY examples/yjs-provider/ examples/yjs-provider/

# Install dependencies
RUN pnpm install --frozen-lockfile
RUN pnpm run -r build


# Need to make production image more clean
FROM node:lts-alpine AS prod
WORKDIR /app

ENV NODE_ENV=production
COPY --from=deps /app/ ./

WORKDIR /app/examples/yjs-provider/

EXPOSE 3000
CMD ["npm", "run", "start"]
28 changes: 28 additions & 0 deletions examples/yjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Yjs Electric provider

This example showcases a multiplayer [Codemirror](https://codemirror.net/) editor with [YJS](https://github.com/yjs/yjs) and [ElectricSQL](https://electric-sql.com/). All data is synchronized through [Postgres](https://www.postgresql.org/), eliminating the need for additional real-time infrastructure.

Y-Electric is a [YJS connection provider](https://docs.yjs.dev/ecosystem/connection-provider) that comes with offline support, integrates with [database providers](https://docs.yjs.dev/ecosystem/database-provider) and also handles [Presence/Awareness](https://docs.yjs.dev/api/about-awareness) data. It works with the entire YJS ecosystem and with you existing apps too!

> We're releasing The Y-Electric backend as a package soon!

## How to run

Make sure you've installed all dependencies for the monorepo and built the packages (from the monorepo root directory):

```shell
pnpm install
pnpm run -r build
```

Start the docker containers (in this directory):

```shell
pnpm backend:up
```

Start the dev server:

```shell
pnpm dev
```
73 changes: 73 additions & 0 deletions examples/yjs/app/api/operation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Pool } from "pg"
import { NextResponse } from "next/server"
import { neon } from "@neondatabase/serverless"

// hybrid implementation for connection pool and serverless with neon

const connectionString =
process.env.NEON_DATABASE_URL ||
process.env.DATABASE_URL ||
`postgresql://postgres:password@localhost:54321/electric`

const sql = process.env.NEON_DATABASE_URL ? neon(connectionString) : undefined

const pool = !process.env.NEON_DATABASE_URL
? new Pool({ connectionString })
: undefined

export async function POST(request: Request) {
try {
const { room, op, clientId } = await getRequestParams(request)
if (!clientId) {
await saveOperation(room, op)
} else {
await saveAwarenessOperation(room, op, clientId)
}
return NextResponse.json({})
} catch (e) {
const resp = e instanceof Error ? e.message : e
return NextResponse.json(resp, { status: 400 })
}
}

async function saveOperation(room: string, op: string) {
const q = `INSERT INTO ydoc_operations (room, op) VALUES ($1, decode($2, 'base64'))`
const params = [room, op]
await runQuery(q, params)
}

async function saveAwarenessOperation(
room: string,
op: string,
clientId: string
) {
const q = `INSERT INTO ydoc_awareness (room, clientId, op) VALUES ($1, $2, decode($3, 'base64'))
ON CONFLICT (clientId, room)
DO UPDATE SET op = decode($3, 'base64'), updated = now()`
const params = [room, clientId, op]
await runQuery(q, params)
}

async function getRequestParams(
request: Request
): Promise<{ room: string; op: string; clientId?: string }> {
const { room, op, clientId } = await request.json()
if (!room) {
throw new Error(`'room' is required`)
}
if (!op) {
throw new Error(`'op' is required`)
}

return { room, op, clientId }
}

async function runQuery(q: string, params: string[]) {
if (pool) {
await pool.query(q, params)
} else if (sql) {
await sql(q, params)
} else {
throw new Error(`No database driver provided`)
}
}
115 changes: 115 additions & 0 deletions examples/yjs/app/electric-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client"

import { useEffect, useRef, useState } from "react"

import * as Y from "yjs"
import { yCollab, yUndoManagerKeymap } from "y-codemirror.next"
import { ElectricProvider } from "./y-electric"
import { Awareness } from "y-protocols/awareness"

import { EditorState } from "@codemirror/state"
import { EditorView, basicSetup } from "codemirror"
import { keymap } from "@codemirror/view"
import { javascript } from "@codemirror/lang-javascript"

import * as random from "lib0/random"
import { IndexeddbPersistence } from "y-indexeddb"

const room = `electric-demo`

const usercolors = [
{ color: `#30bced`, light: `#30bced33` },
{ color: `#6eeb83`, light: `#6eeb8333` },
{ color: `#ffbc42`, light: `#ffbc4233` },
{ color: `#ecd444`, light: `#ecd44433` },
{ color: `#ee6352`, light: `#ee635233` },
{ color: `#9ac2c9`, light: `#9ac2c933` },
]

const userColor = usercolors[random.uint32() % usercolors.length]
const ydoc = new Y.Doc()

const isServer = typeof window === `undefined`

const awareness = !isServer ? new Awareness(ydoc) : undefined
awareness?.setLocalStateField(`user`, {
name: userColor.color,
color: userColor.color,
colorLight: userColor.light,
})

const network = !isServer
? new ElectricProvider(
new URL(`/shape-proxy`, window?.location.origin).href,
room,
ydoc,
{
connect: true,
awareness,
persistence: new IndexeddbPersistence(room, ydoc),
}
)
: undefined

export default function ElectricEditor() {
const editor = useRef(null)

const [connectivityStatus, setConnectivityStatus] = useState<
`connected` | `disconnected`
>(`connected`)

const toggle = () => {
if (!network) {
return
}
const toggleStatus =
connectivityStatus === `connected` ? `disconnected` : `connected`
setConnectivityStatus(toggleStatus)
toggleStatus === `connected` ? network.connect() : network.disconnect()
}

useEffect(() => {
if (typeof window === `undefined`) {
return
}

const ytext = ydoc.getText(room)

const state = EditorState.create({
doc: ytext.toString(),
extensions: [
keymap.of([...yUndoManagerKeymap]),
basicSetup,
javascript(),
EditorView.lineWrapping,
yCollab(ytext, awareness),
],
})

const view = new EditorView({ state, parent: editor.current ?? undefined })

return () => view.destroy()
})

return (
<div>
<form action={async () => toggle()}>
<button type="submit" className="button" name="intent" value="add">
{connectivityStatus}
</button>
</form>
<p>
This is a demo of <a href="https://github.com/yjs/yjs">Yjs</a> using
{` `}
{` `}
<a href="https://github.com/electric-sql/electric">Electric</a> for
syncing.
</p>
<p>
The content of this editor is shared with every client that visits this
domain.
</p>
<div ref={editor}></div>
</div>
)
}
16 changes: 16 additions & 0 deletions examples/yjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const metadata = {
title: `Yjs <> Electric`,
description: `Yjs synching with Electric`,
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
8 changes: 8 additions & 0 deletions examples/yjs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use server"

import React from "react"
import ElectricEditor from "./electric-editor"

const Page = async () => <ElectricEditor />

export default Page
Loading
Loading