@webjsdev/server 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -205,6 +205,21 @@ export interface ServerHandle {
205
205
  * Create an embeddable request handler. Returns `{ handle, rebuild, routeFor,
206
206
  * warmup, getRouteTable, appDir, dev, logger }`. Throws at boot on an
207
207
  * unsupported Node version or a failed `env.{js,ts}` validation.
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * // test/home.test.ts, run with `webjs test`: the whole pipeline, no server, no network
212
+ * import { test } from 'node:test';
213
+ * import assert from 'node:assert/strict';
214
+ * import { createRequestHandler } from '@webjsdev/server';
215
+ *
216
+ * test('the home page renders', async () => {
217
+ * const app = await createRequestHandler({ appDir: process.cwd() });
218
+ * const res = await app.handle(new Request('http://localhost/'));
219
+ * assert.equal(res.status, 200);
220
+ * assert.match(await res.text(), /<h1/);
221
+ * });
222
+ * ```
208
223
  */
209
224
  export declare function createRequestHandler(opts: RequestHandlerOptions): Promise<RequestHandler>;
210
225
 
@@ -237,7 +252,25 @@ export declare function assertNodeVersion(opts?: {
237
252
  // env-schema.js (#236)
238
253
  // ---------------------------------------------------------------------------
239
254
 
240
- /** Pure env validator: check an env object against a schema or validator function. */
255
+ /**
256
+ * Pure env validator: check an env object against a schema or validator function.
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * // env.ts at the app root: WebJs runs validateEnv(schema, process.env) at boot
261
+ * // and refuses to start, listing every missing or invalid variable at once.
262
+ * export default {
263
+ * DATABASE_URL: 'url',
264
+ * AUTH_SECRET: { type: 'string', minLength: 32 },
265
+ * PORT: { type: 'number', default: 3000 }, // a default makes it optional
266
+ * NODE_ENV: { type: 'enum', values: ['development', 'production', 'test'] },
267
+ * };
268
+ *
269
+ * // Calling it yourself (a test, a script) returns the verdict instead of throwing:
270
+ * import { validateEnv } from '@webjsdev/server';
271
+ * const { ok, errors } = validateEnv({ API_KEY: 'string' }, process.env);
272
+ * ```
273
+ */
241
274
  export declare function validateEnv(
242
275
  schema: object | ((env: Record<string, string | undefined>) => void),
243
276
  env: Record<string, string | undefined>,
@@ -363,6 +396,22 @@ export declare function invokeAction(
363
396
  * optional boundary validator, and dispatches through the request abort signal
364
397
  * + the optional middleware chain. The always-works baseline is a hand-written
365
398
  * `route.ts` that imports and calls the action; this is the convenience.
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * // app/api/posts/[id]/route.ts: GET /api/posts/7 calls getPost({ id: '7' })
403
+ * import { route } from '@webjsdev/server';
404
+ * import { getPost } from '#modules/posts/actions/get-post.server.ts'; // 'use server' actions
405
+ * import { updatePost } from '#modules/posts/actions/update-post.server.ts';
406
+ *
407
+ * export const GET = route(getPost); // the result is sent as JSON, a returned Response as is
408
+ * export const PUT = route(updatePost, { // the JSON body merges into the same input object
409
+ * validate: (input) => {
410
+ * if (typeof input.title !== 'string') throw new Error('title is required'); // a throw answers 400
411
+ * return { id: String(input.id), title: input.title };
412
+ * },
413
+ * });
414
+ * ```
366
415
  */
367
416
  export declare function route<A, R>(
368
417
  action: (input: A, ctx: { req: Request; params: Record<string, string> }) => R | Promise<R>,
@@ -693,7 +742,9 @@ export interface RequestCookies {
693
742
  readonly size: number;
694
743
  set(name: string, value: string, options?: Omit<ResponseCookie, 'name' | 'value'>): RequestCookies;
695
744
  set(cookie: ResponseCookie): RequestCookies;
696
- delete(name: string | { name: string; path?: string; domain?: string }): RequestCookies;
745
+ /** Expire a cookie. Pass the `path` / `domain` it was set with, or the browser keeps it. */
746
+ delete(name: string, options?: Omit<ResponseCookie, 'name' | 'value' | 'expires' | 'maxAge'>): RequestCookies;
747
+ delete(cookie: { name: string; path?: string; domain?: string }): RequestCookies;
697
748
  toString(): string;
698
749
  [Symbol.iterator](): IterableIterator<[string, RequestCookie]>;
699
750
  }
@@ -734,7 +785,18 @@ export declare function defaultLogger(opts?: { dev?: boolean }): Logger;
734
785
  // rate-limit.js
735
786
  // ---------------------------------------------------------------------------
736
787
 
737
- /** Build a rate-limit middleware `(req, next) => Response`. */
788
+ /**
789
+ * Build a rate-limit middleware `(req, next) => Response`.
790
+ *
791
+ * @example
792
+ * ```ts
793
+ * // app/api/auth/middleware.ts: 10 requests a minute per client on every /api/auth route
794
+ * import { rateLimit } from '@webjsdev/server';
795
+ * export default rateLimit({ window: '1m', max: 10 }); // over it answers 429 + retry-after
796
+ * // Behind Cloudflare, key on the visitor rather than the edge:
797
+ * // rateLimit({ window: '1m', max: 10, trustProxy: true, clientIpHeader: 'cf-connecting-ip' })
798
+ * ```
799
+ */
738
800
  export declare function rateLimit(opts?: {
739
801
  window?: number | string;
740
802
  max?: number;
@@ -781,6 +843,19 @@ export declare function parseWindow(w: number | string): number;
781
843
  * whenever a CDN sits in front: the default chain's leftmost
782
844
  * `X-Forwarded-For` entry is then the CDN's egress address rather than the
783
845
  * visitor.
846
+ *
847
+ * @example
848
+ * ```ts
849
+ * // app/api/votes/route.ts: one vote per client address
850
+ * import { clientIp } from '@webjsdev/server';
851
+ * import { recordVote } from '#modules/votes/queries/record-vote.server.ts';
852
+ *
853
+ * export async function POST(req: Request) {
854
+ * const ip = clientIp(req); // the socket peer, never a client-sent header
855
+ * // Behind Cloudflare: clientIp(req, { trustProxy: true, header: 'cf-connecting-ip' })
856
+ * return Response.json({ counted: await recordVote(ip) });
857
+ * }
858
+ * ```
784
859
  */
785
860
  export declare function clientIp(
786
861
  req: Request,
@@ -806,7 +881,22 @@ export interface CorsOptions {
806
881
  maxAge?: number;
807
882
  }
808
883
 
809
- /** Build a CORS middleware `(req, next) => Response`. */
884
+ /**
885
+ * Build a CORS middleware `(req, next) => Response`.
886
+ *
887
+ * @example
888
+ * ```ts
889
+ * // app/api/public/middleware.ts: CORS for every route.ts under /api/public,
890
+ * // the OPTIONS preflight answered for you
891
+ * import { cors } from '@webjsdev/server';
892
+ *
893
+ * export default cors({
894
+ * origin: ['https://app.example.com', /\.example\.com$/], // or '*' for a public, cookie-free API
895
+ * credentials: true, // needs a listed origin, never '*'
896
+ * maxAge: 600, // preflight cache, in seconds
897
+ * });
898
+ * ```
899
+ */
810
900
  export declare function cors(options?: CorsOptions): Middleware;
811
901
  /** Resolve the allowed origin for a request given a policy (shared CORS core). */
812
902
  export declare function resolveOrigin(
@@ -838,6 +928,21 @@ export declare function setStore(store: CacheStore): void;
838
928
  * Wrap an async function with TTL caching. Preserves the wrapped signature and
839
929
  * adds an `invalidate()` member. `tags` (static `string[]` or a per-arg
840
930
  * `(...args) => string[]`) enable `revalidateTag` cross-module eviction.
931
+ *
932
+ * @example
933
+ * ```ts
934
+ * // modules/posts/queries/get-post.server.ts
935
+ * import { cache } from '@webjsdev/server';
936
+ * import { eq } from 'drizzle-orm';
937
+ * import { db } from '#db/connection.server.ts';
938
+ * import { posts } from '#db/schema.server.ts';
939
+ *
940
+ * export const getPost = cache(
941
+ * async (id: number) => (await db.select().from(posts).where(eq(posts.id, id)).limit(1))[0],
942
+ * { key: 'post', ttl: 300, tags: (id) => ['post:' + id] }, // ttl in seconds, default 60
943
+ * );
944
+ * // A write elsewhere evicts just that id: await revalidateTag('post:' + id)
945
+ * ```
841
946
  */
842
947
  export declare function cache<T extends (...args: any[]) => Promise<any>>(
843
948
  fn: T,
@@ -857,6 +962,20 @@ export declare function cache<T extends (...args: any[]) => Promise<any>>(
857
962
  * argument, `revalidateTag(tag, 'max')` (a cache-life profile, or
858
963
  * `{ expire }`), for source compatibility: WebJs evicts at once whatever the
859
964
  * profile, since `cache()` has no stale-while-revalidate window to honour.
965
+ *
966
+ * @example
967
+ * ```ts
968
+ * // modules/posts/actions/rename-post.server.ts
969
+ * 'use server';
970
+ * import { revalidateTag } from '@webjsdev/server';
971
+ * import { updatePostTitle } from '../queries/update-post-title.server.ts';
972
+ *
973
+ * export async function renamePost(input: { id: number; title: string }) {
974
+ * await updatePostTitle(input.id, input.title);
975
+ * await revalidateTag('post:' + input.id); // every cache() read tagged 'post:<id>' recomputes
976
+ * return { success: true };
977
+ * }
978
+ * ```
860
979
  */
861
980
  export declare function revalidateTag(tag: string, profile?: string | { expire?: number }): Promise<void>;
862
981
  /** Evict every `cache()` entry tagged with any of `tags`. */
@@ -968,7 +1087,25 @@ export declare function getSession(req?: Request | null): Session;
968
1087
  // broadcast.js
969
1088
  // ---------------------------------------------------------------------------
970
1089
 
971
- /** Broadcast data to all WebSocket clients connected to a route path. */
1090
+ /**
1091
+ * Broadcast data to all WebSocket clients connected to a route path.
1092
+ *
1093
+ * @example
1094
+ * ```ts
1095
+ * // app/api/chat/route.ts: each socket that connects here is registered under '/api/chat'
1096
+ * import { broadcast } from '@webjsdev/server';
1097
+ *
1098
+ * type Socket = { on(event: 'message', cb: (data: Buffer) => void): void }; // or ws's own WebSocket type
1099
+ * export function WS(ws: Socket) {
1100
+ * ws.on('message', (data) => broadcast('/api/chat', data.toString(), { except: ws }));
1101
+ * }
1102
+ * // A 'use server' action can push to the same sockets after a write:
1103
+ * // broadcast('/api/chat', JSON.stringify({ kind: 'joined', name }))
1104
+ * ```
1105
+ * The path is the connected URL's pathname, so a dynamic route broadcasts to
1106
+ * its concrete path (`'/api/rooms/' + params.roomId`). One process only: fan out
1107
+ * across instances yourself (Redis pub/sub).
1108
+ */
972
1109
  export declare function broadcast(
973
1110
  path: string,
974
1111
  data: string | Uint8Array,
@@ -1016,9 +1153,36 @@ export interface FileStore {
1016
1153
 
1017
1154
  /** The default uploads directory (relative to cwd), gitignore-friendly. */
1018
1155
  export declare const DEFAULT_UPLOAD_DIR: string;
1019
- /** Local-disk file store (the default adapter). Streams writes. */
1156
+ /**
1157
+ * Local-disk file store (the default adapter). Streams writes.
1158
+ *
1159
+ * @example
1160
+ * ```ts
1161
+ * // instrumentation.ts at the app root: register() runs once at boot
1162
+ * import { setFileStore, diskStore } from '@webjsdev/server';
1163
+ *
1164
+ * export function register() {
1165
+ * // Defaults: dir '<cwd>/.webjs/uploads', baseUrl '/uploads'. url(key) is baseUrl + '/' + key,
1166
+ * // and nothing serves it for you: add the route.ts at that path (see verifySignedUrl).
1167
+ * setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
1168
+ * }
1169
+ * ```
1170
+ */
1020
1171
  export declare function diskStore(opts?: { dir?: string; baseUrl?: string }): FileStore;
1021
- /** Set the default file store (call at startup to use a custom dir or S3). */
1172
+ /**
1173
+ * Set the default file store (call at startup to use a custom dir or S3).
1174
+ *
1175
+ * @example
1176
+ * ```ts
1177
+ * // instrumentation.ts at the app root: every getFileStore() call follows the swap
1178
+ * import { setFileStore, diskStore } from '@webjsdev/server';
1179
+ * import { r2Store } from '#lib/r2-store.server.ts'; // your adapter: put, get, delete, url
1180
+ *
1181
+ * export function register() {
1182
+ * setFileStore(process.env.R2_BUCKET ? r2Store(process.env.R2_BUCKET) : diskStore());
1183
+ * }
1184
+ * ```
1185
+ */
1022
1186
  export declare function setFileStore(store: FileStore): void;
1023
1187
  /**
1024
1188
  * Get the default file store (`diskStore` under `<cwd>/.webjs/uploads` unless set).
@@ -1041,16 +1205,65 @@ export declare function setFileStore(store: FileStore): void;
1041
1205
  * ```
1042
1206
  */
1043
1207
  export declare function getFileStore(): FileStore;
1044
- /** Generate a random, opaque, traversal-safe key, preserving a whitelisted extension. */
1208
+ /**
1209
+ * Generate a random, opaque, traversal-safe key, preserving a whitelisted extension.
1210
+ *
1211
+ * @example
1212
+ * ```ts
1213
+ * // modules/photos/actions/upload-photo.server.ts, bound as <form action=${uploadPhoto}>
1214
+ * // holding <input type="file" name="photo">
1215
+ * 'use server';
1216
+ * import { getFileStore, generateKey, type FormInput } from '@webjsdev/server';
1217
+ *
1218
+ * export async function uploadPhoto(input: FormInput<{ photo: File }>) {
1219
+ * const key = generateKey(input.photo.name); // '<uuid>.jpg': never the user's own name or path
1220
+ * await getFileStore().put(key, input.photo);
1221
+ * return { success: true, data: { key } }; // store the key on the row
1222
+ * }
1223
+ * ```
1224
+ */
1045
1225
  export declare function generateKey(filename?: string): string;
1046
1226
  /** Validate a key and return the absolute path it resolves to (throws on traversal). */
1047
1227
  export declare function assertSafeKey(dir: string, key: string): string;
1048
- /** Mint a signed, expiring URL for a stored object (HMAC-SHA256). */
1228
+ /**
1229
+ * Mint a signed, expiring URL for a stored object (HMAC-SHA256).
1230
+ *
1231
+ * @example
1232
+ * ```ts
1233
+ * // modules/photos/utils/photo-url.server.ts: a link that works for an hour, no session needed
1234
+ * import { signedUrl } from '@webjsdev/server';
1235
+ *
1236
+ * export function photoUrl(key: string) {
1237
+ * return signedUrl(key, {
1238
+ * secret: process.env.FILE_URL_SECRET!,
1239
+ * base: '/files/' + encodeURIComponent(key), // the route.ts serving it (default getFileStore().url(key))
1240
+ * expiresIn: 3600, // seconds, the default (0 or less is already expired)
1241
+ * });
1242
+ * }
1243
+ * // returns '/files/<key>?key=<key>&exp=<unix seconds>&sig=<hmac>', checked by verifySignedUrl
1244
+ * ```
1245
+ */
1049
1246
  export declare function signedUrl(
1050
1247
  key: string,
1051
1248
  opts: { secret: string; expiresIn?: number; base?: string },
1052
1249
  ): string;
1053
- /** Verify a signed URL (or parsed params); constant-time, checks expiry + tamper. */
1250
+ /**
1251
+ * Verify a signed URL (or parsed params); constant-time, checks expiry + tamper.
1252
+ *
1253
+ * @example
1254
+ * ```ts
1255
+ * // app/files/[key]/route.ts: serve a stored file only to a valid, unexpired signed link
1256
+ * import { getFileStore, verifySignedUrl } from '@webjsdev/server';
1257
+ *
1258
+ * export async function GET(req: Request, { params }: { params: { key: string } }) {
1259
+ * const check = verifySignedUrl(req.url, process.env.FILE_URL_SECRET!); // { valid, key, reason? }
1260
+ * if (!check.valid || check.key !== params.key) return new Response('Forbidden', { status: 403 });
1261
+ * const file = await getFileStore().get(params.key);
1262
+ * if (!file) return new Response('Not found', { status: 404 });
1263
+ * return new Response(file.body, { headers: { 'content-type': file.contentType, 'x-content-type-options': 'nosniff' } });
1264
+ * }
1265
+ * ```
1266
+ */
1054
1267
  export declare function verifySignedUrl(
1055
1268
  input: string | URL | URLSearchParams | { key?: string; exp?: string | number; sig?: string },
1056
1269
  secret: string,
@@ -1060,9 +1273,39 @@ export declare function verifySignedUrl(
1060
1273
  // json.js
1061
1274
  // ---------------------------------------------------------------------------
1062
1275
 
1063
- /** Content-negotiated JSON response (rich wire format when the client asks for it). */
1276
+ /**
1277
+ * Content-negotiated JSON response (rich wire format when the client asks for it).
1278
+ *
1279
+ * @example
1280
+ * ```ts
1281
+ * // app/api/posts/route.ts
1282
+ * import { json } from '@webjsdev/server';
1283
+ * import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
1284
+ *
1285
+ * export async function GET() {
1286
+ * // Plain JSON for curl and fetch; richFetch from @webjsdev/core gets Date, Map and BigInt back intact.
1287
+ * return json(await listPosts(), { headers: { 'cache-control': 'max-age=60' } });
1288
+ * }
1289
+ * ```
1290
+ */
1064
1291
  export declare function json<T>(data: T, init?: ResponseInit): Promise<Response>;
1065
- /** Parse a request body (webjs rich format or plain JSON); enforces the body-size limit. */
1292
+ /**
1293
+ * Parse a request body (WebJs rich format or plain JSON); enforces the body-size limit.
1294
+ *
1295
+ * @example
1296
+ * ```ts
1297
+ * // app/api/posts/route.ts
1298
+ * import { json, readBody } from '@webjsdev/server';
1299
+ * import { createPost } from '#modules/posts/queries/create-post.server.ts';
1300
+ *
1301
+ * export async function POST(req: Request) {
1302
+ * const body = (await readBody(req)) as { title?: unknown } | null; // null for an empty body
1303
+ * if (typeof body?.title !== 'string' || !body.title) return json({ error: 'title is required' }, { status: 422 });
1304
+ * return json(await createPost({ title: body.title }), { status: 201 });
1305
+ * }
1306
+ * // A body over the limit (1 MiB by default) is answered 413 before it is parsed.
1307
+ * ```
1308
+ */
1066
1309
  export declare function readBody(req: Request): Promise<unknown>;
1067
1310
 
1068
1311
  // ---------------------------------------------------------------------------
@@ -1081,7 +1324,25 @@ export interface SitemapIndexEntry {
1081
1324
  url: string;
1082
1325
  lastModified?: string | Date;
1083
1326
  }
1084
- /** Serialize entries into a spec-valid `<urlset>` XML document. */
1327
+ /**
1328
+ * Serialize entries into a spec-valid `<urlset>` XML document.
1329
+ *
1330
+ * @example
1331
+ * ```ts
1332
+ * // app/sitemap.ts, served at /sitemap.xml. Returning the entries array also works,
1333
+ * // sitemap() is for when you want the XML string yourself.
1334
+ * import { sitemap, type MetadataRouteContext } from '@webjsdev/server';
1335
+ * import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
1336
+ *
1337
+ * export default async function ({ siteUrl, pages }: MetadataRouteContext) {
1338
+ * const posts = await listPosts();
1339
+ * return sitemap([
1340
+ * ...(await pages()), // every static page, already absolute
1341
+ * ...posts.map((p) => ({ url: siteUrl + '/posts/' + p.id, lastModified: p.updatedAt, priority: 0.7 })),
1342
+ * ]);
1343
+ * }
1344
+ * ```
1345
+ */
1085
1346
  export declare function sitemap(entries: SitemapEntry[]): string;
1086
1347
  /** Serialize child sitemaps into a `<sitemapindex>` XML document. */
1087
1348
  export declare function sitemapIndex(sitemaps: SitemapIndexEntry[]): string;
@@ -1100,7 +1361,23 @@ export interface RobotsConfig {
1100
1361
  sitemap?: string | string[];
1101
1362
  host?: string;
1102
1363
  }
1103
- /** Serialize a robots config into robots.txt text. */
1364
+ /**
1365
+ * Serialize a robots config into robots.txt text.
1366
+ *
1367
+ * @example
1368
+ * ```ts
1369
+ * // app/robots.ts, served at /robots.txt. Return the config and WebJs runs robots() on it.
1370
+ * import type { MetadataRouteContext } from '@webjsdev/server';
1371
+ *
1372
+ * export default function ({ siteUrl }: MetadataRouteContext) {
1373
+ * return { rules: { userAgent: '*', disallow: ['/api/', '/account'] }, sitemap: siteUrl + '/sitemap.xml' };
1374
+ * }
1375
+ *
1376
+ * // Calling it yourself returns the text, for a handler building its own Response:
1377
+ * import { robots } from '@webjsdev/server';
1378
+ * robots({ rules: [{ userAgent: '*', allow: '/' }, { userAgent: 'GPTBot', disallow: '/' }] });
1379
+ * ```
1380
+ */
1104
1381
  export declare function robots(config?: RobotsConfig): string;
1105
1382
 
1106
1383
  /**
@@ -1134,9 +1411,42 @@ export type RobotsRouteResult = RobotsConfig | string | Response;
1134
1411
 
1135
1412
  /** The content type that negotiates and carries a stream-action response. */
1136
1413
  export declare const STREAM_MIME: string;
1137
- /** Report whether a request opted into a stream-action response (its `Accept` carries the stream MIME). */
1414
+ /**
1415
+ * Report whether a request opted into a stream-action response (its `Accept` carries the stream MIME).
1416
+ *
1417
+ * @example
1418
+ * ```ts
1419
+ * // app/todos/[id]/done/route.ts, posted by <form method="post" action="/todos/7/done">
1420
+ * import { acceptsStream, stream, streamResponse } from '@webjsdev/server';
1421
+ * import { completeTodo } from '#modules/todos/queries/complete-todo.server.ts';
1422
+ *
1423
+ * export async function POST(req: Request, { params }: { params: { id: string } }) {
1424
+ * await completeTodo(Number(params.id));
1425
+ * // The client router asks for a stream on a form submit, a browser with JS off does not.
1426
+ * if (acceptsStream(req)) return streamResponse(stream.remove('todo-' + params.id));
1427
+ * return Response.redirect(new URL('/todos', req.url), 303);
1428
+ * }
1429
+ * ```
1430
+ */
1138
1431
  export declare function acceptsStream(req: Request | { headers?: Headers }): boolean;
1139
- /** The stream-action builder; each method returns one `<webjs-stream>` HTML string. */
1432
+ /**
1433
+ * The stream-action builder; each method returns one `<webjs-stream>` HTML string.
1434
+ *
1435
+ * @example
1436
+ * ```ts
1437
+ * // modules/comments/utils/comment-streams.server.ts
1438
+ * import { stream } from '@webjsdev/server';
1439
+ * import { escapeText } from '@webjsdev/core';
1440
+ *
1441
+ * // The target is an element id. It is escaped, the content is not: escape user text yourself.
1442
+ * export const commentAdded = (c: { id: number; body: string }) =>
1443
+ * stream.append('comments', '<li id="comment-' + c.id + '">' + escapeText(c.body) + '</li>');
1444
+ * export const commentDeleted = (id: number) => stream.remove('comment-' + id);
1445
+ * // Also prepend, before, after, replace (the element itself) and update (its children).
1446
+ * // Send them with streamResponse(...) to a form submit, or broadcast(path, html) to sockets,
1447
+ * // where connectWS(path, { onMessage: (m) => renderStream(m) }) applies them.
1448
+ * ```
1449
+ */
1140
1450
  export declare const stream: {
1141
1451
  append(target: string, content: string): string;
1142
1452
  prepend(target: string, content: string): string;
@@ -1146,7 +1456,25 @@ export declare const stream: {
1146
1456
  update(target: string, content: string): string;
1147
1457
  remove(target: string): string;
1148
1458
  };
1149
- /** Wrap one or more stream-action strings in a `Response` carrying the stream content type. */
1459
+ /**
1460
+ * Wrap one or more stream-action strings in a `Response` carrying the stream content type.
1461
+ *
1462
+ * @example
1463
+ * ```ts
1464
+ * // modules/todos/actions/add-todo.server.ts, bound as <form action=${addTodo}> beside <ul id="todos">
1465
+ * 'use server';
1466
+ * import { acceptsStream, getRequest, stream, streamResponse, type FormInput } from '@webjsdev/server';
1467
+ * import { escapeText } from '@webjsdev/core';
1468
+ * import { insertTodo } from '../queries/insert-todo.server.ts';
1469
+ *
1470
+ * export async function addTodo(input: FormInput<{ title: string }>) {
1471
+ * const todo = await insertTodo(input.title);
1472
+ * const req = getRequest();
1473
+ * if (req && acceptsStream(req)) return streamResponse(stream.append('todos', '<li>' + escapeText(todo.title) + '</li>'));
1474
+ * return { success: true, redirect: '/todos' }; // JS off: the usual redirect and full render
1475
+ * }
1476
+ * ```
1477
+ */
1150
1478
  export declare function streamResponse(...parts: string[]): Response;
1151
1479
 
1152
1480
  // ---------------------------------------------------------------------------
@@ -1375,6 +1703,7 @@ export interface AuthInstance<TUser = ResolvedAuthUser> {
1375
1703
  * @example
1376
1704
  * ```ts
1377
1705
  * // modules/auth/auth.server.ts
1706
+ * import { createAuth, Credentials, AuthError } from '@webjsdev/server';
1378
1707
  * export const { auth, signIn, signOut, handlers } = createAuth({
1379
1708
  * providers: [Credentials({
1380
1709
  * async authorize(c: { email: string; password: string }) {
@@ -1388,7 +1717,8 @@ export interface AuthInstance<TUser = ResolvedAuthUser> {
1388
1717
  * // app/api/auth/[...path]/route.ts: export const { GET, POST } = handlers;
1389
1718
  * // Read the user anywhere on the server: const session = await auth();
1390
1719
  *
1391
- * // A 'use server' action bound as <form action=${logIn}>, as with Auth.js in Next:
1720
+ * // A 'use server' action bound as <form action=${logIn}>, as with Auth.js in Next.
1721
+ * // AuthError comes from '@webjsdev/server' (as above), never from auth.server.ts:
1392
1722
  * export async function logIn(formData: FormData) {
1393
1723
  * try {
1394
1724
  * await signIn('credentials', formData, { redirectTo: '/dashboard' });
@@ -1431,7 +1761,27 @@ export interface CredentialsOptions<C extends Record<string, unknown> = Record<s
1431
1761
  authorize: (credentials: C) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null;
1432
1762
  [key: string]: unknown;
1433
1763
  }
1434
- /** The credentials (email/password) provider, Auth.js's `Credentials`. */
1764
+ /**
1765
+ * The credentials (email/password) provider, Auth.js's `Credentials`.
1766
+ *
1767
+ * @example
1768
+ * ```ts
1769
+ * // modules/auth/auth.server.ts
1770
+ * import { createAuth, Credentials } from '@webjsdev/server';
1771
+ * import { findUserByEmail } from '#modules/users/queries/find-user-by-email.server.ts';
1772
+ * import { verifyPassword } from './password.server.ts';
1773
+ *
1774
+ * export const { auth, signIn, signOut, handlers } = createAuth({
1775
+ * providers: [Credentials({
1776
+ * async authorize(credentials: { email: string; password: string }) {
1777
+ * const user = await findUserByEmail(credentials.email);
1778
+ * if (!user || !(await verifyPassword(credentials.password, user.passwordHash))) return null;
1779
+ * return { id: String(user.id), email: user.email }; // the session user, null refuses
1780
+ * },
1781
+ * })],
1782
+ * });
1783
+ * ```
1784
+ */
1435
1785
  export declare function Credentials<C extends Record<string, unknown> = Record<string, unknown>>(
1436
1786
  opts: CredentialsOptions<C>,
1437
1787
  ): ProviderConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -55,9 +55,9 @@
55
55
  "amaro": "^1.1.10"
56
56
  },
57
57
  "peerDependencies": {
58
- "@webjsdev/core": "^0.9.0"
58
+ "@webjsdev/core": "^0.9.1"
59
59
  },
60
60
  "devDependencies": {
61
- "@webjsdev/core": "^0.9.0"
61
+ "@webjsdev/core": "^0.9.1"
62
62
  }
63
63
  }
package/src/actions.js CHANGED
@@ -539,7 +539,7 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
539
539
  // CDN-cacheable. See csrf.js.
540
540
  if (!SAFE_VERBS.has(method)) {
541
541
  const v = verifyOrigin(req, allowedOrigins);
542
- if (!v.ok) return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
542
+ if (!v.ok) return rpcResponse({ error: `CSRF validation failed: ${v.reason}` }, { status: 403 });
543
543
  }
544
544
 
545
545
  // Args ride the URL for a URL-arg request (GET / DELETE), else the body.
@@ -10,7 +10,7 @@
10
10
  * `ResponseCookies` (`@edge-runtime/cookies`): `get(name)` returns
11
11
  * `{ name, value }`, `set(name, value, options)` or `set({ name, value, ... })`
12
12
  * with `maxAge` in SECONDS, `path` defaulting to `/`, and `delete(name)`
13
- * expiring the cookie.
13
+ * (or `delete(name, { path, domain })`) expiring the cookie.
14
14
  *
15
15
  * @module cookie-store
16
16
  */
@@ -150,14 +150,21 @@ export class CookieStore {
150
150
  }
151
151
 
152
152
  /**
153
- * Expire a cookie: `delete(name)` or `delete({ name, path, domain })`.
153
+ * Expire a cookie: `delete(name)`, `delete(name, { path, domain })` or
154
+ * `delete({ name, path, domain })`. A cookie set on a path or domain is only
155
+ * cleared by a `Set-Cookie` naming the same one, so both forms carry them.
154
156
  * @param {string | { name: string, path?: string, domain?: string }} a
157
+ * @param {{ path?: string, domain?: string, secure?: boolean, httpOnly?: boolean, sameSite?: ResponseCookie['sameSite'], partitioned?: boolean }} [options]
155
158
  */
156
- delete(a) {
159
+ delete(a, options) {
157
160
  this.assertWritable('delete');
158
- const opts = typeof a === 'string' ? { name: a } : a;
161
+ const opts = typeof a === 'string' ? { ...(options || {}), name: a } : a;
159
162
  this.map.delete(opts.name);
160
- this.pending.set(opts.name, stringifyCookie(normalizeCookie({ ...opts, value: '', expires: new Date(0) })));
163
+ // A `maxAge` or `expires` the caller passed (options shared with `set`)
164
+ // would otherwise outlive the deletion: `normalizeCookie` lets `maxAge`
165
+ // replace the epoch `expires`, and the cookie stayed alive for that long.
166
+ const { maxAge: _maxAge, expires: _expires, ...scope } = /** @type {any} */ (opts);
167
+ this.pending.set(opts.name, stringifyCookie(normalizeCookie({ ...scope, value: '', expires: new Date(0) })));
161
168
  return this;
162
169
  }
163
170
 
@@ -182,7 +189,7 @@ export function cookiesView(store) {
182
189
  getAll: store.getAll.bind(store),
183
190
  has: store.has.bind(store),
184
191
  set: (/** @type {any} */ a, /** @type {any} */ v, /** @type {any} */ o) => { store.set(a, v, o); return view; },
185
- delete: (/** @type {any} */ a) => { store.delete(a); return view; },
192
+ delete: (/** @type {any} */ a, /** @type {any} */ o) => { store.delete(a, o); return view; },
186
193
  toString: () => store.toString(),
187
194
  [Symbol.iterator]: () => store[Symbol.iterator](),
188
195
  };
package/src/csrf.js CHANGED
@@ -97,6 +97,23 @@ export function requestHost(req) {
97
97
  return hostOf(req.url);
98
98
  }
99
99
 
100
+ /**
101
+ * The request's `Origin` as a clean `scheme://host[:port]` for a refusal
102
+ * message, so the message never echoes arbitrary header text. An origin
103
+ * carries no path, query or credentials, so nothing secret can reach it.
104
+ * @param {string | null} origin
105
+ */
106
+ function originLabel(origin) {
107
+ if (!origin) return 'no Origin';
108
+ if (origin === 'null') return 'Origin null (a sandboxed frame or a file: page)';
109
+ try { return `Origin ${new URL(origin).origin}`; } catch { return 'an unparseable Origin'; }
110
+ }
111
+
112
+ /** What to do about a refused request, appended to every reason. */
113
+ const ORIGIN_FIX = 'A browser form posted from this app passes; a hand-built request (curl, fetch in a test) '
114
+ + 'should send no Origin, or the origin of the host it addresses. A trusted other origin goes in '
115
+ + '"webjs": { "allowedOrigins": [...] } in package.json.';
116
+
100
117
  /** Is the request's `Origin` host in the configured allowlist? */
101
118
  function originAllowed(req, allowedOrigins) {
102
119
  const origin = req.headers.get('origin');
@@ -116,7 +133,8 @@ function originAllowed(req, allowedOrigins) {
116
133
  * Cross-origin (CSRF) verification for a state-changing action request.
117
134
  * @param {Request} req
118
135
  * @param {string[]} [allowedOrigins] hosts or full origins allowed cross-site
119
- * @returns {{ ok: boolean, reason?: string }}
136
+ * @returns {{ ok: boolean, reason?: string }} on a refusal, `reason` names the
137
+ * headers that caused it and the fix, safe to send back in the 403
120
138
  */
121
139
  export function verifyOrigin(req, allowedOrigins = []) {
122
140
  // Primary: the Sec-Fetch-Site fetch-metadata header (browser-set, not
@@ -127,7 +145,7 @@ export function verifyOrigin(req, allowedOrigins = []) {
127
145
  // 'same-site' or 'cross-site': reject unless the source origin is trusted.
128
146
  return originAllowed(req, allowedOrigins)
129
147
  ? { ok: true }
130
- : { ok: false, reason: `cross-origin request (Sec-Fetch-Site: ${secFetchSite})` };
148
+ : { ok: false, reason: `the browser marked it Sec-Fetch-Site: ${secFetchSite.slice(0, 32)} (${originLabel(req.headers.get('origin'))}, host ${requestHost(req).slice(0, 255) || '(none)'}), and that origin is not in webjs.allowedOrigins. ${ORIGIN_FIX}` };
131
149
  }
132
150
  // Fallback (no Sec-Fetch-Site, older browser): compare Origin host to host.
133
151
  const origin = req.headers.get('origin');
@@ -137,7 +155,7 @@ export function verifyOrigin(req, allowedOrigins = []) {
137
155
  if (sourceHost && host && sourceHost === host) return { ok: true };
138
156
  return originAllowed(req, allowedOrigins)
139
157
  ? { ok: true }
140
- : { ok: false, reason: `origin ${sourceHost || '(none)'} does not match host ${host || '(none)'}` };
158
+ : { ok: false, reason: `${originLabel(origin)} does not match this request's host ${host.slice(0, 255) || '(none)'}. ${ORIGIN_FIX}` };
141
159
  }
142
160
 
143
161
  /**
@@ -469,7 +469,8 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
469
469
  // Sec-Fetch-Site check the RPC endpoint applies (`csrf.js`). The page `action`
470
470
  // export this replaces had none, and was shielded only by SameSite=Lax cookies.
471
471
  const origin = verifyOrigin(req, allowedOrigins);
472
- if (!origin.ok) return new Response('CSRF validation failed', { status: 403 });
472
+ // The reason names the mismatched Origin / host and the fix, never a secret.
473
+ if (!origin.ok) return new Response(`CSRF validation failed: ${origin.reason}`, { status: 403 });
473
474
 
474
475
  let formData = new FormData();
475
476
  let actionReq = req;
package/src/testing.d.ts CHANGED
@@ -23,6 +23,23 @@ export declare function toRequest(input: string | Request, init?: RequestInit):
23
23
  /**
24
24
  * Fire a request through the real `handle()` pipeline and return the `Response`.
25
25
  * The documented one-liner: `await testRequest(app.handle, '/about')`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // test/posts/posts.test.ts, run with `webjs test`
30
+ * import { test } from 'node:test';
31
+ * import assert from 'node:assert/strict';
32
+ * import { createRequestHandler } from '@webjsdev/server';
33
+ * import { testRequest } from '@webjsdev/server/testing';
34
+ *
35
+ * test('the posts API creates a post', async () => {
36
+ * const app = await createRequestHandler({ appDir: process.cwd() });
37
+ * const res = await testRequest(app.handle, '/api/posts', { // a bare path is fine
38
+ * method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Hi' }),
39
+ * });
40
+ * assert.equal(res.status, 201);
41
+ * });
42
+ * ```
26
43
  */
27
44
  export declare function testRequest(
28
45
  handle: Handle,
@@ -66,6 +83,12 @@ export declare function submitForm(
66
83
  index?: number;
67
84
  match?: string | RegExp;
68
85
  cookies?: string;
86
+ /**
87
+ * The page's HTML as the test fetched it earlier. The form is taken from it
88
+ * instead of from a fresh render, so a test can submit a form the page no
89
+ * longer shows (a vote on a poll closed since the page loaded).
90
+ */
91
+ html?: string;
69
92
  submitPath?: string;
70
93
  headers?: Record<string, string>;
71
94
  },
@@ -74,6 +97,23 @@ export declare function submitForm(
74
97
  /**
75
98
  * Drive the REAL credentials login (`POST /api/auth/signin/credentials` by
76
99
  * default) and capture the genuine signed session `Set-Cookie`.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * // test/dashboard.test.ts, run with `webjs test`. Needs createAuth's handlers at
104
+ * // app/api/auth/[...path]/route.ts (export const { GET, POST } = handlers).
105
+ * import { test } from 'node:test';
106
+ * import assert from 'node:assert/strict';
107
+ * import { createRequestHandler } from '@webjsdev/server';
108
+ * import { loginAndGetCookies, testRequest, withSessionCookie } from '@webjsdev/server/testing';
109
+ *
110
+ * test('a signed-in user sees the dashboard', async () => {
111
+ * const app = await createRequestHandler({ appDir: process.cwd() });
112
+ * const { cookies } = await loginAndGetCookies(app.handle, { email: 'ada@example.com', password: 'correct horse' });
113
+ * const res = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));
114
+ * assert.equal(res.status, 200); // the user must exist first: seed it or sign up in the test
115
+ * });
116
+ * ```
77
117
  */
78
118
  export declare function loginAndGetCookies(
79
119
  handle: Handle,
package/src/testing.js CHANGED
@@ -213,6 +213,10 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
213
213
  * status is wrong, an assertion fails, and a `catch` around it reports a skip.
214
214
  * Hence a helper, so the identity is never the test's business.
215
215
  *
216
+ * Pass `opts.html` (the page's HTML the test fetched earlier) to submit the
217
+ * form as that earlier page load had it, the way a browser tab left open
218
+ * submits a stale form, rather than as the page renders now.
219
+ *
216
220
  * The form is located by INDEX (`opts.index`, default the first on the page),
217
221
  * which is enough for the common one-form page. For a page with several, pass
218
222
  * `opts.match`: a string or RegExp that must appear in the form's markup, so
@@ -226,6 +230,7 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
226
230
  * index?: number,
227
231
  * match?: string | RegExp,
228
232
  * cookies?: string,
233
+ * html?: string,
229
234
  * submitPath?: string,
230
235
  * headers?: Record<string, string>,
231
236
  * }} [opts]
@@ -284,9 +289,18 @@ function formFieldNames(form) {
284
289
  }
285
290
 
286
291
  export async function submitForm(handle, path, fields = {}, opts = {}) {
287
- const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
288
- const page = await testRequest(handle, path, getInit);
289
- const html = await page.text();
292
+ // `html`: the page as the test already fetched it. A form captured before
293
+ // the state changed (a poll closed since, a row deleted) is no longer on a
294
+ // fresh render, and the browser that loaded the page earlier can still
295
+ // submit it, so the test submits that copy instead of re-fetching.
296
+ let status = 'from opts.html';
297
+ let html = opts.html;
298
+ if (html === undefined) {
299
+ const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
300
+ const page = await testRequest(handle, path, getInit);
301
+ status = `status ${page.status}`;
302
+ html = await page.text();
303
+ }
290
304
 
291
305
  const forms = findForms(html);
292
306
  // Without `match`, a bound form is the one a test means: a layout's plain
@@ -323,7 +337,12 @@ export async function submitForm(handle, path, fields = {}, opts = {}) {
323
337
  if (!form) {
324
338
  throw new Error(
325
339
  `submitForm: no ${opts.match ? 'matching ' : ''}<form> found on ${path} `
326
- + `(status ${page.status}, ${forms.length} form(s) on the page).`,
340
+ + `(${status}, ${forms.length} form(s) on the page).`
341
+ + (opts.html === undefined
342
+ ? ' submitForm renders the page afresh, so a form the page showed earlier and no longer does (a poll closed, '
343
+ + 'a row deleted) is gone. To submit the form as an earlier page load had it, pass that HTML: '
344
+ + 'const html = await (await testRequest(app.handle, path)).text(), then submitForm(app.handle, path, fields, { html }).'
345
+ : ''),
327
346
  );
328
347
  }
329
348
  const id = form.match(/name="__webjs_action"\s+value="([^"]*)"/);