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

@syncular/server-workers

v0.15.45

Published

Cloudflare Workers adapter for the Syncular sync server

Downloads

9,666

Readme

@syncular/server-workers

The Cloudflare Workers entry for the Syncular sync server. It wires the runtime-neutral server core to Workers bindings — D1 for storage, R2 for durable segment/blob bytes, secrets for signing and auth — behind a standard Workers module fetch handler.

This package is deliberately thin. @syncular/server-hono's createSyncularHono is already Workers-native (it routes with Hono, which runs unmodified on workerd, and speaks only Web Request/Response/ fetch/Web-Crypto). So the Workers lane is not a second adapter — it is the same HTTP handler wired to env bindings.

What it mounts

The HTTP binding (SPEC §1.1):

| Route | Method | Purpose | |---|---|---| | <mount>/sync | POST | Combined push+pull (§4, §6) | | <mount>/segments/{id} | GET | Bootstrap segment download (§5.5) | | <mount>/blobs/{id} | PUT | Blob upload, content-address verified (§5.9.3) | | <mount>/blobs/{id} | GET | Blob download, row-derived re-auth (§5.9.5) |

Every D1 /sync round that may push traverses a per-partition Durable Object queue. Pass coordinator for HTTP-only transport, or realtime to reuse the same namespace while also mounting GET <mount>/realtime (§8). WebSockets are optional; D1 write coordination is not. A plain stateless D1 writer fails closed before app-row mutation.

Usage

// src/worker.ts
import {
  D1ServerStorage,
  S3BlobStore,
  S3SegmentStore,
  s3PresignedBlobUrls,
  s3PresignedUrls,
  type SyncServerConfig,
} from '@syncular/server';
import { createWorkersFetchHandler } from '@syncular/server-workers';
import { schema } from './syncular.generated'; // typegen output

interface Env {
  DB: D1Database; // wrangler.toml [[d1_databases]] binding = "DB"
  SYNC_COORDINATOR: DurableObjectNamespace<SyncularRealtimeDO>;
  R2_ACCOUNT_ID: string;
  R2_ACCESS_KEY_ID: string;
  R2_SECRET_ACCESS_KEY: string;
  SYNC_JWT_SECRET: string;
}

function syncConfig(env: Env): SyncServerConfig {
  const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
  const r2 = {
    endpoint,
    region: 'auto' as const,
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
  };
  const segments = new S3SegmentStore({ ...r2, bucket: 'syncular-segments' });
  // Durable attachment bytes (§5.9) in R2 — no TTL, no lifecycle rule; the
  // host schedules `sweepOrphanBlobs` for GC (see the server README runbook).
  const blobs = new S3BlobStore({ ...r2, bucket: 'syncular-blobs' });
  return {
    schema,
    storage: new D1ServerStorage(env.DB),
    segments,
    blobs,
    // §5.4 delegated presign: R2 mints the segment URL directly.
    signedUrls: s3PresignedUrls(segments, { ttlSeconds: 900 }),
    // §5.9.5 delegated presign for blob downloads (issued post-authz).
    blobSignedUrls: s3PresignedBlobUrls(blobs, { ttlSeconds: 900 }),
    resolveScopes: (args) => resolveScopes(args, env),
    // No `sqliteImageBuilder`: Workers has no SQLite engine, so bit-2
    // clients are served the rows lane (§5.3 support floor).
  };
}

export default {
  fetch: createWorkersFetchHandler<Env>({
    config: (env) => ({
      config: syncConfig(env),
      authenticate: (request) => authenticate(request, env),
    }),
    coordinator: (env) => ({ namespace: env.SYNC_COORDINATOR }),
  }),
};

createWorkersFetchHandler(factory) builds the Hono app once per request from the factory and delegates. Building per request keeps the handler stateless (no module-global mutable server) — the Workers-correct posture, since each invocation may run on a fresh isolate. The coordinator forwards authenticated /sync bodies to the partition DO; segments and blob endpoints remain direct. The complete DO class and canonical config factory are shown under "Wiring".

See wrangler.toml.example for the binding config.

Schema migration (D1)

D1ServerStorage does not apply its DDL on construction (a cold request must never race a schema apply). Apply it once with wrangler. Generate the migration SQL from sqliteDdlStatements() (exported from @syncular/server) into a migrations/ file, then:

wrangler d1 create syncular
wrangler d1 migrations apply syncular

The schema is plain SQLite DDL (shared with bun:sqlite via sqlite-dialect.ts), so it is portable across the two SQLite-family storages.

Storage: D1 (D1ServerStorage)

D1 is SQLite over an async, statement-at-a-time API, so D1ServerStorage shares the schema and value codecs with SqliteServerStorage (the sqlite-dialect.ts module) and differs only in execution shape.

Transaction model. D1 has no interactive transaction — the only atomic primitive is db.batch([...]). The push handler reads first (conflict detection) then writes, so D1ServerStorage's transaction executes reads immediately (autocommit) and buffers writes, flushing them as one atomic db.batch() at commit() (the §6.4 all-or-nothing commit; a rejected op rolls back by never flushing). A read-your-own-writes overlay makes getRow see buffered writes of the same commit.

Concurrency posture. Every push, not only whole-commit validation, must serialize before operation reads and re-check idempotency under that boundary. createWorkersFetchHandler forwards /sync to one DO per authenticated partition; the host uses an explicit FIFO because Durable Object events can interleave at await. Cross-partition pushes still use different DOs.

A plain new D1ServerStorage(env.DB) fails closed before every push. Only an actual coordinator may construct new D1ServerStorage(env.DB, { pushApplySerialized: true }); never set that assertion in a stateless Worker. The deprecated commitValidationSerialized alias exists only for coordinated hosts upgrading from an older release and has the same every-push meaning.

Workers realtime — the Durable Object

The realtime channel (§8) needs a durable, stateful WebSocket host. On Workers that is a Durable Object: SyncularRealtimeHost (src/realtime-do.ts) hosts the RealtimeHub, uses WebSocket hibernation to drive the existing RealtimeSession, and reads/writes the same D1 binding as the HTTP handler.

Sharding: one DO per partition

The DO id is idFromName(partition), so all of a partition's sockets and its commit fan-out live in one DO with an explicit sync-round FIFO — which is also the per-partition write-serialization point the D1 storage wants (see "Concurrency posture"). Because the hub is the RealtimeNotifier (§8.2) inside the DO, a sync round landing over the socket fans its full delta to the partition's other sockets with no LISTEN/NOTIFY — writes and sockets are co-located.

One-partition-per-DO is the natural §8.2 fan-out boundary and the rung we ship. Many-partitions-per-shard (one DO fronting a bucket of low-traffic partitions, to amortize the DO floor) is a future tuning knob: the hub already keys every operation by partition, so a shard DO hosts one hub and routes by partition — no protocol change, only the id-derivation. Deferred until a cost/traffic signal asks for it.

Hibernation semantics

The DO uses the Hibernation API (state.acceptWebSocket(ws) + webSocketMessage/webSocketClose/webSocketError handlers), so idle connections do not pin the DO in memory or bill wall time — the cost story for realtime on Workers: an idle open socket is ~free, you pay for rounds and fan-out, not for connection wall time.

A RealtimeSession is in-memory only. The honest rule, as built:

  • Hibernation only happens between rounds. An in-flight sync round is an async generator draining over ws.send; while pending it holds the DO's event loop, so the DO cannot be evicted mid-round. (This is the same property the §8.7 "one round in flight" rule already relies on.)
  • On the first message after a wake, the socket carries a serialized attachment (ws.serializeAttachment — the minimal {clientId, actorId, partition} §8.1 identity, written at accept time) but no live session. The host rebuilds it via hub.connect(...), which reloads the registration list from the client record in D1 (exactly what a fresh upgrade does, §8.1). Rehydration is transparent to the client: it was greeted once at the real upgrade, so the rehydration hello is swallowed. Cursor and registrations are the durable truth in D1; nothing in-flight is lost because nothing in-flight can be hibernated.

So the serialized attachment is deliberately minimal — the three identity fields connect needs. Everything else is re-derived from D1, which is authoritative.

HTTP sync and fanout

Authenticated HTTP /sync rounds are forwarded into the same partition DO as socket rounds. Applied commits therefore fan out through the in-DO hub without a post-commit wake race. durableObjectRealtimeNotifier remains available for an external authoritative command host that already provides equally strong partition serialization and needs to wake the DO after its own commit; it is not a substitute for the /sync coordinator.

Wiring

createWorkersFetchHandler takes a { config, realtime } options object; the realtime factory resolves the DO namespace + the upgrade auth per request:

// src/worker.ts
import {
  createWorkersFetchHandler,
  D1ServerStorage,
  SyncularRealtimeHost,
  type RealtimeDOConfig,
} from '@syncular/server-workers';
import { DurableObject } from 'cloudflare:workers';
import type { RealtimeHubConfig } from '@syncular/server';
import { schema } from './syncular.generated';

interface Env {
  DB: D1Database;
  REALTIME: DurableObjectNamespace<SyncularRealtimeDO>;
}

const canonicalSyncConfig = (
  env: Env,
  storage: D1ServerStorage,
) => ({
    schema,
    storage,
    resolveScopes: (args) => resolveScopes(args, env),
    segments: makeSegments(env),
    blobs: makeBlobs(env),
    crdtMergers: makeCrdtMergers(env),
  } satisfies RealtimeHubConfig);

const realtimeDOConfig = (env: Env): RealtimeDOConfig => ({
  // One factory owns HTTP-forwarded and socket-round sync capabilities.
  syncConfig: (storage) => canonicalSyncConfig(env, storage),
});

// The DO class the runtime instantiates. It delegates to SyncularRealtimeHost;
// the platform bindings (DurableObjectState, WebSocket, D1Database) are the
// real cloudflare:workers types here.
export class SyncularRealtimeDO extends DurableObject<Env> {
  #host = new SyncularRealtimeHost(this.ctx, this.env.DB, realtimeDOConfig(this.env));
  fetch(request: Request) { return this.#host.fetch(request); }
  webSocketMessage(ws: WebSocket, msg: ArrayBuffer | string) {
    return this.#host.webSocketMessage(ws, msg);
  }
  webSocketClose(ws: WebSocket) { return this.#host.webSocketClose(ws); }
  webSocketError(ws: WebSocket) { return this.#host.webSocketError(ws); }
}

export default {
  fetch: createWorkersFetchHandler<Env>({
    config: (env) => ({
      config: {
        ...canonicalSyncConfig(env, new D1ServerStorage(env.DB)),
      },
      authenticate: (request) => authenticate(request, env),
    }),
    realtime: (env) => ({
      namespace: env.REALTIME,
      // This namespace also coordinates authenticated HTTP /sync rounds.
      // The realtime-channel auth seam resolves
      // the §8 upgrade identity; the `partition` selects the DO. Return
      // undefined to reject with a 401.
      authenticate: (request) => authenticateRealtime(request, env),
    }),
  }),
};

The platform surface (DurableObjectState, WebSocket, D1Database, DurableObjectNamespace) is typed structurally in realtime-do.ts, so the package takes no @cloudflare/workers-types dependency — the same posture d1-storage.ts takes for the D1 API. Your Worker's own types come from @cloudflare/workers-types / cloudflare:workers; they are structurally compatible with the host's declared subset.

Add the DO binding + migration to wrangler.toml (see wrangler.toml.example).

Real-workerd smoke: a manual recipe (why no automated lane)

The hermetic tests (test/realtime-do.test.ts) drive the real RealtimeSession/RealtimeHub/D1ServerStorage code through the real DO class over a DO double + the D1 double + the reference codec — connect → hello → round-over-socket → delta-on-commit → ack, hibernation rehydration, the HTTP-forwarded push fan-out, and presence. Because the DO is a deployment adapter (same wire, same handler), that is the conformance bar.

An automated wrangler dev smoke was deliberately not added: wrangler as a devDependency bundles workerd + esbuild + miniflare — well over 100 MB installed, disproportionate for one WebSocket round when the double already exercises the real logic. Instead, smoke it manually against real workerd:

# In a Worker project wired per the "Wiring" example above:
wrangler d1 create syncular && wrangler d1 migrations apply syncular --local
wrangler dev
# Then, against the local dev server, open the socket and run one round:
#   const ws = new WebSocket('ws://localhost:8787/realtime?...')
#   ws.onmessage = (e) => console.log(e.data)  // expect a `hello` frame
# (the demo app's frontend worker is a worked reference client.)

If a signal justifies it later, the automated lane is a small SYNCULAR_ WRANGLER_SMOKE=1-gated test wrapping exactly this recipe.

Runtime neutrality

The server core this entry loads (handler, realtime session, D1 storage, memory stores, signed-URL/segment/blob machinery) is free of Bun- and Node-only builtins — SigV4 and all hashing use Web Crypto, base64 uses btoa/atob, and the SQLite-family stores that need bun:sqlite (SqliteServerStorage, SqliteSegmentStore, SqliteBlobStore, SqliteLeaseStore, buildSqliteImage) live in separate modules a Bun/Node host opts into and a Workers bundle tree-shakes away. This is enforced by a static import-graph scan in packages/server/test/runtime-neutrality.test.ts.