jChat LogojChat Docs
Addon Development

Backend Services & Real-Time Events

Build Elysia API endpoints, manage isolated Kysely databases, and broadcast WebSocket events.

Elysia Backend Router (src/api.ts)

Fullstack addons define their backend in src/api.ts. The host passes an AddonServerContext containing a scoped Kysely database instance, Redis cache, real-time emitter, validation schema builder t, and entitlement verifier:

import { verifyAddonEntitlement, type AddonApp, type AddonServerContext } from "@jchat/addon-sdk"
import type { PollwiseDatabase } from "./db"

export default (
  { db, cache, emit, t, verifyEntitlement }: AddonServerContext<PollwiseDatabase>,
  app: AddonApp
) => {
  // 1. Verify commercial license entitlement (required for commercial addons)
  verifyAddonEntitlement("pollwise", verifyEntitlement)

  return app
    .get(
      "/polls",
      async ({ user }) => {
        // Direct type-safe Kysely query: camelCase properties map to snake_case DB columns
        const polls = await db
          .selectFrom("pollwise_polls")
          .where("siteId", "=", user?.siteId ?? "")
          .where("closedAt", "is", null)
          .selectAll()
          .execute()

        return { status: "success", data: polls }
      },
      {
        requirePermission: "addon.pollwise.vote"
      }
    )
    .post(
      "/polls",
      async ({ body, user }) => {
        const id = Bun.randomUUIDv7()

        // 2. Insert into isolated "addons" PostgreSQL schema
        await db
          .insertInto("pollwise_polls")
          .values({
            id,
            siteId: user?.siteId ?? "",
            creatorId: user.id,
            question: body.question,
            options: body.options,
            multipleChoice: body.multipleChoice ?? false
          })
          .execute()

        // 3. Set namespaced Redis cache with TTL
        await cache.set(`latest-poll:${user.siteId}`, id, 300)

        // 4. Broadcast real-time WebSocket event across site
        await emit(
          "addon:pollwise:created",
          { pollId: id, question: body.question },
          { siteId: user.siteId }
        )

        return { status: "success", data: { id } }
      },
      {
        requirePermission: "addon.pollwise.create",
        body: t.Object({
          question: t.String({ minLength: 1, maxLength: 200 }),
          options: t.Array(t.Object({ text: t.String() })),
          multipleChoice: t.Optional(t.Boolean())
        })
      }
    )
}

Commercial License Verification

Commercial marketplace addons verify their active license on startup using verifyAddonEntitlement. You can pass either the verifyEntitlement function directly from context or the entire context object:

// Pass verifyEntitlement directly when destructuring context:
export default ({ db, verifyEntitlement }: AddonServerContext<MyDb>, app: AddonApp) => {
  verifyAddonEntitlement("my-addon", verifyEntitlement)
  // ...
}

If an addon requires a commercial license and the current deployment lacks a valid entitlement, verifyAddonEntitlement prevents the extension from activating.

Database Schema (src/db.ts)

Database interfaces are declared in src/db.ts using type-safe table definitions. In TypeScript, properties use idiomatic camelCase, which maps automatically to database snake_case columns with 0 runtime bundle overhead:

import type { Generated, Selectable } from "@jchat/addon-sdk"

export interface PollTable {
  id: string
  siteId: string
  roomId: string | null
  creatorId: string
  question: string
  options: { text: string }[]
  multipleChoice: boolean
  expiresAt: Date | null
  closedAt: Date | null
  createdAt: Generated<Date>
}

export interface PollVoteTable {
  id: string
  pollId: string
  userId: string
  optionIndex: number
  createdAt: Generated<Date>
}

export interface PollwiseDatabase {
  pollwise_polls: PollTable
  pollwise_votes: PollVoteTable
}

export type Poll = Selectable<PollTable>
export type PollVote = Selectable<PollVoteTable>
-- migrations/001_initial.sql
CREATE TABLE IF NOT EXISTS "addons"."pollwise_polls" (
  "id" VARCHAR(36) PRIMARY KEY,
  "site_id" VARCHAR(36) NOT NULL,
  "room_id" VARCHAR(36),
  "creator_id" VARCHAR(36) NOT NULL,
  "question" TEXT NOT NULL,
  "options" JSONB NOT NULL DEFAULT '[]',
  "multiple_choice" BOOLEAN NOT NULL DEFAULT FALSE,
  "expires_at" TIMESTAMPTZ,
  "closed_at" TIMESTAMPTZ,
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS "addons"."pollwise_votes" (
  "id" VARCHAR(36) PRIMARY KEY,
  "poll_id" VARCHAR(36) NOT NULL,
  "user_id" VARCHAR(36) NOT NULL,
  "option_index" INT NOT NULL,
  "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS "idx_pollwise_polls_site" ON "addons"."pollwise_polls" ("site_id");
CREATE INDEX IF NOT EXISTS "idx_pollwise_votes_poll" ON "addons"."pollwise_votes" ("poll_id");

20-Language Translation Parity

All JChat addons must support all 20 host languages (ar, bg, de, el, en, es, fr, he, hi, hr, kn, ml, nl, pt, ro, ru, ta, te, tr, ur). The automated test in src/locales.test.ts validates 100% key parity using validateAddonLocales from @jchat/addon-sdk/i18n-validator:

import { validateAddonLocales } from "@jchat/addon-sdk/i18n-validator"
import { describe, expect, test } from "bun:test"
import * as path from "node:path"

describe("addon locales", () => {
  test("maintains key parity across all 20 host languages", () => {
    const localesDir = path.join(import.meta.dir, "locales")
    const result = validateAddonLocales(localesDir)
    if (!result.valid) {
      console.error(result.errors.join("\n"))
    }
    expect(result.valid).toBe(true)
  })
})

On this page