release: WRNexusJS 0.3.0
This commit is contained in:
@@ -6,6 +6,9 @@
|
||||
* it can later be reused by the `.wrn` compiler output.
|
||||
*/
|
||||
|
||||
import type { Tenant } from "./tenant.ts";
|
||||
import type { Tracer } from "./observability.ts";
|
||||
|
||||
import {
|
||||
applyCookieHeaders,
|
||||
createCookieStore,
|
||||
@@ -40,6 +43,10 @@ export type Context = {
|
||||
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
|
||||
*/
|
||||
user?: unknown;
|
||||
/** Active tenant/workspace resolved by tenant middleware. */
|
||||
tenant?: Tenant;
|
||||
/** Request tracer installed by observability middleware. */
|
||||
tracer?: Tracer;
|
||||
/**
|
||||
* The direct socket peer IP, set by the server from `server.requestIP`. This
|
||||
* is NOT spoofable by request headers — prefer it over `x-forwarded-for` for
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Context } from "./context.ts";
|
||||
|
||||
export interface CachePolicy {
|
||||
ttlMs?: number;
|
||||
staleWhileRevalidateMs?: number;
|
||||
tags?: string[] | ((ctx: Context) => string[]);
|
||||
}
|
||||
|
||||
export interface LoaderDefinition<T> {
|
||||
cache?: CachePolicy;
|
||||
load(ctx: Context): T | Promise<T>;
|
||||
}
|
||||
|
||||
export interface ActionDefinition<I, O> {
|
||||
csrf?: boolean;
|
||||
run(input: I, ctx: Context): O | Promise<O>;
|
||||
invalidate?: string[] | ((output: O, ctx: Context) => string[]);
|
||||
}
|
||||
|
||||
export interface DefinedLoader<T> {
|
||||
readonly definition: LoaderDefinition<T>;
|
||||
(ctx: Context): Promise<T>;
|
||||
}
|
||||
|
||||
export interface DefinedAction<I, O> {
|
||||
readonly definition: ActionDefinition<I, O>;
|
||||
(input: I, ctx: Context): Promise<O>;
|
||||
}
|
||||
|
||||
export function defineLoader<T>(definition: LoaderDefinition<T>): DefinedLoader<T> {
|
||||
return Object.assign(async (ctx: Context) => definition.load(ctx), { definition });
|
||||
}
|
||||
|
||||
export function defineAction<I, O>(definition: ActionDefinition<I, O>): DefinedAction<I, O> {
|
||||
return Object.assign(async (input: I, ctx: Context) => definition.run(input, ctx), {
|
||||
definition,
|
||||
});
|
||||
}
|
||||
|
||||
/** Request-local fetch deduplication keyed by a stable string. */
|
||||
export async function dedupe<T>(ctx: Context, key: string, load: () => T | Promise<T>): Promise<T> {
|
||||
const bucket = (ctx.locals.__wrnexusData ??= new Map<string, Promise<unknown>>()) as Map<
|
||||
string,
|
||||
Promise<unknown>
|
||||
>;
|
||||
const existing = bucket.get(key);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const pending = Promise.resolve().then(load);
|
||||
bucket.set(key, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
bucket.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Context } from "./context.ts";
|
||||
|
||||
export interface SchemaLike<T> {
|
||||
parse(input: unknown): T;
|
||||
}
|
||||
|
||||
export interface EndpointErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export class EndpointError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "EndpointError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface EndpointDefinition<I, O> {
|
||||
input?: SchemaLike<I>;
|
||||
output?: SchemaLike<O>;
|
||||
auth?: "optional" | "required";
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
handler(input: I, ctx: Context): O | Promise<O>;
|
||||
}
|
||||
|
||||
export interface DefinedEndpoint<I, O> {
|
||||
readonly definition: EndpointDefinition<I, O>;
|
||||
(ctx: Context, input?: unknown): Promise<Response>;
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Define a validated, typed endpoint that can also drive SDK/OpenAPI generation. */
|
||||
export function defineEndpoint<I = unknown, O = unknown>(
|
||||
definition: EndpointDefinition<I, O>,
|
||||
): DefinedEndpoint<I, O> {
|
||||
const endpoint = async (ctx: Context, rawInput?: unknown): Promise<Response> => {
|
||||
try {
|
||||
if (definition.auth === "required" && !ctx.user) {
|
||||
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
||||
}
|
||||
const input = definition.input ? definition.input.parse(rawInput) : (rawInput as I);
|
||||
const rawOutput = await definition.handler(input, ctx);
|
||||
const output = definition.output ? definition.output.parse(rawOutput) : rawOutput;
|
||||
return output instanceof Response ? output : json({ data: output });
|
||||
} catch (error) {
|
||||
if (error instanceof EndpointError) {
|
||||
return json(
|
||||
{ error: { code: error.code, message: error.message, details: error.details } },
|
||||
error.status,
|
||||
);
|
||||
}
|
||||
return json(
|
||||
{
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "The endpoint failed unexpectedly.",
|
||||
} satisfies EndpointErrorBody,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
};
|
||||
return Object.assign(endpoint, { definition });
|
||||
}
|
||||
|
||||
export interface RpcClientOptions {
|
||||
baseUrl?: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
||||
}
|
||||
|
||||
/** Create a tiny typed RPC caller for endpoints exposed by a WrNexus app. */
|
||||
export function createRpcClient(options: RpcClientOptions = {}) {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
return async function call<I, O>(path: string, input: I): Promise<O> {
|
||||
const headers =
|
||||
typeof options.headers === "function" ? await options.headers() : (options.headers ?? {});
|
||||
const response = await request(new URL(path, options.baseUrl ?? globalThis.location?.origin), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = (await response.json()) as { data?: O; error?: EndpointErrorBody };
|
||||
if (!response.ok || body.error) {
|
||||
throw new EndpointError(
|
||||
response.status,
|
||||
body.error?.code ?? "RPC_ERROR",
|
||||
body.error?.message ?? `RPC request failed with ${response.status}`,
|
||||
body.error?.details,
|
||||
);
|
||||
}
|
||||
return body.data as O;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Context } from "./context.ts";
|
||||
|
||||
export type FeatureValue = boolean | string | number;
|
||||
export type FeatureRule = FeatureValue | ((ctx: Context) => FeatureValue | Promise<FeatureValue>);
|
||||
|
||||
export interface FeatureFlags {
|
||||
get(name: string, ctx: Context): Promise<FeatureValue | undefined>;
|
||||
enabled(name: string, ctx: Context): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function defineFeatureFlags(rules: Record<string, FeatureRule>): FeatureFlags {
|
||||
return {
|
||||
async get(name, ctx) {
|
||||
const rule = rules[name];
|
||||
return typeof rule === "function" ? rule(ctx) : rule;
|
||||
},
|
||||
async enabled(name, ctx) {
|
||||
return (await this.get(name, ctx)) === true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -104,3 +104,33 @@ export { setSessionBackend, loadSession } from "./storage.ts";
|
||||
|
||||
export { Fragment, Html, jsx, jsxs, mustache } from "./jsx-runtime.ts";
|
||||
export type { Component as JSXComponent, Props as JSXProps, Renderable } from "./jsx-runtime.ts";
|
||||
|
||||
export { defineEndpoint, createRpcClient, EndpointError } from "./endpoint.ts";
|
||||
export type {
|
||||
DefinedEndpoint,
|
||||
EndpointDefinition,
|
||||
EndpointErrorBody,
|
||||
RpcClientOptions,
|
||||
SchemaLike,
|
||||
} from "./endpoint.ts";
|
||||
|
||||
export { defineAction, defineLoader, dedupe } from "./data.ts";
|
||||
export type {
|
||||
ActionDefinition,
|
||||
CachePolicy,
|
||||
DefinedAction,
|
||||
DefinedLoader,
|
||||
LoaderDefinition,
|
||||
} from "./data.ts";
|
||||
|
||||
export { requireTenant, tenantFromSubdomain, tenantMiddleware, tenantScope } from "./tenant.ts";
|
||||
export type { Tenant, TenantMiddlewareOptions, TenantResolver } from "./tenant.ts";
|
||||
|
||||
export { createTracer, tracingMiddleware, withSpan } from "./observability.ts";
|
||||
export type { Span, SpanRecord, Tracer } from "./observability.ts";
|
||||
|
||||
export { defineFeatureFlags } from "./features.ts";
|
||||
export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts";
|
||||
|
||||
export { checkPerformanceBudgets } from "./performance.ts";
|
||||
export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts";
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export interface SpanRecord {
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
durationMs?: number;
|
||||
status?: "ok" | "error";
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface Tracer {
|
||||
startSpan(name: string, attributes?: SpanRecord["attributes"]): Span;
|
||||
records(): readonly SpanRecord[];
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
setAttribute(name: string, value: string | number | boolean): void;
|
||||
end(status?: "ok" | "error", error?: unknown): SpanRecord;
|
||||
}
|
||||
|
||||
export function createTracer(clock: () => number = () => performance.now()): Tracer {
|
||||
const spans: SpanRecord[] = [];
|
||||
return {
|
||||
startSpan(name, attributes = {}) {
|
||||
const record: SpanRecord = { name, startTime: clock(), attributes: { ...attributes } };
|
||||
spans.push(record);
|
||||
let ended = false;
|
||||
return {
|
||||
setAttribute(key, value) {
|
||||
record.attributes[key] = value;
|
||||
},
|
||||
end(status = "ok", error) {
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
record.endTime = clock();
|
||||
record.durationMs = record.endTime - record.startTime;
|
||||
record.status = status;
|
||||
record.error = error;
|
||||
}
|
||||
return record;
|
||||
},
|
||||
};
|
||||
},
|
||||
records: () => spans,
|
||||
};
|
||||
}
|
||||
|
||||
export async function withSpan<T>(
|
||||
tracer: Tracer,
|
||||
name: string,
|
||||
run: (span: Span) => T | Promise<T>,
|
||||
attributes?: SpanRecord["attributes"],
|
||||
): Promise<T> {
|
||||
const span = tracer.startSpan(name, attributes);
|
||||
try {
|
||||
const result = await run(span);
|
||||
span.end("ok");
|
||||
return result;
|
||||
} catch (error) {
|
||||
span.end("error", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TracingMiddlewareOptions {
|
||||
/** Include W3C Server-Timing response headers. Defaults to true. */
|
||||
serverTiming?: boolean;
|
||||
/** Fraction of requests to trace, from 0 to 1. Defaults to 1. */
|
||||
sampleRate?: number;
|
||||
/** Called after a traced response completes. */
|
||||
onComplete?: (ctx: Context, records: readonly SpanRecord[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function tracingMiddleware(
|
||||
tracerFactory: (ctx: Context) => Tracer = () => createTracer(),
|
||||
options: TracingMiddlewareOptions = {},
|
||||
): Middleware {
|
||||
const sampleRate = Math.max(0, Math.min(1, options.sampleRate ?? 1));
|
||||
return async (ctx, next) => {
|
||||
if (sampleRate === 0 || (sampleRate < 1 && Math.random() > sampleRate)) return next();
|
||||
|
||||
const tracer = tracerFactory(ctx);
|
||||
ctx.tracer = tracer;
|
||||
const response = await withSpan(tracer, "http.request", () => next(), {
|
||||
method: ctx.req.method,
|
||||
path: ctx.url.pathname,
|
||||
});
|
||||
const records = tracer.records();
|
||||
await options.onComplete?.(ctx, records);
|
||||
|
||||
if (options.serverTiming === false) return response;
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
const timings = records
|
||||
.filter((record) => record.durationMs !== undefined)
|
||||
.map(
|
||||
(record, index) =>
|
||||
`wrn${index};dur=${record.durationMs!.toFixed(2)};desc="${record.name.replace(/"/g, "")}"`,
|
||||
);
|
||||
if (timings.length) headers.set("server-timing", timings.join(", "));
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface PerformanceBudgets {
|
||||
routeJsBytes?: number;
|
||||
routeCssBytes?: number;
|
||||
htmlBytes?: number;
|
||||
imageBytes?: number;
|
||||
hydrationMs?: number;
|
||||
serverRenderMs?: number;
|
||||
}
|
||||
|
||||
export interface PerformanceMeasurement {
|
||||
routeJsBytes?: number;
|
||||
routeCssBytes?: number;
|
||||
htmlBytes?: number;
|
||||
imageBytes?: number;
|
||||
hydrationMs?: number;
|
||||
serverRenderMs?: number;
|
||||
}
|
||||
|
||||
export interface BudgetViolation {
|
||||
metric: keyof PerformanceBudgets;
|
||||
budget: number;
|
||||
actual: number;
|
||||
overBy: number;
|
||||
}
|
||||
|
||||
export function checkPerformanceBudgets(
|
||||
budgets: PerformanceBudgets,
|
||||
measurement: PerformanceMeasurement,
|
||||
): BudgetViolation[] {
|
||||
const violations: BudgetViolation[] = [];
|
||||
for (const metric of Object.keys(budgets) as Array<keyof PerformanceBudgets>) {
|
||||
const budget = budgets[metric];
|
||||
const actual = measurement[metric];
|
||||
if (budget === undefined || actual === undefined || actual <= budget) continue;
|
||||
violations.push({ metric, budget, actual, overBy: actual - budget });
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TenantResolver = (ctx: Context) => Tenant | null | Promise<Tenant | null>;
|
||||
|
||||
export interface TenantMiddlewareOptions {
|
||||
required?: boolean;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export function tenantMiddleware(
|
||||
resolveTenant: TenantResolver,
|
||||
options: TenantMiddlewareOptions = {},
|
||||
): Middleware {
|
||||
return async (ctx, next) => {
|
||||
const tenant = await resolveTenant(ctx);
|
||||
ctx.tenant = tenant ?? undefined;
|
||||
if (!tenant && options.required !== false) {
|
||||
return new Response("Tenant not found", { status: options.status ?? 404 });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export function tenantFromSubdomain(
|
||||
lookup: (slug: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
|
||||
rootDomains: string[] = [],
|
||||
): TenantResolver {
|
||||
return async (ctx) => {
|
||||
const host = ctx.url.hostname.toLowerCase();
|
||||
const root = rootDomains.find((domain) => host === domain || host.endsWith(`.${domain}`));
|
||||
const slug = root ? host.slice(0, -(root.length + 1)) : host.split(".")[0];
|
||||
if (!slug || slug === host || slug === "www") return null;
|
||||
return lookup(slug, ctx);
|
||||
};
|
||||
}
|
||||
|
||||
export function requireTenant(ctx: Context): Tenant {
|
||||
if (!ctx.tenant)
|
||||
throw new Error("WRN-TENANT-REQUIRED: tenant middleware has not resolved a tenant.");
|
||||
return ctx.tenant;
|
||||
}
|
||||
|
||||
/** Wrap a repository so every operation receives the current tenant id. */
|
||||
export function tenantScope<T extends object>(
|
||||
tenant: Tenant,
|
||||
repository: T,
|
||||
): T & { tenantId: string } {
|
||||
return Object.assign(Object.create(repository), { tenantId: tenant.id });
|
||||
}
|
||||
Reference in New Issue
Block a user