diff --git a/packages/js/package.json b/packages/js/package.json index fa5f8768b..b297df7db 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -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", diff --git a/packages/next/package.json b/packages/next/package.json index b449dca81..b0e1842f6 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -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", diff --git a/packages/react/package.json b/packages/react/package.json index 7e4512590..ac5d6ba57 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index fe21c9f62..12262eb2d 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -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", diff --git a/packages/template/package-template.json b/packages/template/package-template.json index 40c532480..996a895db 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -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", diff --git a/packages/template/package.json b/packages/template/package.json index a63ee66c9..496c7e19f 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -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", diff --git a/packages/template/src/integrations/adapter-core.ts b/packages/template/src/integrations/adapter-core.ts new file mode 100644 index 000000000..087b3013d --- /dev/null +++ b/packages/template/src/integrations/adapter-core.ts @@ -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; + +/** 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 }`) 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, +}; + +export function createRequestContext(app: AdapterServerApp, requestInput: unknown): HexclaveRequestContext { + const request = normalizeRequestLike(requestInput); + let userPromise: Promise | null = null; + return { + request, + getUser: () => { + userPromise ??= request === null + ? Promise.resolve(null) + : (app.getUser as (options: { tokenStore: RequestLike, or: "return-null" }) => Promise)({ 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, +}; + +/** + * 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( + app: AdapterServerApp, + context: HexclaveRequestContext, + info: { + defaultSpanType: string, + data: Record, + telemetry: AdapterTelemetryOptions | undefined, + }, + fn: (span: Span | null) => Promise, +): Promise { + 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), + ); +} diff --git a/packages/template/src/integrations/adapters.test.ts b/packages/template/src/integrations/adapters.test.ts new file mode 100644 index 000000000..e59f517c7 --- /dev/null +++ b/packages/template/src/integrations/adapters.test.ts @@ -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): 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) => fn }; + + function callMiddleware(middleware: unknown, ctx: Record) { + const next = vi.fn(async (opts?: { ctx?: Record }) => ({ nextCtx: opts?.ctx })); + const result = (middleware as (opts: unknown) => Promise)({ 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; + 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; + 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; + 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 }[] = []; + const handler = { + handle: (request: Request, options?: Record) => { + captured.push(options as { context?: Record }); + 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 }) => 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(); + }); +}); diff --git a/packages/template/src/integrations/convex.ts b/packages/template/src/integrations/convex.ts index d5f720f51..ac6553b75 100644 --- a/packages/template/src/integrations/convex.ts +++ b/packages/template/src/integrations/convex.ts @@ -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( + app: AdapterServerApp, + handler: (options: { ctx: TCtx, args: TArgs, user: AdapterUser | null }) => TResult | Promise, + options?: HexclaveConvexFunctionOptions, +): (ctx: TCtx, args: TArgs) => Promise { + return async (ctx: TCtx, args: TArgs): Promise => { + const user = await (app.getUser as (options: { from: "convex", ctx: ConvexCtx, or: "return-null" }) => Promise)({ 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(), + ); + }; +} diff --git a/packages/template/src/integrations/elysia.ts b/packages/template/src/integrations/elysia.ts new file mode 100644 index 000000000..05d072cdf --- /dev/null +++ b/packages/template/src/integrations/elysia.ts @@ -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 & Record; + +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 => { + 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: ( + fn: (ctx: TContext) => TResult | Promise, + handlerOptions?: HexclaveElysiaHandlerOptions, + ) => { + return async (ctx: TContext): Promise => { + 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)); + }; + }, + }; +} diff --git a/packages/template/src/integrations/orpc.ts b/packages/template/src/integrations/orpc.ts new file mode 100644 index 000000000..6480956b1 --- /dev/null +++ b/packages/template/src/integrations/orpc.ts @@ -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().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, + path?: readonly string[], + next: (opts?: { context?: Record }) => Promise, +}; + +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: ) => unknown }>( + handler: THandler, + defaultOptions?: Record, + ) => { + return (request: Request, handleOptions?: Record): ReturnType => { + const merged = { ...defaultOptions, ...handleOptions }; + return handler.handle(request, { + ...merged, + context: { + ...(merged.context as Record | undefined) ?? {}, + hexclave: createRequestContext(app, request), + }, + }) as ReturnType; + }; + }, + }; +} diff --git a/packages/template/src/integrations/trpc.ts b/packages/template/src/integrations/trpc.ts new file mode 100644 index 000000000..4a8e777d3 --- /dev/null +++ b/packages/template/src/integrations/trpc.ts @@ -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().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, + path: string, + type: string, + next: (opts?: { ctx?: Record }) => Promise, +}; + +/** The slice of a `initTRPC...create()` instance the adapter needs. */ +export type TRPCInstanceLike = { + middleware: (fn: (opts: TRPCMiddlewareOpts) => Promise) => 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) => 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: T, app: AdapterServerApp, options?: { + /** Override how the request is pulled out of the adapter's context options. */ + getRequest?: (contextOptions: Record) => 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): 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 => { + 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; + }, + }; +} diff --git a/packages/template/tsdown.config.ts b/packages/template/tsdown.config.ts index 16c082aab..41be32f63 100644 --- a/packages/template/tsdown.config.ts +++ b/packages/template/tsdown.config.ts @@ -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", ] });