npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

effect-yjs

v0.1.0

Published

Schema-first integration between Effect Schema and Yjs — type-safe, validated, reactive collaborative documents

Downloads

220

Readme

effect-yjs

Schema-first integration between Effect Schema and Yjs. Define your data model as an Effect Schema, get a fully typed, validated, reactive Yjs document.

Why

Yjs is the backbone of many collaborative applications, but it offers no type safety, no runtime validation, and no schema-level composition. You work with raw bytes and imperative mutations.

Effect Schema stores its schema as a traversable AST. effect-yjs walks that AST and builds a Yjs document tree automatically — every S.Struct becomes a Y.Map, every S.Array becomes a Y.Array, every YText becomes a Y.Text. You get validated reads and writes, self-contained lenses for precise access, and reactive atoms powered by effect-atom.

Install

pnpm add effect-yjs effect yjs y-protocols @effect-atom/atom

Quick Start

import * as S from "effect/Schema"
import { YDocument, YText } from "effect-yjs"

// Define your schema
const AppSchema = S.Struct({
  shapes: S.Record({
    key: S.String,
    value: S.Struct({ x: S.Number, y: S.Number, label: YText }),
  }),
  metadata: S.Struct({ title: YText, version: S.Number }),
  tags: S.Array(S.String),
})

// Create a typed Yjs document
const { doc, root } = YDocument.make(AppSchema)

// Write with validation
root.focus("metadata").focus("version").unsafeSet(1)
root.focus("tags").unsafeSet(["drawing", "v1"])

// Focus deep into records
root.focus("shapes").focus("shape-1").focus("x").unsafeSet(100)
root.focus("shapes").focus("shape-1").focus("y").unsafeSet(200)

// Read back
root.focus("shapes").focus("shape-1").focus("x").unsafeGet() // 100

// Use Y.Text directly for collaborative rich text
const title = root.focus("metadata").focus("title").unsafeGet()
title.insert(0, "My Drawing")

Core Concepts

Schema-to-Yjs Mapping

Every Effect Schema type maps deterministically to a Yjs structure:

| Effect Schema | Yjs Type | Notes | |---|---|---| | S.Struct({...}) | Y.Map | Each field is a key in the map | | S.Record({key, value}) | Y.Map | Dynamic keys, homogeneous values | | S.Array(item) | Y.Array | Ordered collection | | YText | Y.Text | Collaborative rich text | | Primitives | Plain values | Stored directly in parent |

Structs are pre-populated at document creation time — nested shared types are created immediately so the structure is always navigable. Records and Arrays start empty; entries are created dynamically when you write to them.

YLens — Type-Safe Accessors

YLens<T> is the primary way to interact with data. Lenses are created via .focus(), carry their own root reference internally, and compose via further .focus() calls.

const shapeLens = root.focus("shapes").focus("shape-1")
const xLens = shapeLens.focus("x")

xLens.unsafeSet(99)
xLens.unsafeGet() // 99

A YLens<T> depends only on T in its type signature — the root reference is internal. Components receive a lens and can read, write, and subscribe without knowing the document structure:

function VertexEditor({ position }: { position: YLens<{ x: number; y: number }> }) {
  const pos = useAtom(position.atom())
  const onDrag = (p: { x: number; y: number }) => position.unsafeSet(p)
  // ...
}

Validation

Writes validate through Effect Schema before mutating Yjs:

// Throwing API — for programmer errors
root.focus("count").unsafeSet("not a number") // throws TypedYValidationError

// Effect API — for expected failures
const result = root.focus("count").set(value) // Effect<void, ParseError>

// Validated reads — validate data from untrusted peers
const count = root.focus("count").get() // Effect<number, ParseError>

Reactive Atoms

Every lens can produce an effect-atom Atom that updates when the underlying Yjs data changes:

const countAtom = root.focus("count").atom()        // Atom<number | undefined>
const posAtom = root.focus("position").atom()        // Atom<{x, y} | undefined>

// Derived atoms compose naturally
const doubled = Atom.map(countAtom, (c) => (c ?? 0) * 2)

Granularity comes from Yjs's per-shared-type observation — changing a shape's x coordinate only triggers atoms subscribed to that specific path.

Transactions

Batch multiple writes into a single Yjs transaction:

YDocument.transact(root, () => {
  root.focus("metadata").focus("version").unsafeSet(2)
  root.focus("tags").unsafeSet(["updated"])
})

Binding to Existing Documents

Connect to a Y.Doc from any Yjs provider (WebSocket, WebRTC, etc.):

import * as Y from "yjs"
import { WebsocketProvider } from "y-websocket"

const ydoc = new Y.Doc()
new WebsocketProvider("ws://localhost:1234", "my-room", ydoc)

const root = YDocument.bind(AppSchema, ydoc)
root.focus("metadata").focus("version").unsafeGet() // reads synced data

Schema Composability

Compose schemas using plain Effect Schema mechanics — no special API:

// Field-level: spread shared field groups
const Position = { x: S.Number, y: S.Number }
const Dimensions = { width: S.Number, height: S.Number }

const Rectangle = S.Struct({
  id: S.String,
  ...Position,
  ...Dimensions,
  label: YText,
})

// Document-level: compose modules into a top-level document
const ShapesFragment = S.Record({ key: S.String, value: Rectangle })
const ChatFragment = S.Array(S.Struct({ author: S.String, message: YText }))

const AppDocument = S.Struct({
  shapes: ShapesFragment,
  chat: ChatFragment,
})

Awareness — Typed Presence & Ephemeral State

The Yjs Awareness protocol handles ephemeral data like cursor positions, user info, and selections. Unlike document data, awareness state is not persisted — it's automatically removed when a client goes offline.

effect-yjs wraps the Awareness CRDT with the same schema-validated lens API used for document data:

import * as S from "effect/Schema"
import { YAwareness } from "effect-yjs"

const PresenceSchema = S.Struct({
  user: S.Struct({ name: S.String, color: S.String }),
  cursor: S.Struct({ x: S.Number, y: S.Number }),
})

// Bind to a provider's awareness instance
const handle = YAwareness.bind(PresenceSchema, provider.awareness)

// Or create a standalone awareness from a Y.Doc
const handle2 = YAwareness.make(PresenceSchema, doc)

Local State — Read-Write Lens

Your own awareness state is a full YLens<A> with focus(), validation, and atoms:

handle.local.unsafeSet({
  user: { name: "Alice", color: "#ff0000" },
  cursor: { x: 0, y: 0 },
})

// Focus into nested fields
handle.local.focus("cursor").focus("x").unsafeSet(100)
handle.local.focus("user").focus("name").unsafeGet() // "Alice"

// Reactive atom on a specific field
const cursorAtom = handle.local.focus("cursor").atom()

// Go offline
handle.unsafeClearLocal()

Remote State — Read-Only Lens

Other clients' states are accessible via ReadonlyYLens<A> — same focus()/unsafeGet()/atom() API, but no set():

const bobCursor = handle.remote(bobClientId).focus("cursor").unsafeGet()
const bobNameAtom = handle.remote(bobClientId).focus("user").focus("name").atom()

Presence Detection & Atom Family

Track who's online with clientIdsAtom() (backed by the 'update' event for heartbeat-based offline detection), and get stable per-client atom references via Atom.family:

// Reactive list of connected client IDs
const onlineIds = handle.clientIdsAtom()

// Stable atom per client — value becomes undefined when they go offline
const bobStateAtom = handle.remoteStateFamily(bobClientId)

// Full states map
const allStates = handle.statesAtom() // Atom<ReadonlyMap<number, PresenceState>>

API Reference

YDocument.make(schema)

Creates a new Y.Doc and typed root from an Effect Schema. Returns { doc, root }.

YDocument.bind(schema, doc)

Binds a typed root to an existing Y.Doc.

YDocument.transact(root, fn)

Wraps operations in a single Yjs transaction.

YText

Schema marker for collaborative text fields. Maps to Y.Text in the Yjs document. Access the Y.Text instance via .unsafeGet() and manipulate it using the standard Yjs Text API.

YLens<T>

| Method | Returns | Description | |--------|---------|-------------| | focus(key) | YLens<T[K]> | Focus on a child field or record entry | | unsafeGet() | T \| undefined | Read the current value | | unsafeSet(value) | void | Write with validation (throws on failure) | | set(value) | Effect<void, ParseError> | Write with validation (Effect-based) | | get() | Effect<T, ParseError> | Read with validation | | atom() | Atom<T \| undefined> | Reactive atom that updates on Yjs changes |

ReadonlyYLens<T>

Read-only variant of YLens<T>. Used for remote awareness states.

| Method | Returns | Description | |--------|---------|-------------| | focus(key) | ReadonlyYLens<T[K]> | Focus on a child field | | unsafeGet() | T \| undefined | Read the current value | | get() | Effect<T, ParseError> | Read with validation | | atom() | Atom<T \| undefined> | Reactive atom that updates on changes |

YAwareness.make(schema, doc)

Creates a new Awareness instance (from y-protocols) bound to a Y.Doc and returns a YAwarenessHandle<A>.

YAwareness.bind(schema, awareness)

Binds a schema to an existing Awareness instance (e.g., from a provider like y-websocket).

YAwarenessHandle<A>

| Property / Method | Returns | Description | |-------------------|---------|-------------| | local | YLens<A> | Read-write lens for the local client's state | | remote(clientId) | ReadonlyYLens<A> | Read-only lens for a remote client's state | | clearLocal() | Effect<void> | Set local state to null (signals offline) | | unsafeClearLocal() | void | Set local state to null (throws on failure) | | unsafeGetStates() | ReadonlyMap<number, A> | All clients' states (unvalidated) | | getStates() | Effect<ReadonlyMap<number, A>, ParseError> | All states with validation | | statesAtom() | Atom<ReadonlyMap<number, A>> | Reactive atom for all states | | clientIdsAtom() | Atom<ReadonlyArray<number>> | Reactive presence tracking (uses 'update' event) | | remoteStateFamily | (id: number) => Atom<A \| undefined> | Stable per-client atoms via Atom.family |

Limitations

  • Discriminated unions are detected and rejected at document creation time with a clear error. Support may be added in the future when conflict resolution semantics are well-defined.
  • Y.XmlFragment / Y.XmlElement are not supported.
  • Schema migrations between versions are not handled — this is a separate concern.
  • Undo/Redo integration with Y.UndoManager is not yet wired into the lens API.
  • Persistence adapters — users wire persistence at the Y.Doc level.
  • Full optics — prisms, traversals, isos deferred to a future version.

Development

pnpm install
pnpm test      # run tests
pnpm check     # type check
pnpm build     # build for distribution