Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ import { storyHtml, when } from '@bquery/bquery/storybook';
| **Devtools** | Beta | Runtime inspection helpers for signals, stores, components, and timelines |
| **Testing** | Beta | Component mounting, mock signals/router helpers, and async test utilities |
| **SSR** | Experimental | Runtime-agnostic server-side rendering (Node ≥ 24, Deno, Bun), streaming, async loaders, hydration islands, head/asset/CSP-nonce management, runtime adapters |
| **Server** | Experimental | Express-inspired backend routing, middleware, safe response helpers, SSR-aware request handling, and runtime-agnostic WebSocket sessions |
| **Server** | Experimental | Express-inspired backend routing, middleware, safe responses, SSR-aware requests, sessions, CSRF, guards, auth, and WebSocket sessions (targeting Stable in 1.15.0) |

Storybook authoring helpers are also available as a dedicated entry point via `@bquery/bquery/storybook`. Worker-task, RPC, worker-pool, high-level task-list / collection helpers, and the optional fluent pipeline layer ship as a dedicated entry point via `@bquery/bquery/concurrency`. Server-side middleware, HTTP routing, and runtime-agnostic WebSocket session helpers ship as a dedicated entry point via `@bquery/bquery/server`.

Expand Down Expand Up @@ -913,10 +913,10 @@ mount('#app', {

| Browser | Version | Support |
| ------- | ------- | ------- |
| Chrome | 90+ | ✅ Full |
| Firefox | 90+ | ✅ Full |
| Safari | 15+ | ✅ Full |
| Edge | 90+ | ✅ Full |
| Chrome | 90+ | ✅ Full |
| Firefox | 90+ | ✅ Full |
| Safari | 15+ | ✅ Full |
| Edge | 90+ | ✅ Full |

> **No IE support** by design.
>
Expand Down
229 changes: 198 additions & 31 deletions docs/guide/server.md

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

bQuery.js is a **batteries-included TypeScript framework for the modern web**. It brings the directness and ergonomics of jQuery's API to fine-grained reactivity, Web Components, SPA routing, state management, motion, accessibility, i18n, drag-and-drop, server-side rendering, and a dependency-free backend — all in one modular system with **zero runtime dependencies**.

> If you only need a quick installation walkthrough, jump to **[Getting Started](/guide/getting-started)**. This page explains the *why*.
> If you only need a quick installation walkthrough, jump to **[Getting Started](/guide/getting-started)**. This page explains the _why_.

## Design goals

Expand All @@ -24,22 +24,24 @@ These are explicitly **not** goals — calling them out so expectations stay cal
- **All-in-one mega-package.** Each module is independently importable; the root entry is a curated convenience, not an unavoidable surface.
- **A new build tool.** bQuery integrates with Vite, Rollup, esbuild, tsup, Rspack, and webpack. It does not ship one of its own.
- **A CSS framework.** Styling is left to the host project. The library is style-agnostic.
- **Drop-in jQuery compatibility.** The API is *inspired by* jQuery, not a clone of it. See the [Migration Guide](/guide/migration) for a translation table.
- **Drop-in jQuery compatibility.** The API is _inspired by_ jQuery, not a clone of it. See the [Migration Guide](/guide/migration) for a translation table.

## Stability matrix

bQuery follows semver. The maturity of each module today:

| Status | Modules |
| -------------- | ------------------------------------------------------------------------------------------------------- |
| **Stable** | `core`, `reactive`, `security`, `component`, `motion`, `platform`, `router`, `store` |
| **Beta** | `view`, `forms`, `i18n`, `a11y`, `dnd`, `media`, `plugin`, `devtools`, `testing`, `storybook` |
| **Experimental** | `ssr`, `server`, `concurrency` |
| Status | Modules |
| ---------------- | --------------------------------------------------------------------------------------------- |
| **Stable** | `core`, `reactive`, `security`, `component`, `motion`, `platform`, `router`, `store` |
| **Beta** | `view`, `forms`, `i18n`, `a11y`, `dnd`, `media`, `plugin`, `devtools`, `testing`, `storybook` |
| **Experimental** | `ssr`, `server`, `concurrency` |

Stable modules will not introduce breaking changes between minor releases. Beta and experimental modules may evolve faster — breaking changes for those are flagged in the [Release Notes](/release-notes/).

`ssr` is **targeting Stable in 1.15.0**: its substantive prerequisites (directive parity, resumability, production hydration) are resolved, and its public surface is now frozen for one minor cycle. See the [SSR Stability section](/guide/ssr) for the exit-criteria checklist, frozen surface, and per-runtime support matrix.

`server` is also **targeting Stable in 1.15.0**: its session/middleware prerequisite is resolved (first-party sessions, CSRF, guards, and auth helpers), and the `ctx`/`app` contract is now frozen for one minor cycle. See the [Server Stability section](/guide/server) for the exit-criteria checklist, frozen surface, and per-runtime support matrix.

## When to use bQuery

bQuery is a good fit when you want:
Expand Down
24 changes: 24 additions & 0 deletions src/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,16 +1193,36 @@ export type {
export {
ServerHttpError,
badRequest,
base64UrlDecode,
base64UrlEncode,
basicAuth,
bearerAuth,
conflict,
createServer,
csrf,
csrfToken,
forbidden,
guard,
isServerWebSocketSession,
isWebSocketRequest,
memoryStore,
notFound,
randomId,
randomToken,
session,
signValue,
timingSafeEqual,
unauthorized,
unsignValue,
} from './server/index';
export type {
BasicAuthCredentials,
BasicAuthOptions,
BearerAuthOptions,
CreateServerOptions,
CsrfOptions,
GuardOptions,
MemoryStoreOptions,
ServerApp,
ServerCookieOptions,
ServerContext,
Expand All @@ -1219,6 +1239,7 @@ export type {
ServerResponseInit,
ServerResult,
ServerRoute,
ServerSession,
ServerSseEvent,
ServerSseOptions,
ServerWebSocketConnection,
Expand All @@ -1229,4 +1250,7 @@ export type {
ServerWebSocketPeer,
ServerWebSocketRouteHandler,
ServerWebSocketSession,
SessionData,
SessionOptions,
SessionStore,
} from './server/index';
137 changes: 137 additions & 0 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Minimal authentication helpers for the server module.
*
* These are deliberately small primitives — credential-verification hooks, not a
* turnkey identity provider. Each parses an `Authorization` header, delegates the
* actual credential check to a user-supplied `verify` callback, and stores the
* resolved user on `ctx.state` for downstream handlers and {@link guard}s.
*
* @module bquery/server
*/

import { base64UrlDecode } from './crypto';
import type { ServerContext, ServerMiddleware } from './types';

const DEFAULT_STATE_KEY = 'user';

/** Username/password pair parsed from a Basic `Authorization` header. */
export interface BasicAuthCredentials {
username: string;
password: string;
}

/** Options for {@link basicAuth}. */
export interface BasicAuthOptions {
/**
* Verify the supplied credentials. Return a truthy user object to authenticate
* (stored on `ctx.state[stateKey]`), or a falsy value to reject with `401`.
*/
verify: (credentials: BasicAuthCredentials, ctx: ServerContext) => unknown | Promise<unknown>;
/** Realm advertised in the `WWW-Authenticate` challenge. Default `'Restricted'`. */
realm?: string;
/** `ctx.state` key the resolved user is written to. Default `'user'`. */
stateKey?: string;
}

/** Options for {@link bearerAuth}. */
export interface BearerAuthOptions {
/**
* Verify the bearer token. Return a truthy user/principal to authenticate, or
* a falsy value to reject with `401`.
*/
verify: (token: string, ctx: ServerContext) => unknown | Promise<unknown>;
/** `ctx.state` key the resolved principal is written to. Default `'user'`. */
stateKey?: string;
/** Authentication scheme name. Default `'Bearer'`. */
scheme?: string;
}

const sanitizeRealm = (realm: string): string => realm.replace(/["\r\n]/g, '');

/**
* HTTP Basic authentication middleware.
*
* @example
* ```ts
* app.use(basicAuth({ verify: ({ username, password }) => username === 'admin' && password === secret }));
* ```
*/
export const basicAuth = (options: BasicAuthOptions): ServerMiddleware => {
const realm = sanitizeRealm(options.realm ?? 'Restricted');
const stateKey = options.stateKey ?? DEFAULT_STATE_KEY;

return async (ctx, next) => {
const challenge = (): Response =>
ctx.text('Unauthorized', {
status: 401,
headers: { 'www-authenticate': `Basic realm="${realm}", charset="UTF-8"` },
});

const header = ctx.request.headers.get('authorization') ?? '';
const separator = header.indexOf(' ');
const scheme = separator === -1 ? header : header.slice(0, separator);
const encoded = separator === -1 ? '' : header.slice(separator + 1).trim();
if (scheme.toLowerCase() !== 'basic' || !encoded) {
return challenge();
}

const bytes = base64UrlDecode(encoded);
if (!bytes) {
return challenge();
}
const decoded = new TextDecoder().decode(bytes);
const colon = decoded.indexOf(':');
if (colon === -1) {
return challenge();
}

const credentials: BasicAuthCredentials = {
username: decoded.slice(0, colon),
password: decoded.slice(colon + 1),
};
const user = await options.verify(credentials, ctx);
if (!user) {
return challenge();
}

ctx.state[stateKey] = user === true ? { username: credentials.username } : user;
return next();
};
};

/**
* HTTP Bearer-token authentication middleware.
*
* @example
* ```ts
* app.use(bearerAuth({ verify: (token) => verifyJwt(token) }));
* ```
*/
export const bearerAuth = (options: BearerAuthOptions): ServerMiddleware => {
const stateKey = options.stateKey ?? DEFAULT_STATE_KEY;
const scheme = options.scheme ?? 'Bearer';

return async (ctx, next) => {
const challenge = (): Response =>
ctx.text('Unauthorized', {
status: 401,
headers: { 'www-authenticate': scheme },
});

const header = ctx.request.headers.get('authorization') ?? '';
const separator = header.indexOf(' ');
const headerScheme = separator === -1 ? header : header.slice(0, separator);
const token = separator === -1 ? '' : header.slice(separator + 1).trim();
if (headerScheme.toLowerCase() !== scheme.toLowerCase() || !token) {
return challenge();
}

const user = await options.verify(token, ctx);
if (!user) {
return challenge();
}

ctx.state[stateKey] = user === true ? { token } : user;
return next();
};
};
102 changes: 102 additions & 0 deletions src/server/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Cookie serialization helpers shared by the request context, session, and CSRF
* middleware.
*
* @module bquery/server
*/

import type { ServerCookieOptions } from './types';

const COOKIE_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const COOKIE_SAME_SITE_LOOKUP = {
lax: 'Lax',
none: 'None',
strict: 'Strict',
} as const;

const assertCookieName = (name: string): void => {
if (!COOKIE_NAME_PATTERN.test(name)) {
throw new TypeError('Cookie name contains invalid characters.');
}
};

/**
* Reject control characters (0x00–0x1F, 0x7F) and `;` in cookie attribute
* values so request-controlled data cannot inject extra cookie attributes.
*
* @internal
*/
const hasInvalidCookieAttributeChar = (value: string): boolean => {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f || code === 0x3b) {
return true;
}
}
return false;
};

const assertCookieAttributeValue = (label: string, value: string): string => {
if (hasInvalidCookieAttributeChar(value)) {
throw new TypeError(`Cookie ${label} contains invalid characters.`);
}
return value;
};

/**
* Serialize a `Set-Cookie` header value, validating the name and attribute
* values so request-controlled data can never inject extra cookie attributes.
*/
export const serializeCookie = (
name: string,
value: string,
options: ServerCookieOptions = {}
): string => {
assertCookieName(name);
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.path) parts.push(`Path=${assertCookieAttributeValue('path', options.path)}`);
if (options.domain) parts.push(`Domain=${assertCookieAttributeValue('domain', options.domain)}`);
if (typeof options.maxAge === 'number' && Number.isFinite(options.maxAge)) {
parts.push(`Max-Age=${Math.trunc(options.maxAge)}`);
}
if (options.sameSite) {
if (typeof options.sameSite !== 'string') {
throw new TypeError('Cookie sameSite must be one of "lax", "none", or "strict".');
}
const sameSite =
COOKIE_SAME_SITE_LOOKUP[
options.sameSite.toLowerCase() as keyof typeof COOKIE_SAME_SITE_LOOKUP
];
if (!sameSite) {
throw new TypeError('Cookie sameSite must be one of "lax", "none", or "strict".');
}
parts.push(`SameSite=${sameSite}`);
}
if (options.httpOnly) parts.push('HttpOnly');
if (options.secure) parts.push('Secure');
return parts.join('; ');
};

/**
* Append a `Set-Cookie` header to an existing response without collapsing it
* into other cookies.
*
* Responses built by `createServer()` expose mutable headers, so the cookie is
* appended in place. When the headers are immutable (e.g. a response produced by
* `Response.redirect()`), the response is reconstructed around the same body so
* the cookie is still emitted.
*/
export const appendSetCookie = (response: Response, cookie: string): Response => {
try {
response.headers.append('set-cookie', cookie);
return response;
} catch {
const headers = new Headers(response.headers);
headers.append('set-cookie', cookie);
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
};
Loading