@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 +369 -19
- package/package.json +3 -3
- package/src/actions.js +1 -1
- package/src/cookie-store.js +13 -6
- package/src/csrf.js +21 -3
- package/src/form-dispatch.js +2 -1
- package/src/testing.d.ts +40 -0
- package/src/testing.js +23 -4
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
58
|
+
"@webjsdev/core": "^0.9.1"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@webjsdev/core": "^0.9.
|
|
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:
|
|
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.
|
package/src/cookie-store.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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: `
|
|
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:
|
|
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
|
/**
|
package/src/form-dispatch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
+ `(
|
|
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="([^"]*)"/);
|