feat(sdk): framework adapters — tRPC, oRPC, Elysia, Convex

Full-SDK adapters: each resolves the authenticated caller onto the framework's
context AND wraps the handler in a request-linked span, so backend telemetry
nests under the caller's client session ($refresh-token/$session-replay/
$session-replay-segment) with ZERO per-route plumbing — consumers never pass
{ request } themselves (they can via the core primitive, but don't need to).

- integrations/adapter-core.ts: shared core — normalizeRequestLike, lazy
  memoized per-request user resolution, runRequestSpan (withSpan({request}))
- trpc.ts: createHexclaveTRPC(t, app) → createContext + middleware({ required,
  telemetry }); no @trpc/server dep (TRPCError-shaped UNAUTHORIZED rejection)
- orpc.ts: createHexclaveORPC(app, { unauthorized }) → middleware +
  wrapFetchHandler (oRPC middleware can't see the Request otherwise); refuses
  required:true without a real ORPCError factory (branded class, not faked)
- elysia.ts: createHexclaveElysia(app) → resolveUser (.resolve), requireUser
  (beforeHandle 401 guard), handler (around-wrapper — the only honest way to
  put an Elysia handler inside a span's async extent)
- convex.ts: hexclaveConvexFunction(app, handler, { required, kind, name }) —
  identity-based attribution (Convex is WebSocket-transported; no Request, so
  segment linking is a documented follow-up)

Span types stay low-cardinality (trpc.procedure/orpc.procedure/elysia.route/
convex.function) with path/type/kind in span data. New subpath exports ./trpc
./orpc ./elysia ./convex on all four SDK packages. 14 unit tests.
This commit is contained in:
mantrakp04 2026-07-03 13:55:35 -07:00
parent e6038897ae
commit 939c230f50
13 changed files with 860 additions and 1 deletions

View File

@ -42,6 +42,42 @@
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./convex": {
"types": "./dist/integrations/convex.d.ts",
"import": {
"default": "./dist/esm/integrations/convex.js"
},
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./trpc": {
"types": "./dist/integrations/trpc.d.ts",
"import": {
"default": "./dist/esm/integrations/trpc.js"
},
"require": {
"default": "./dist/integrations/trpc.js"
}
},
"./orpc": {
"types": "./dist/integrations/orpc.d.ts",
"import": {
"default": "./dist/esm/integrations/orpc.js"
},
"require": {
"default": "./dist/integrations/orpc.js"
}
},
"./elysia": {
"types": "./dist/integrations/elysia.d.ts",
"import": {
"default": "./dist/esm/integrations/elysia.js"
},
"require": {
"default": "./dist/integrations/elysia.js"
}
}
},
"homepage": "https://hexclave.com",

View File

@ -42,6 +42,42 @@
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./convex": {
"types": "./dist/integrations/convex.d.ts",
"import": {
"default": "./dist/esm/integrations/convex.js"
},
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./trpc": {
"types": "./dist/integrations/trpc.d.ts",
"import": {
"default": "./dist/esm/integrations/trpc.js"
},
"require": {
"default": "./dist/integrations/trpc.js"
}
},
"./orpc": {
"types": "./dist/integrations/orpc.d.ts",
"import": {
"default": "./dist/esm/integrations/orpc.js"
},
"require": {
"default": "./dist/integrations/orpc.js"
}
},
"./elysia": {
"types": "./dist/integrations/elysia.d.ts",
"import": {
"default": "./dist/esm/integrations/elysia.js"
},
"require": {
"default": "./dist/integrations/elysia.js"
}
}
},
"homepage": "https://hexclave.com",

View File

@ -42,6 +42,42 @@
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./convex": {
"types": "./dist/integrations/convex.d.ts",
"import": {
"default": "./dist/esm/integrations/convex.js"
},
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./trpc": {
"types": "./dist/integrations/trpc.d.ts",
"import": {
"default": "./dist/esm/integrations/trpc.js"
},
"require": {
"default": "./dist/integrations/trpc.js"
}
},
"./orpc": {
"types": "./dist/integrations/orpc.d.ts",
"import": {
"default": "./dist/esm/integrations/orpc.js"
},
"require": {
"default": "./dist/integrations/orpc.js"
}
},
"./elysia": {
"types": "./dist/integrations/elysia.d.ts",
"import": {
"default": "./dist/esm/integrations/elysia.js"
},
"require": {
"default": "./dist/integrations/elysia.js"
}
}
},
"homepage": "https://hexclave.com",

View File

@ -53,6 +53,42 @@
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./convex": {
"types": "./dist/integrations/convex.d.ts",
"import": {
"default": "./dist/esm/integrations/convex.js"
},
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./trpc": {
"types": "./dist/integrations/trpc.d.ts",
"import": {
"default": "./dist/esm/integrations/trpc.js"
},
"require": {
"default": "./dist/integrations/trpc.js"
}
},
"./orpc": {
"types": "./dist/integrations/orpc.d.ts",
"import": {
"default": "./dist/esm/integrations/orpc.js"
},
"require": {
"default": "./dist/integrations/orpc.js"
}
},
"./elysia": {
"types": "./dist/integrations/elysia.d.ts",
"import": {
"default": "./dist/esm/integrations/elysia.js"
},
"require": {
"default": "./dist/integrations/elysia.js"
}
}
},
"homepage": "https://hexclave.com",

View File

@ -67,6 +67,42 @@
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./convex": {
"types": "./dist/integrations/convex.d.ts",
"import": {
"default": "./dist/esm/integrations/convex.js"
},
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./trpc": {
"types": "./dist/integrations/trpc.d.ts",
"import": {
"default": "./dist/esm/integrations/trpc.js"
},
"require": {
"default": "./dist/integrations/trpc.js"
}
},
"./orpc": {
"types": "./dist/integrations/orpc.d.ts",
"import": {
"default": "./dist/esm/integrations/orpc.js"
},
"require": {
"default": "./dist/integrations/orpc.js"
}
},
"./elysia": {
"types": "./dist/integrations/elysia.d.ts",
"import": {
"default": "./dist/esm/integrations/elysia.js"
},
"require": {
"default": "./dist/integrations/elysia.js"
}
}
},
"homepage": "https://hexclave.com",

View File

@ -54,6 +54,42 @@
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./convex": {
"types": "./dist/integrations/convex.d.ts",
"import": {
"default": "./dist/esm/integrations/convex.js"
},
"require": {
"default": "./dist/integrations/convex.js"
}
},
"./trpc": {
"types": "./dist/integrations/trpc.d.ts",
"import": {
"default": "./dist/esm/integrations/trpc.js"
},
"require": {
"default": "./dist/integrations/trpc.js"
}
},
"./orpc": {
"types": "./dist/integrations/orpc.d.ts",
"import": {
"default": "./dist/esm/integrations/orpc.js"
},
"require": {
"default": "./dist/integrations/orpc.js"
}
},
"./elysia": {
"types": "./dist/integrations/elysia.d.ts",
"import": {
"default": "./dist/esm/integrations/elysia.js"
},
"require": {
"default": "./dist/integrations/elysia.js"
}
}
},
"homepage": "https://hexclave.com",

View File

@ -0,0 +1,98 @@
import type { RequestLike } from "../lib/hexclave-app/common";
import type { Span } from "../lib/hexclave-app/apps/implementations/event-tracker";
import type { StackServerApp } from "../lib/hexclave-app/apps/interfaces/server-app";
import type { ServerUser } from "../lib/hexclave-app/users";
/**
* Shared core for the framework adapters (tRPC / oRPC / Elysia / Convex).
*
* Each adapter does exactly two things with the incoming request: resolve the
* caller (`ctx.user`, lazily, cached per request) and wrap the framework's
* handler in a `withSpan(type, { request }, …)` span which is what links the
* backend span to the caller's client session (`$refresh-token` /
* `$session-replay` / `$session-replay-segment`) via the session plus the
* `x-hexclave-span-context` header the browser SDK attaches automatically.
* Consumers of an adapter never pass `{ request }` themselves.
*/
/** The (non-generic) server-app surface the adapters program against. */
export type AdapterServerApp = StackServerApp<boolean, string>;
/** What adapters put on the framework context as the authenticated caller. */
export type AdapterUser = ServerUser;
/**
* Accepts anything with request-like headers (a fetch `Request`, a Node
* `IncomingMessage`, `{ headers: Record<string, string | null> }`) and returns
* it as a RequestLike, or null when there is nothing usable adapters treat
* that as an unauthenticated, unlinked call rather than an error.
*/
export function normalizeRequestLike(input: unknown): RequestLike | null {
if (typeof input !== "object" || input === null) return null;
const headers = (input as { headers?: unknown }).headers;
if (typeof headers !== "object" || headers === null) return null;
return input as RequestLike;
}
/**
* Per-request context every adapter builds once and threads through: the
* normalized request plus a lazy, memoized user resolution (no auth work is
* done until something actually asks for the user).
*/
export type HexclaveRequestContext = {
request: RequestLike | null,
getUser: () => Promise<AdapterUser | null>,
};
export function createRequestContext(app: AdapterServerApp, requestInput: unknown): HexclaveRequestContext {
const request = normalizeRequestLike(requestInput);
let userPromise: Promise<AdapterUser | null> | null = null;
return {
request,
getUser: () => {
userPromise ??= request === null
? Promise.resolve(null)
: (app.getUser as (options: { tokenStore: RequestLike, or: "return-null" }) => Promise<AdapterUser | null>)({ tokenStore: request, or: "return-null" });
return userPromise;
},
};
}
/**
* Per-adapter telemetry knob: `false` disables the span, `true`/undefined uses
* the adapter's low-cardinality default span type (`trpc.procedure`,
* `orpc.procedure`, `elysia.route`, `convex.function` the variable bits like
* path/method belong in `data`, not the type).
*/
export type AdapterTelemetryOptions = boolean | {
spanType?: string,
data?: Record<string, unknown>,
};
/**
* Wraps `fn` in the adapter's request-linked span (or calls it straight through
* when telemetry is off). The span and everything created inside `fn`
* parents under the caller's client session via `withSpan({ request })`.
*/
export async function runRequestSpan<T>(
app: AdapterServerApp,
context: HexclaveRequestContext,
info: {
defaultSpanType: string,
data: Record<string, unknown>,
telemetry: AdapterTelemetryOptions | undefined,
},
fn: (span: Span | null) => Promise<T>,
): Promise<T> {
if (info.telemetry === false) {
return await fn(null);
}
const custom = typeof info.telemetry === "object" ? info.telemetry : undefined;
const spanType = custom?.spanType ?? info.defaultSpanType;
const data = { ...info.data, ...custom?.data ?? {} };
return await app.withSpan(
spanType,
{ ...context.request !== null ? { request: context.request } : {}, data },
(span) => fn(span),
);
}

View File

@ -0,0 +1,201 @@
import { describe, expect, it, vi } from "vitest";
import { createRequestContext, normalizeRequestLike, type AdapterServerApp } from "./adapter-core";
import { hexclaveConvexFunction } from "./convex";
import { createHexclaveElysia } from "./elysia";
import { createHexclaveORPC } from "./orpc";
import { createHexclaveTRPC } from "./trpc";
const FAKE_USER = { id: "user-1", displayName: "Test" };
function makeApp(overrides?: { user?: unknown | null }) {
const user = overrides && "user" in overrides ? overrides.user : FAKE_USER;
const withSpan = vi.fn(async (_type: string, _options: unknown, fn: (span: unknown) => unknown) => await fn({ spanId: "span-1" }));
const getUser = vi.fn(async () => user);
return { app: { withSpan, getUser } as unknown as AdapterServerApp, withSpan, getUser };
}
function makeRequest(headers?: Record<string, string>): Request {
return new Request("https://api.example.com/x", { headers });
}
describe("adapter-core", () => {
it("normalizeRequestLike accepts Request / header records, rejects garbage", () => {
expect(normalizeRequestLike(makeRequest())).not.toBeNull();
expect(normalizeRequestLike({ headers: { cookie: "a=b" } })).not.toBeNull();
expect(normalizeRequestLike(null)).toBeNull();
expect(normalizeRequestLike("nope")).toBeNull();
expect(normalizeRequestLike({})).toBeNull();
});
it("createRequestContext resolves the user lazily and memoizes", async () => {
const { app, getUser } = makeApp();
const context = createRequestContext(app, makeRequest());
expect(getUser).not.toHaveBeenCalled();
await expect(context.getUser()).resolves.toEqual(FAKE_USER);
await context.getUser();
expect(getUser).toHaveBeenCalledTimes(1);
});
it("createRequestContext with no usable request yields null user without an auth call", async () => {
const { app, getUser } = makeApp();
const context = createRequestContext(app, undefined);
expect(context.request).toBeNull();
await expect(context.getUser()).resolves.toBeNull();
expect(getUser).not.toHaveBeenCalled();
});
});
describe("tRPC adapter", () => {
// Minimal `t` stand-in: t.middleware(fn) just returns fn.
const t = { middleware: (fn: (opts: never) => Promise<unknown>) => fn };
function callMiddleware(middleware: unknown, ctx: Record<string, unknown>) {
const next = vi.fn(async (opts?: { ctx?: Record<string, unknown> }) => ({ nextCtx: opts?.ctx }));
const result = (middleware as (opts: unknown) => Promise<unknown>)({ ctx, path: "checkout.create", type: "mutation", next });
return { next, result };
}
it("createContext stashes a lazy context from opts.req", async () => {
const { app, getUser } = makeApp();
const hexclave = createHexclaveTRPC(t, app);
const ctx = hexclave.createContext({ req: makeRequest() });
expect(getUser).not.toHaveBeenCalled();
await expect(ctx.hexclave!.getUser()).resolves.toEqual(FAKE_USER);
});
it("middleware wraps the procedure in a request-linked span and injects ctx.user", async () => {
const { app, withSpan } = makeApp();
const hexclave = createHexclaveTRPC(t, app);
const ctx = hexclave.createContext({ req: makeRequest() }) as Record<string, unknown>;
const { next, result } = callMiddleware(hexclave.middleware(), ctx);
await result;
expect(withSpan).toHaveBeenCalledTimes(1);
const [spanType, options] = withSpan.mock.calls[0];
expect(spanType).toBe("trpc.procedure");
expect((options as { data: unknown }).data).toEqual({ path: "checkout.create", type: "mutation" });
expect((options as { request: unknown }).request).not.toBeUndefined();
expect(next).toHaveBeenCalledWith({ ctx: expect.objectContaining({ user: FAKE_USER }) });
});
it("required: true rejects unauthenticated calls with a TRPCError-shaped UNAUTHORIZED", async () => {
const { app } = makeApp({ user: null });
const hexclave = createHexclaveTRPC(t, app);
const ctx = hexclave.createContext({ req: makeRequest() }) as Record<string, unknown>;
const { next, result } = callMiddleware(hexclave.middleware({ required: true }), ctx);
await expect(result).rejects.toMatchObject({ name: "TRPCError", code: "UNAUTHORIZED" });
expect(next).not.toHaveBeenCalled();
});
it("telemetry: false skips the span but still resolves the user", async () => {
const { app, withSpan } = makeApp();
const hexclave = createHexclaveTRPC(t, app);
const ctx = hexclave.createContext({ req: makeRequest() }) as Record<string, unknown>;
const { next, result } = callMiddleware(hexclave.middleware({ telemetry: false }), ctx);
await result;
expect(withSpan).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith({ ctx: expect.objectContaining({ user: FAKE_USER }) });
});
});
describe("oRPC adapter", () => {
it("required: true without an unauthorized factory fails fast at creation", () => {
const { app } = makeApp();
const hexclave = createHexclaveORPC(app);
expect(() => hexclave.middleware({ required: true })).toThrow(/unauthorized/);
});
it("wrapFetchHandler injects the per-request context; middleware resolves the user in a span", async () => {
const { app, withSpan } = makeApp();
const hexclave = createHexclaveORPC(app, { unauthorized: () => new Error("nope") });
// Handler stand-in that captures its options and immediately runs the middleware.
const captured: { context?: Record<string, unknown> }[] = [];
const handler = {
handle: (request: Request, options?: Record<string, unknown>) => {
captured.push(options as { context?: Record<string, unknown> });
return { matched: true, request };
},
};
const handle = hexclave.wrapFetchHandler(handler, { prefix: "/rpc" });
handle(makeRequest());
expect(captured[0].context?.hexclave).toBeDefined();
expect((captured[0] as { prefix?: string }).prefix).toBe("/rpc");
const middleware = hexclave.middleware();
const next = vi.fn(async (opts?: { context?: Record<string, unknown> }) => opts?.context);
await middleware({ context: captured[0].context as never, path: ["checkout", "create"], next });
expect(withSpan).toHaveBeenCalledTimes(1);
expect(withSpan.mock.calls[0][0]).toBe("orpc.procedure");
expect((withSpan.mock.calls[0][1] as { data: unknown }).data).toEqual({ path: "checkout.create" });
expect(next).toHaveBeenCalledWith({ context: expect.objectContaining({ user: FAKE_USER }) });
});
it("required: true throws the caller-provided error for unauthenticated calls", async () => {
const { app } = makeApp({ user: null });
const unauthorized = () => Object.assign(new Error("UNAUTHORIZED"), { fromFactory: true });
const hexclave = createHexclaveORPC(app, { unauthorized });
const middleware = hexclave.middleware({ required: true });
const context = { hexclave: createRequestContext(app, makeRequest()) };
await expect(middleware({ context: context as never, path: ["x"], next: vi.fn() })).rejects.toMatchObject({ fromFactory: true });
});
});
describe("Elysia adapter", () => {
it("resolveUser adds the context + user; requireUser guards with a 401", async () => {
const { app } = makeApp();
const hexclave = createHexclaveElysia(app);
const resolved = await hexclave.resolveUser({ request: makeRequest() });
expect(resolved.user).toEqual(FAKE_USER);
const okSet: { status?: number | string } = {};
expect(hexclave.requireUser({ request: makeRequest(), set: okSet, user: resolved.user } as never)).toBeUndefined();
const failSet: { status?: number | string } = {};
const body = hexclave.requireUser({ request: makeRequest(), set: failSet, user: null } as never);
expect(failSet.status).toBe(401);
expect(body).toMatchObject({ error: expect.stringContaining("signed in") });
});
it("handler wraps the route in an elysia.route span with path + method", async () => {
const { app, withSpan } = makeApp();
const hexclave = createHexclaveElysia(app);
const wrapped = hexclave.handler(async (ctx) => `hello ${(ctx as { user?: { id: string } }).user?.id}`);
const result = await wrapped({
request: new Request("https://api.example.com/me", { method: "GET" }),
path: "/me",
set: {},
user: FAKE_USER,
hexclave: createRequestContext(app, makeRequest()),
} as never);
expect(result).toBe("hello user-1");
expect(withSpan).toHaveBeenCalledTimes(1);
expect(withSpan.mock.calls[0][0]).toBe("elysia.route");
expect((withSpan.mock.calls[0][1] as { data: unknown }).data).toEqual({ path: "/me", method: "GET" });
});
});
describe("Convex adapter", () => {
const convexCtx = { auth: { getUserIdentity: async () => ({ subject: "user-1" }) } };
it("resolves the caller from convex identity and runs inside a convex.function span", async () => {
const { app, withSpan, getUser } = makeApp();
const wrapped = hexclaveConvexFunction(app, async ({ user, args }) => `${(user as { id: string }).id}:${(args as { n: number }).n}`, { kind: "query", name: "listItems" });
const result = await wrapped(convexCtx as never, { n: 7 });
expect(result).toBe("user-1:7");
expect(getUser).toHaveBeenCalledWith({ from: "convex", ctx: convexCtx, or: "return-null" });
expect(withSpan).toHaveBeenCalledTimes(1);
expect(withSpan.mock.calls[0][0]).toBe("convex.function");
expect(withSpan.mock.calls[0][1]).toMatchObject({ userId: "user-1", data: { kind: "query", name: "listItems" } });
});
it("required: true rejects unauthenticated calls; telemetry: false skips the span", async () => {
const { app } = makeApp({ user: null });
const guarded = hexclaveConvexFunction(app, async () => "never", { required: true });
await expect(guarded(convexCtx as never, {})).rejects.toThrow(/signed in/);
const { app: app2, withSpan } = makeApp();
const bare = hexclaveConvexFunction(app2, async () => "ok", { telemetry: false });
await expect(bare(convexCtx as never, {})).resolves.toBe("ok");
expect(withSpan).not.toHaveBeenCalled();
});
});

View File

@ -1,5 +1,7 @@
import { urlString } from "@hexclave/shared/dist/utils/urls";
import type { ConvexCtx } from "../lib/hexclave-app/common";
import { defaultBaseUrl } from "../lib/hexclave-app/apps/implementations/common";
import { AdapterServerApp, AdapterTelemetryOptions, AdapterUser } from "./adapter-core";
export function getConvexProvidersConfig(options: {
baseUrl?: string,
@ -22,3 +24,64 @@ export function getConvexProvidersConfig(options: {
},
];
}
export type HexclaveConvexFunctionOptions = {
/** Reject unauthenticated calls; the handler's `user` is then non-null. */
required?: boolean,
/** Recorded in the span data (low-cardinality span type stays `convex.function`). */
kind?: "query" | "mutation" | "action",
/** The function's name, recorded in the span data. */
name?: string,
/** Disable or customize the span. @default true */
telemetry?: AdapterTelemetryOptions,
/** Error factory for `required` rejections. */
unauthorized?: () => Error,
};
/**
* Wraps a Convex function handler: resolves the caller from Convex's identity
* (`ctx.auth`, wired to Hexclave via `getConvexProvidersConfig`) and runs the
* handler inside a `convex.function` span attributed to that user.
*
* ```ts
* export const listItems = query({
* handler: hexclaveConvexFunction(stackServerApp, async ({ ctx, args, user }) => {
* // user: ServerUser (non-null with { required: true })
* }, { required: true, kind: "query", name: "listItems" }),
* });
* ```
*
* NOTE: Convex clients talk over WebSockets and functions receive no Request,
* so unlike the HTTP adapters this span is attributed by USER only it does
* not (yet) nest under the caller's `$session-replay-segment`. Cross-tier
* segment linking for Convex is a follow-up.
*/
export function hexclaveConvexFunction<TCtx extends ConvexCtx, TArgs, TResult>(
app: AdapterServerApp,
handler: (options: { ctx: TCtx, args: TArgs, user: AdapterUser | null }) => TResult | Promise<TResult>,
options?: HexclaveConvexFunctionOptions,
): (ctx: TCtx, args: TArgs) => Promise<TResult> {
return async (ctx: TCtx, args: TArgs): Promise<TResult> => {
const user = await (app.getUser as (options: { from: "convex", ctx: ConvexCtx, or: "return-null" }) => Promise<AdapterUser | null>)({ from: "convex", ctx, or: "return-null" });
if (options?.required && user === null) {
throw options.unauthorized?.() ?? new Error("You must be signed in to call this Convex function. (Hexclave: no identity on ctx.auth.)");
}
const run = () => handler({ ctx, args, user });
if (options?.telemetry === false) {
return await run();
}
const custom = typeof options?.telemetry === "object" ? options.telemetry : undefined;
return await app.withSpan(
custom?.spanType ?? "convex.function",
{
...user !== null ? { userId: user.id } : {},
data: {
...options?.kind ? { kind: options.kind } : {},
...options?.name ? { name: options.name } : {},
...custom?.data ?? {},
},
},
() => run(),
);
};
}

View File

@ -0,0 +1,79 @@
import { AdapterServerApp, AdapterTelemetryOptions, AdapterUser, createRequestContext, HexclaveRequestContext, runRequestSpan } from "./adapter-core";
/**
* Hexclave adapter for ElysiaJS. Zero runtime dependency on `elysia`
* exported as composable pieces because Elysia has no around-middleware: a
* `.resolve()` callback for the user, a `beforeHandle` guard, and a handler
* wrapper for the request-linked span (wrapping the handler is the only honest
* way to put it INSIDE the span's async extent).
*
* ```ts
* const hexclave = createHexclaveElysia(stackServerApp);
*
* new Elysia()
* .resolve(hexclave.resolveUser) // ctx.user (+ ctx.hexclave)
* .get("/me", ({ user }) => user, { beforeHandle: hexclave.requireUser })
* .post("/checkout", hexclave.handler(async ({ user, hexclave }) => {
* // runs inside an `elysia.route` span linked to the caller's client session
* }), { beforeHandle: hexclave.requireUser });
* ```
*/
/** What `resolveUser` adds to the Elysia context. */
export type HexclaveElysiaContext = {
hexclave: HexclaveRequestContext,
user: AdapterUser | null,
};
type ElysiaRequestContext = {
request: Request,
path?: string,
set: { status?: number | string },
} & Partial<HexclaveElysiaContext> & Record<string, unknown>;
export type HexclaveElysiaHandlerOptions = {
/** Disable or customize the per-route span. @default true */
telemetry?: AdapterTelemetryOptions,
};
export function createHexclaveElysia(app: AdapterServerApp) {
const ensureContext = (ctx: ElysiaRequestContext): HexclaveRequestContext =>
ctx.hexclave ?? createRequestContext(app, ctx.request);
return {
/**
* `.resolve()` callback: adds the lazy per-request context and the resolved
* `user` (null when unauthenticated) to every route's context.
*/
resolveUser: async (ctx: { request: Request }): Promise<HexclaveElysiaContext> => {
const hexclave = createRequestContext(app, ctx.request);
return { hexclave, user: await hexclave.getUser() };
},
/** `beforeHandle` guard: short-circuits unauthenticated calls with a 401. */
requireUser: (ctx: ElysiaRequestContext) => {
if (!ctx.user) {
ctx.set.status = 401;
return { error: "You must be signed in to call this route. (Hexclave: no valid session on the request.)" };
}
},
/**
* Wraps a route handler in an `elysia.route` span linked to the caller's
* client session ({ path, method } in the span data). Everything created
* inside the handler trackEvent, child spans nests under it.
*/
handler: <TContext extends ElysiaRequestContext, TResult>(
fn: (ctx: TContext) => TResult | Promise<TResult>,
handlerOptions?: HexclaveElysiaHandlerOptions,
) => {
return async (ctx: TContext): Promise<TResult> => {
return await runRequestSpan(app, ensureContext(ctx), {
defaultSpanType: "elysia.route",
data: { path: ctx.path ?? new URL(ctx.request.url).pathname, method: ctx.request.method },
telemetry: handlerOptions?.telemetry,
}, async () => await fn(ctx));
};
},
};
}

View File

@ -0,0 +1,99 @@
import { AdapterServerApp, AdapterTelemetryOptions, AdapterUser, createRequestContext, HexclaveRequestContext, runRequestSpan } from "./adapter-core";
/**
* Hexclave adapter for oRPC. Zero runtime dependency on `orpc`: `middleware()`
* returns a plain middleware function for `.use(...)`, and `wrapFetchHandler`
* wraps an `RPCHandler`-like object so the raw `Request` reaches the middleware
* through the initial context (oRPC middlewares cannot see the Request
* otherwise).
*
* ```ts
* const hexclave = createHexclaveORPC(stackServerApp, {
* unauthorized: () => new ORPCError("UNAUTHORIZED"), // required for `required: true`
* });
*
* export const publicBase = os.$context<HexclaveORPCContext>().use(hexclave.middleware());
* export const protectedBase = publicBase.use(hexclave.middleware({ required: true }));
*
* const handler = new RPCHandler(router);
* export const handle = hexclave.wrapFetchHandler(handler, { prefix: "/rpc" });
* ```
*
* Every procedure then runs inside an `orpc.procedure` span linked to the
* caller's client session, with `context.user` resolved no `{ request }`
* plumbing.
*
* NOTE: oRPC's `ORPCError` is constructor-branded, so the adapter deliberately
* does NOT fake one structurally pass your own `unauthorized` factory (built
* from your `orpc` import) to use `required: true`.
*/
/** Shape of the initial context `wrapFetchHandler` injects / the middleware consumes+extends. */
export type HexclaveORPCContext = {
hexclave?: HexclaveRequestContext,
user?: AdapterUser | null,
};
type ORPCMiddlewareOpts = {
context: HexclaveORPCContext & Record<string, unknown>,
path?: readonly string[],
next: (opts?: { context?: Record<string, unknown> }) => Promise<unknown>,
};
export type HexclaveORPCMiddlewareOptions = {
/** Reject unauthenticated calls; `context.user` is then non-null. Requires an `unauthorized` factory. */
required?: boolean,
/** Disable or customize the per-procedure span. @default true */
telemetry?: AdapterTelemetryOptions,
/** Error factory for `required` rejections, e.g. `() => new ORPCError("UNAUTHORIZED")`. */
unauthorized?: () => Error,
};
export function createHexclaveORPC(app: AdapterServerApp, options?: {
unauthorized?: () => Error,
}) {
return {
/** A plain middleware for `.use(...)`: request-linked span + `context.user`. */
middleware: (middlewareOptions?: HexclaveORPCMiddlewareOptions) => {
const unauthorized = middlewareOptions?.unauthorized ?? options?.unauthorized;
if (middlewareOptions?.required && !unauthorized) {
throw new Error("Hexclave oRPC adapter: `required: true` needs an `unauthorized` error factory (e.g. () => new ORPCError(\"UNAUTHORIZED\")) — oRPC errors cannot be constructed without your orpc import.");
}
return async ({ context, path, next }: ORPCMiddlewareOpts) => {
const hexclave = context.hexclave ?? createRequestContext(app, null);
return await runRequestSpan(app, hexclave, {
defaultSpanType: "orpc.procedure",
data: { path: path?.join(".") ?? null },
telemetry: middlewareOptions?.telemetry,
}, async () => {
const user = await hexclave.getUser();
if (middlewareOptions?.required && user === null) {
throw unauthorized!();
}
return await next({ context: { hexclave, user } });
});
};
},
/**
* Wraps an oRPC fetch handler (`RPCHandler` / `OpenAPIHandler`) so every
* `handle(request, …)` call carries the per-request Hexclave context into
* the middlewares above. Extra per-call options pass through untouched.
*/
wrapFetchHandler: <THandler extends { handle: (request: Request, handleOptions?: Record<string, unknown>) => unknown }>(
handler: THandler,
defaultOptions?: Record<string, unknown>,
) => {
return (request: Request, handleOptions?: Record<string, unknown>): ReturnType<THandler["handle"]> => {
const merged = { ...defaultOptions, ...handleOptions };
return handler.handle(request, {
...merged,
context: {
...(merged.context as Record<string, unknown> | undefined) ?? {},
hexclave: createRequestContext(app, request),
},
}) as ReturnType<THandler["handle"]>;
};
},
};
}

View File

@ -0,0 +1,100 @@
import { AdapterServerApp, AdapterTelemetryOptions, AdapterUser, createRequestContext, HexclaveRequestContext, runRequestSpan } from "./adapter-core";
/**
* Hexclave adapter for tRPC (v11). Zero runtime dependency on `@trpc/server`:
* the factory is handed the consumer's `t` instance (from `initTRPC`) and only
* calls `t.middleware`; the `required` rejection is shaped like a `TRPCError`
* with code `UNAUTHORIZED` (tRPC's error normalization accepts Error objects
* named "TRPCError"), overridable via `unauthorized`.
*
* ```ts
* const t = initTRPC.context<HexclaveTRPCContext>().create();
* const hexclave = createHexclaveTRPC(t, stackServerApp);
*
* export const createContext = hexclave.createContext; // pass to your adapter
* export const publicProcedure = t.procedure.use(hexclave.middleware());
* export const protectedProcedure = t.procedure.use(hexclave.middleware({ required: true }));
* ```
*
* Every procedure then runs inside a `trpc.procedure` span linked to the
* caller's client session ({ path, type } in the span data), with `ctx.user`
* resolved (non-null under `required: true`) no `{ request }` plumbing.
*/
/** Shape of the context `createContext` produces / the middleware consumes+extends. */
export type HexclaveTRPCContext = {
hexclave?: HexclaveRequestContext,
user?: AdapterUser | null,
};
type TRPCMiddlewareOpts = {
ctx: HexclaveTRPCContext & Record<string, unknown>,
path: string,
type: string,
next: (opts?: { ctx?: Record<string, unknown> }) => Promise<unknown>,
};
/** The slice of a `initTRPC...create()` instance the adapter needs. */
export type TRPCInstanceLike = {
middleware: (fn: (opts: TRPCMiddlewareOpts) => Promise<unknown>) => unknown,
};
export type HexclaveTRPCMiddlewareOptions = {
/** Reject unauthenticated calls (UNAUTHORIZED); `ctx.user` is then non-null. */
required?: boolean,
/** Disable or customize the per-procedure span. @default true */
telemetry?: AdapterTelemetryOptions,
/** Fallback request extraction when `createContext` was not used. */
getRequest?: (ctx: Record<string, unknown>) => unknown,
/** Error factory for `required` rejections (e.g. `() => new TRPCError({ code: "UNAUTHORIZED" })`). */
unauthorized?: () => Error,
};
function defaultUnauthorized(): Error {
// Structurally a TRPCError with code UNAUTHORIZED — accepted by tRPC's error
// normalization without importing @trpc/server.
const error = new Error("You must be signed in to call this procedure. (Hexclave: no valid session on the request.)");
error.name = "TRPCError";
(error as Error & { code: string }).code = "UNAUTHORIZED";
return error;
}
export function createHexclaveTRPC<T extends TRPCInstanceLike>(t: T, app: AdapterServerApp, options?: {
/** Override how the request is pulled out of the adapter's context options. */
getRequest?: (contextOptions: Record<string, unknown>) => unknown,
unauthorized?: () => Error,
}) {
return {
/**
* Context builder to pass to the tRPC adapter (fetch adapter, Next.js, ).
* Normalizes `opts.req` / `opts.request` (both the fetch adapter's Request
* and Node's IncomingMessage are request-like) and stashes the lazy
* per-request Hexclave context.
*/
createContext: (contextOptions: Record<string, unknown>): HexclaveTRPCContext => {
const requestInput = options?.getRequest
? options.getRequest(contextOptions)
: contextOptions.req ?? contextOptions.request ?? null;
return { hexclave: createRequestContext(app, requestInput) };
},
/** A `t.middleware(...)` wrapping the procedure in a request-linked span + resolving `ctx.user`. */
middleware: (middlewareOptions?: HexclaveTRPCMiddlewareOptions): ReturnType<T["middleware"]> => {
return t.middleware(async ({ ctx, path, type, next }: TRPCMiddlewareOpts) => {
const hexclave = ctx.hexclave
?? createRequestContext(app, middlewareOptions?.getRequest?.(ctx) ?? null);
return await runRequestSpan(app, hexclave, {
defaultSpanType: "trpc.procedure",
data: { path, type },
telemetry: middlewareOptions?.telemetry,
}, async () => {
const user = await hexclave.getUser();
if (middlewareOptions?.required && user === null) {
throw (middlewareOptions.unauthorized ?? options?.unauthorized ?? defaultUnauthorized)();
}
return await next({ ctx: { hexclave, user } });
});
}) as ReturnType<T["middleware"]>;
},
};
}

View File

@ -1,7 +1,10 @@
import createJsLibraryTsupConfig from '../../configs/tsdown/js-library.ts';
export default createJsLibraryTsupConfig({ barrelFiles: [
"src/index.ts",
"src/index.ts",
"src/integrations/convex/component/convex.config.ts",
"src/integrations/convex.ts",
"src/integrations/trpc.ts",
"src/integrations/orpc.ts",
"src/integrations/elysia.ts",
] });