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 elysiapnpm add @opencoredev/social-sdk elysiayarn add @opencoredev/social-sdk elysiabun add @opencoredev/social-sdk elysianub add @opencoredev/social-sdk elysiaaube add @opencoredev/social-sdk elysiaPublish 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-ormpnpm add @electric-sql/pglite @neondatabase/serverless drizzle-ormyarn add @electric-sql/pglite @neondatabase/serverless drizzle-ormbun add @electric-sql/pglite @neondatabase/serverless drizzle-ormnub add @electric-sql/pglite @neondatabase/serverless drizzle-ormaube add @electric-sql/pglite @neondatabase/serverless drizzle-ormThen 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.