Skip to content
Social SDK Cloud, the hosted SDK, is taking early-access signups.Join the list
Social SDK megaphone markSocial SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Elysia

Publish to social platforms from Elysia routes on Bun or Node using web-standard Request and Response with Social SDK.

Elysia handles web-standard Request and Response objects, so Social SDK needs no adapter. Routes validate input with Elysia’s schema builder, webhook routes can skip body parsing to keep the raw bytes, and the same app runs on Bun or on Node 22.12+ through the Node adapter.

Install

npm install @opencoredev/social-sdk elysia
pnpm add @opencoredev/social-sdk elysia
yarn add @opencoredev/social-sdk elysia
bun add @opencoredev/social-sdk elysia
nub add @opencoredev/social-sdk elysia
aube add @opencoredev/social-sdk elysia

Publish from a route

Create the client once at module scope and validate the request body with t before it reaches the SDK. The idempotencyKey only stops a retried request from posting twice when the client has an idempotencyStore, so give it a durable store that every process shares. The Neon guide includes a Postgres implementation.

import { Elysia, t } from "elysia";
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
import { idempotencyStore } from "./stores"; // your durable IdempotencyStore

const social = createSocial({
  backend: bluesky({
    auth: {
      service: process.env.BLUESKY_SERVICE!,
      did: process.env.BLUESKY_DID!,
      accessJwt: process.env.BLUESKY_ACCESS_JWT!,
    },
  }),
  idempotencyStore,
});

export const app = new Elysia().post(
  "/publish",
  async ({ request, body }) => {
    const tenant = await requireTenant(request); // your auth
    const result = await social.posts.publish(
      {
        targets: [
          {
            account: connectedAccountRef({
              backend: "default",
              platform: "bluesky",
              accountId: await resolveAuthorizedAccount(tenant),
            }),
          },
        ],
        content: { text: body.text },
        idempotencyKey: body.draftId,
      },
      // Scopes the idempotency key to this tenant, so tenants can't collide on a draft ID.
      { authorization: { tenantId: tenant.id } },
    );

    return {
      status: result.status,
      outcomes: result.outcomes.map((o) => ({ state: o.state })),
    };
  },
  {
    body: t.Object({
      text: t.String({ minLength: 1 }),
      draftId: t.String({ minLength: 1 }),
    }),
  },
);

Elysia rejects a body that fails the schema with a 422 before your handler runs. See idempotency for how keys, fingerprints, and unknown outcomes fit together. If a retry returns unknown, the first attempt may have posted, so check the account before you publish again, even under a new draft ID.

This client holds one Bluesky session, so it can only publish as BLUESKY_DID. The adapter rejects a reference to any other account. To publish for several accounts, load each account’s credentials from your encrypted credential store and build a client for that account. See authentication.

Webhooks from the raw request

Bluesky sends no webhooks. Its events arrive over WebSocket streams, as the events guide explains, so the Bluesky client above has nothing to verify. The route below is a separate one for the Zernio managed backend, which signs each delivery with X-Zernio-Signature. It uses the standalone verifyZernioWebhook with your endpoint secret and needs no client.

Elysia parses JSON bodies by default, and a Request body can only be read once. Set parse: "none" on the webhook route so verification reads the exact bytes the provider signed. Pass request.headers as is, because verifiers read signatures with headers.get(). The verifier resolves on success and throws a SocialError with code unauthorized for a missing or invalid signature or an oversized body, so map that error to a 401.

The route reads the body before anything is authenticated, so it stops at 1 MiB, the verifier’s default limit. A Content-Length check alone is not enough, because chunked requests omit it:

import { SocialError } from "@opencoredev/social-sdk";
import { verifyZernioWebhook } from "@opencoredev/social-sdk/server";

const MAX_WEBHOOK_BYTES = 1024 * 1024;

/** Reads at most `limit` bytes of the body, or returns undefined when it is larger. */
async function readLimited(request: Request, limit: number) {
  if (Number(request.headers.get("content-length") ?? 0) > limit) return undefined;
  if (request.body === null) return new Uint8Array();
  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let size = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    size += value.byteLength;
    if (size > limit) {
      await reader.cancel();
      return undefined;
    }
    chunks.push(value);
  }
  const body = new Uint8Array(size);
  let offset = 0;
  for (const chunk of chunks) {
    body.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return body;
}

app.post(
  "/zernio/events",
  async ({ request, status }) => {
    const body = await readLimited(request, MAX_WEBHOOK_BYTES);
    if (!body) return status(413, "payload too large");
    try {
      await verifyZernioWebhook({
        secret: process.env.ZERNIO_WEBHOOK_SECRET!,
        headers: request.headers,
        body,
      });
    } catch (error) {
      if (error instanceof SocialError && error.code === "unauthorized") {
        return status(401, "unauthorized");
      }
      throw error;
    }
    // decode, resolve tenants, accept into your durable inbox
    return status(202, "accepted");
  },
  { parse: "none" },
);

See the events guide for the decode, quarantine, and worker steps.

Mount the example handler

Elysia’s mount forwards the untouched request to any fetch handler and strips the mount prefix from the path. The example’s Hono adapter already accepts prefix-free paths, so it works here unchanged.

The adapter lives in the repository’s apps/example app. It is a private workspace package. It is not part of @opencoredev/social-sdk and is not published to npm, so there is no subpath to import it from. Copy it into your project instead.

The handler opens a local PGlite database on its first request and applies the Drizzle migrations from apps/example/drizzle. The database module finds them at ../../drizzle relative to src/db/client.ts, so copy both directories and keep them side by side. From your project root, with a checkout of the repository at ../social-sdk:

mkdir -p src/social-example
cp -R ../social-sdk/apps/example/src src/social-example/src
cp -R ../social-sdk/apps/example/drizzle src/social-example/drizzle

That gives this layout. Without drizzle/, every API request fails with HTTP 500 because the migrations cannot be found.

src/
  app.ts
  social-example/
    src/       # app.ts, db/, frameworks/, ...
    drizzle/   # 0000_init.sql and meta/_journal.json

Install the copy’s dependencies, which come from apps/example/package.json:

npm install @electric-sql/pglite @neondatabase/serverless drizzle-orm
pnpm add @electric-sql/pglite @neondatabase/serverless drizzle-orm
yarn add @electric-sql/pglite @neondatabase/serverless drizzle-orm
bun add @electric-sql/pglite @neondatabase/serverless drizzle-orm
nub add @electric-sql/pglite @neondatabase/serverless drizzle-orm
aube add @electric-sql/pglite @neondatabase/serverless drizzle-orm

Then mount it:

import { Elysia } from "elysia";
import { createExampleHandler } from "./social-example/src/app.js";
import { createHonoFetch } from "./social-example/src/frameworks/hono.js";

const app = new Elysia().mount("/social", createHonoFetch(createExampleHandler()));

If you compile with tsc instead of running TypeScript directly, copy drizzle/ next to the compiled social-example/src as well, so the relative path still resolves.

Requests to /social/accounts and /social/publish reach the example API. With the server below on port 3030, the first lists two mock accounts and the second publishes to them. The example starts with a local mock backend and a demo session. Replace the session with your own authentication before it touches real accounts. It also answers only requests for localhost:3030 and 127.0.0.1:3030. On another host or port, pass allowedHosts and allowedOrigins to createExampleHandler, or it returns HTTP 421. Framework recipes documents what the handler provides.

The example’s POST /api/connect/callback takes a JSON body with attemptId, callbackUrl, and returnedState. It is not the URL a provider redirects to. Providers redirect the browser with a GET to the redirect URI you registered, so add your own GET route at that URI. In it, authenticate the session, take the tenant and principal from the session, and pass the full callback URL and returned state to ConnectionManager.discover, then select the accounts the user picks. OAuth redirects carry no webhook signature, so keep this route separate from webhook verification. Authentication describes begin, discover, and select.

Run on Bun or Node

// Bun
import { app } from "./app.js";
app.listen(3030);

On Node, install @elysia/node and pass its adapter when you create the app:

import { Elysia } from "elysia";
import { node } from "@elysia/node";

export const app = new Elysia({ adapter: node() });

Then call app.listen(3030) as on Bun.

Notes

  • Both runtimes are tested SDK targets; the SDK is ESM-only.
  • Cloudflare Workers is not a tested SDK runtime. For Workers-fronted apps, run the SDK in a Node or Bun service behind the Worker.
  • Keep one client instance per process so per-backend concurrency limits apply.

Last updated on October 5, 2026