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.3.0

Published

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

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 protocol with a schema-validated, unidirectional broadcast API — you broadcast local state to peers and read peer state via reactive atoms:

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)

Broadcasting Local State

Awareness is a write-only broadcast channel. Your source of truth lives in local atoms; awareness broadcasts that state to peers:

// Broadcast full state
handle.broadcast({
  user: { name: "Alice", color: "#ff0000" },
  cursor: { x: 0, y: 0 },
})

// Broadcast a single field (partial update, merges with existing state)
handle.broadcastField("cursor", { x: 100, y: 200 })

// Signal offline to peers
handle.clearLocal()

All broadcasts are validated against the schema — invalid data throws TypedYValidationError.

Reading Peer State

Other clients' states are accessible as reactive atoms:

// Single peer's state — undefined when offline or invalid
const bobState = handle.peer(bobClientId) // Atom<PresenceState | undefined>

// All peers' validated states (invalid peers silently excluded)
const allPeers = handle.peers // Atom<ReadonlyMap<number, PresenceState>>

// Connected client IDs (uses heartbeat-based offline detection)
const onlineIds = handle.clientIds // Atom<ReadonlyArray<number>>

peer() returns stable atom references via Atom.family — calling handle.peer(id) twice returns the same atom.

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 |

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 | |-------------------|---------|-------------| | broadcast(state) | void | Validate and broadcast full state to peers | | broadcastField(field, value) | void | Validate and broadcast a single field (merges with existing) | | clearLocal() | void | Set local state to null (signals offline) | | peers | Atom<ReadonlyMap<number, A>> | Reactive atom of all peers' validated states | | peer(clientId) | Atom<A \| undefined> | Reactive atom for a single peer (stable via Atom.family) | | clientIds | Atom<ReadonlyArray<number>> | Reactive presence tracking (uses 'update' event) | | awareness | AwarenessLike | Raw awareness instance (escape hatch for providers) | | clientID | number | This client's ID |

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