@webjsdev/server 0.8.11 → 0.8.13

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.js CHANGED
@@ -2,6 +2,7 @@ export { startServer, createRequestHandler } from './src/dev.js';
2
2
  export { assertNodeVersion, checkNodeVersion, requiredNodeMajor, parseMajor, parseRequiredMajor } from './src/node-version.js';
3
3
  export { validateEnv, formatEnvErrors, loadEnvSchema, applyEnvValidation } from './src/env-schema.js';
4
4
  export { buildRouteTable, matchPage, matchApi } from './src/router.js';
5
+ export { generateRouteTypes } from './src/route-types.js';
5
6
  export { ssrPage, ssrNotFound } from './src/ssr.js';
6
7
  export { handleApi } from './src/api.js';
7
8
  export {
@@ -26,6 +27,7 @@ export {
26
27
  listPinned,
27
28
  auditPinned,
28
29
  findOutdated,
30
+ hasVendorPin,
29
31
  updatePinned,
30
32
  readPinFile,
31
33
  serveDownloadedBundle,
@@ -45,8 +47,25 @@ export { revalidatePath, revalidateAll } from './src/html-cache.js';
45
47
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
46
48
  export { broadcast } from './src/broadcast.js';
47
49
  export { json, readBody } from './src/json.js';
50
+ export { sitemap, sitemapIndex } from './src/sitemap.js';
48
51
  export { attachWebSocket } from './src/websocket.js';
49
52
  export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
50
53
 
51
54
  // Auth (NextAuth-style)
52
55
  export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
56
+
57
+ // Test harness helpers (issue #267): thin builders over handle()
58
+ export {
59
+ testRequest,
60
+ toRequest,
61
+ getCsrf,
62
+ readCsrfCookie,
63
+ getSetCookies,
64
+ cookiesToHeader,
65
+ withCookies,
66
+ withSessionCookie,
67
+ loginAndGetCookies,
68
+ actionEndpoint,
69
+ invokeActionForTest,
70
+ rawActionRequest,
71
+ } from './src/testing.js';
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
- "./check": "./src/check.js"
9
+ "./check": "./src/check.js",
10
+ "./testing": "./src/testing.js",
11
+ "./webjs-config.schema.json": "./webjs-config.schema.json"
10
12
  },
11
13
  "files": [
12
14
  "index.js",
13
15
  "src",
16
+ "webjs-config.schema.json",
14
17
  "README.md"
15
18
  ],
16
19
  "dependencies": {
package/src/actions.js CHANGED
@@ -9,6 +9,8 @@ import { getSerializer } from './serializer.js';
9
9
  import { resolveOrigin } from './cors.js';
10
10
  import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
11
11
  import { getBodyLimits } from './context.js';
12
+ import { basePath } from './importmap.js';
13
+ import { withBasePath } from './base-path.js';
12
14
 
13
15
  /**
14
16
  * The JSON / RPC body cap in effect for the current request: the per-request
@@ -31,6 +33,60 @@ function jsonBodyLimit() {
31
33
  */
32
34
  export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
33
35
 
36
+ /**
37
+ * Run an attached input validator against an action's input, shared by the RPC
38
+ * path (`invokeAction`) and the `expose()` REST path (`invokeExposedAction`) so
39
+ * the contract is identical across transports (#245). The framework only CALLS
40
+ * the validator and shapes the result; it ships no validation library.
41
+ *
42
+ * @param {(input: any) => any} validate the attached validator
43
+ * @param {any} input the value handed to the validator (the action's first arg
44
+ * on RPC, the merged query/params/body object on REST)
45
+ * @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown }}
46
+ * `ok: true` carries the value to pass to the action (the validated `data`,
47
+ * the transformed return, or the original input). `ok: false` carries the
48
+ * structured failure result the caller serializes back; on a THROWN validator
49
+ * it also carries the original error on `thrown` so a caller can salvage a
50
+ * schema lib's structured `issues` (the REST path does, for back-compat).
51
+ */
52
+ export function runValidate(validate, input) {
53
+ let out;
54
+ try {
55
+ out = validate(input);
56
+ } catch (e) {
57
+ // Throw-to-reject (the classic `Schema.parse` contract). Map to a sanitized
58
+ // failure result; a thrown validator is a 400 (bad input), not a 500.
59
+ // Surface only the message; `thrown` lets the REST caller recover `issues`.
60
+ const msg = e instanceof Error ? e.message : String(e);
61
+ return { ok: false, result: { success: false, error: msg, status: 400 }, thrown: e };
62
+ }
63
+ // Disambiguate an ActionResult envelope from a plain transformed value: a
64
+ // return is an envelope ONLY when it is an object carrying a boolean
65
+ // `success` OR a `fieldErrors`. Anything else is a transformed input.
66
+ if (out && typeof out === 'object' && !Array.isArray(out) &&
67
+ (typeof (/** @type any */ (out).success) === 'boolean' || 'fieldErrors' in out)) {
68
+ const env = /** @type any */ (out);
69
+ if (env.success === true) {
70
+ // `{ success: true, data? }` → valid; use `data` when present, else input.
71
+ return { ok: true, value: 'data' in env ? env.data : input };
72
+ }
73
+ // Failure: `{ success: false, ... }` or `{ fieldErrors }` (no literal
74
+ // success). Shape the structured field-error result.
75
+ return {
76
+ ok: false,
77
+ result: {
78
+ success: false,
79
+ ...(env.fieldErrors ? { fieldErrors: env.fieldErrors } : {}),
80
+ ...(env.message || env.error ? { error: env.message || env.error } : {}),
81
+ status: typeof env.status === 'number' ? env.status : 422,
82
+ },
83
+ };
84
+ }
85
+ // A non-envelope return transforms the input (back-compat with
86
+ // `validate: Schema.parse`); `undefined` means "no transform", keep input.
87
+ return { ok: true, value: out === undefined ? input : out };
88
+ }
89
+
34
90
  /** Build a serialized Response with webjs content-type. */
35
91
  async function rpcResponse(payload, init = {}) {
36
92
  const s = getSerializer();
@@ -143,6 +199,12 @@ export async function buildActionIndex(appDir, dev) {
143
199
  if (typeof fn !== 'function') continue;
144
200
  const http = getExposed(fn);
145
201
  if (!http) continue;
202
+ // A `validateInput(fn, ...)` attachment writes `__webjsHttp` with a
203
+ // `validate` but NO `method`/`path` (it does not create a REST route),
204
+ // so it is not an exposed route. Only `expose()` sets a path; skip a
205
+ // validate-only attachment here (its validator is read at RPC call
206
+ // time via getExposed in invokeAction).
207
+ if (!http.path || !http.method) continue;
146
208
  const { pattern, paramNames } = pathToPattern(http.path);
147
209
  httpRoutes.push({
148
210
  method: http.method,
@@ -257,6 +319,12 @@ export async function serveActionStub(idx, absFile) {
257
319
  if (typeof mod.default === 'function' && !fnNames.includes('default')) {
258
320
  fnNames.push('default');
259
321
  }
322
+ // The RPC endpoint is a framework-emitted same-origin URL, so it must
323
+ // carry the basePath prefix under a sub-path deploy (#256), exactly like
324
+ // the importmap targets and the boot module specifiers. Without this the
325
+ // stub would POST to a bare /__webjs/action/... that the ingress strip
326
+ // 404s, breaking every server action when webjs.basePath is set.
327
+ const actionUrl = withBasePath(`/__webjs/action/${hash}/`, basePath());
260
328
  const body = `// webjs: generated server-action stub for ${relative(idx.appDir, absFile)}\n` +
261
329
  `import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';\n` +
262
330
  `function __csrf() {\n` +
@@ -265,7 +333,7 @@ export async function serveActionStub(idx, absFile) {
265
333
  `}\n` +
266
334
  `async function __rpc(fn, args) {\n` +
267
335
  ` const body = await __wjStringify(args);\n` +
268
- ` const res = await fetch(${JSON.stringify(`/__webjs/action/${hash}/`)} + fn, {\n` +
336
+ ` const res = await fetch(${JSON.stringify(actionUrl)} + fn, {\n` +
269
337
  ` method: 'POST',\n` +
270
338
  ` headers: {\n` +
271
339
  ` 'content-type': ${JSON.stringify(RPC_CONTENT_TYPE)},\n` +
@@ -327,6 +395,30 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
327
395
  const mod = await loadModule(file, idx.dev);
328
396
  const fn = fnName === 'default' ? mod.default : mod[fnName];
329
397
  if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
398
+ // Input validation (#245): a validator attached via `validateInput(fn, ...)`
399
+ // or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
400
+ // this RPC path too, not just the REST path. The RPC stub sends an args
401
+ // array; an action conventionally takes one input object, so validate the
402
+ // FIRST arg (matching how the REST path validates its single merged object).
403
+ const attached = getExposed(fn);
404
+ if (attached && typeof attached.validate === 'function') {
405
+ const v = runValidate(attached.validate, args[0]);
406
+ if (!v.ok) {
407
+ if (v.thrown !== undefined) {
408
+ // A THROWN validator behaves like a thrown action: a sanitized error
409
+ // response (non-200, so the client stub throws), with prod stack
410
+ // sanitization. The onError sink sees the original error too.
411
+ if (typeof onError === 'function') onError(v.thrown);
412
+ return actionErrorResponse(v.thrown, idx.dev);
413
+ }
414
+ // A structured `{ success: false, fieldErrors }` envelope is a NORMAL
415
+ // result the action author renders (`result.fieldErrors`): serialized as
416
+ // a 200 RPC payload so the client stub returns it as a real object rather
417
+ // than throwing, the failure status riding inside the envelope.
418
+ return rpcResponse(v.result);
419
+ }
420
+ args = [v.value, ...args.slice(1)];
421
+ }
330
422
  try {
331
423
  const result = await fn(...args);
332
424
  return rpcResponse(result ?? null);
@@ -462,17 +554,26 @@ export async function invokeExposedAction(idx, route, params, req, onError) {
462
554
  }
463
555
  let arg = { ...query, ...params, ...body };
464
556
  if (route.validate) {
465
- try {
466
- arg = route.validate(arg);
467
- } catch (e) {
468
- const msg = e instanceof Error ? e.message : String(e);
469
- // Many schema libs (zod, valibot) throw structured errors: pass their
470
- // `issues` array through when present for easier client-side handling.
471
- const issues = e && typeof e === 'object' && 'issues' in e
472
- ? /** @type any */ (e).issues
473
- : undefined;
474
- return Response.json({ error: msg, issues }, { status: 400 });
557
+ // Run the validator through the SHARED contract (#245) so the REST path and
558
+ // the RPC path interpret a `{ success, fieldErrors }` envelope, a throw, and
559
+ // a transform-return identically. A structured failure becomes a 422 JSON
560
+ // `{ error?, fieldErrors }` (not just a thrown 400); a throw stays a 400; a
561
+ // transform-return keeps replacing the input (back-compat).
562
+ const v = runValidate(route.validate, arg);
563
+ if (!v.ok) {
564
+ if (v.thrown !== undefined) {
565
+ // A thrown validator (the classic `Schema.parse` style): keep the exact
566
+ // legacy REST shape, including a schema lib's structured `issues` array.
567
+ const msg = v.result.error || 'Invalid input';
568
+ const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
569
+ ? /** @type any */ (v.thrown).issues
570
+ : undefined;
571
+ return Response.json({ error: msg, issues }, { status: 400 });
572
+ }
573
+ const { status, ...payload } = v.result;
574
+ return Response.json(payload, { status });
475
575
  }
576
+ arg = v.value;
476
577
  }
477
578
  const mod = await loadModule(route.file, idx.dev);
478
579
  const fn = mod[route.fnName];
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Sub-path deployment support: `webjs.basePath` (issue #256).
3
+ *
4
+ * An app deployed under a sub-path (example.com/app/) behind a proxy that
5
+ * does NOT strip the prefix is broken without this: every
6
+ * framework-emitted absolute URL (the importmap targets, the modulepreload
7
+ * hints, the boot script's `/__webjs/core/*` specifiers and per-route
8
+ * module URLs, the dev reload `src`) assumes the app sits at the origin
9
+ * root, so they point at `/__webjs/core/*` instead of
10
+ * `/app/__webjs/core/*`, module resolution 404s, and the page never
11
+ * hydrates. `createRequestHandler` explicitly targets embedding, where a
12
+ * sub-path mount is the norm.
13
+ *
14
+ * The model is strip-at-ingress + prefix-on-emit, two seams only:
15
+ *
16
+ * 1. STRIP AT INGRESS. At the very start of request handling, when the
17
+ * request pathname starts with the basePath, strip it so all
18
+ * downstream logic (route matching, the `/__webjs/*` checks, the
19
+ * source-file gate, redirects, trailing-slash) sees a ROOT-relative
20
+ * path and works UNCHANGED. This single strip is why the rest of the
21
+ * framework needs no per-site changes. `stripBasePath` does the path
22
+ * computation; `dev.js` rewrites the Request URL with it.
23
+ *
24
+ * 2. PREFIX ON EMIT. Every framework-emitted same-origin absolute URL
25
+ * (begins with a single `/`) gets the basePath prepended via the one
26
+ * `withBasePath` helper. Applied to the importmap targets
27
+ * (`importmap.js`), the modulepreload hrefs + boot module specifiers +
28
+ * dev reload `src` (`ssr.js`). A cross-origin URL (a `https://` CDN
29
+ * vendor target) is absolute and is left untouched.
30
+ *
31
+ * Empty basePath (the default) makes both seams pure no-ops, so an
32
+ * unconfigured app is byte-identical to before this feature. That
33
+ * invariant is the #1 risk and is guarded differentially in the tests.
34
+ *
35
+ * OUT OF SCOPE (a documented follow-up, the same boundary Next draws):
36
+ * rewriting AUTHOR-written `<a href="/about">` links and client-router
37
+ * navigation prefixing. This module covers framework-emitted URLs and the
38
+ * ingress match only.
39
+ */
40
+
41
+ /**
42
+ * Normalize a raw `webjs.basePath` value to the canonical internal form:
43
+ * either `''` (the no-op default) or a string with exactly one leading
44
+ * `/` and no trailing `/`. So `'app'`, `'/app'`, and `'/app/'` all map to
45
+ * `'/app'`, and a nested `'/foo/bar'` is preserved.
46
+ *
47
+ * Empty / undefined / non-string / `'/'` all map to `''` (no base path).
48
+ * A value that cannot be a safe, single-origin path prefix is rejected to
49
+ * `''`: anything containing `..` (path traversal), a protocol (`://`), a
50
+ * backslash, whitespace, or a network-path `//host` reference. So a typo
51
+ * or a hostile value fails safe to "no base path" rather than poisoning
52
+ * every emitted URL.
53
+ *
54
+ * @param {unknown} raw the configured value
55
+ * @returns {string} `''` or `/segment[/segment...]`
56
+ */
57
+ export function normalizeBasePath(raw) {
58
+ if (typeof raw !== 'string') return '';
59
+ let v = raw.trim();
60
+ if (v === '' || v === '/') return '';
61
+ // Reject anything that is not a plain same-origin path prefix.
62
+ if (v.includes('..')) return '';
63
+ if (v.includes('://')) return '';
64
+ if (v.includes('\\')) return '';
65
+ if (/\s/.test(v)) return '';
66
+ // Reject a network-path reference (`//host`) BEFORE collapsing leading
67
+ // slashes: such a value would emit a protocol-relative, cross-origin URL
68
+ // once prefixed (an open redirect / origin escape), so it fails safe to
69
+ // "no base path" rather than being collapsed to `/host`.
70
+ if (v.startsWith('//')) return '';
71
+ // Ensure exactly one leading slash.
72
+ v = '/' + v.replace(/^\/+/, '');
73
+ // Strip any trailing slash(es).
74
+ v = v.replace(/\/+$/, '');
75
+ // A leading-slash-only value collapses to '' here (already handled as
76
+ // '/' above, but guard a value like '//' that slipped a different path).
77
+ if (v === '' || v === '/') return '';
78
+ return v;
79
+ }
80
+
81
+ /**
82
+ * Read and normalize the `webjs.basePath` config from a parsed
83
+ * package.json (or any object). Pure; `dev.js` wraps it with the
84
+ * package.json read like the other `webjs.*` readers.
85
+ *
86
+ * @param {unknown} pkg parsed package.json (or any object)
87
+ * @returns {string} the normalized base path (`''` when unset)
88
+ */
89
+ export function readBasePath(pkg) {
90
+ const raw =
91
+ pkg &&
92
+ typeof pkg === 'object' &&
93
+ /** @type {any} */ (pkg).webjs &&
94
+ /** @type {any} */ (pkg).webjs.basePath;
95
+ return normalizeBasePath(raw);
96
+ }
97
+
98
+ /**
99
+ * Prefix a framework-emitted same-origin absolute URL with the base path.
100
+ * Returns the URL unchanged when basePath is empty (the no-op default) or
101
+ * when the URL is not a same-origin absolute path (a cross-origin
102
+ * `https://` CDN target, a protocol-relative `//host` reference, or a
103
+ * relative URL), so only the framework's own `/`-rooted paths are moved.
104
+ *
105
+ * @param {string} url the URL to (maybe) prefix
106
+ * @param {string} basePath the normalized base path (`''` = no-op)
107
+ * @returns {string}
108
+ */
109
+ export function withBasePath(url, basePath) {
110
+ if (!basePath) return url;
111
+ if (typeof url !== 'string') return url;
112
+ // Only a single-leading-slash same-origin path is prefixed. A
113
+ // protocol-relative `//host` or an absolute `scheme://` URL is left
114
+ // alone (a vendor CDN target), as is a relative URL.
115
+ if (url[0] !== '/' || url[1] === '/') return url;
116
+ return basePath + url;
117
+ }
118
+
119
+ /**
120
+ * Compute the root-relative pathname for an incoming request pathname
121
+ * under the base path (the ingress strip). Returns:
122
+ * - the stripped, root-relative pathname (always begins with `/`) when
123
+ * the request is for this app, OR
124
+ * - `null` when the request path is NOT under the base path, so the
125
+ * caller can 404 it (the request is not for this mounted app).
126
+ *
127
+ * Mapping: `<basePath>` and `<basePath>/` both map to the root `/`.
128
+ * `<basePath>/foo` maps to `/foo`. A path that merely shares a prefix but
129
+ * is not a real segment boundary (e.g. `/application` under basePath
130
+ * `/app`) is NOT under the base path and returns null. When basePath is
131
+ * empty this is a pure pass-through (returns the input unchanged).
132
+ *
133
+ * @param {string} pathname the incoming request pathname (begins with `/`)
134
+ * @param {string} basePath the normalized base path (`''` = no-op)
135
+ * @returns {string | null}
136
+ */
137
+ export function stripBasePath(pathname, basePath) {
138
+ if (!basePath) return pathname;
139
+ if (pathname === basePath) return '/';
140
+ const withSlash = basePath + '/';
141
+ if (pathname.startsWith(withSlash)) {
142
+ // Slice off the base path; keep the leading slash of the remainder.
143
+ const rest = pathname.slice(basePath.length);
144
+ return rest || '/';
145
+ }
146
+ // Not under the base path (`/application` vs basePath `/app`, or an
147
+ // unrelated path): not this app.
148
+ return null;
149
+ }