@webjsdev/server 0.8.12 → 0.8.14

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
@@ -27,6 +27,7 @@ export {
27
27
  listPinned,
28
28
  auditPinned,
29
29
  findOutdated,
30
+ hasVendorPin,
30
31
  updatePinned,
31
32
  readPinFile,
32
33
  serveDownloadedBundle,
@@ -46,8 +47,25 @@ export { revalidatePath, revalidateAll } from './src/html-cache.js';
46
47
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
47
48
  export { broadcast } from './src/broadcast.js';
48
49
  export { json, readBody } from './src/json.js';
50
+ export { sitemap, sitemapIndex } from './src/sitemap.js';
49
51
  export { attachWebSocket } from './src/websocket.js';
50
52
  export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
51
53
 
52
54
  // Auth (NextAuth-style)
53
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,12 +1,13 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.12",
3
+ "version": "0.8.14",
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
9
  "./check": "./src/check.js",
10
+ "./testing": "./src/testing.js",
10
11
  "./webjs-config.schema.json": "./webjs-config.schema.json"
11
12
  },
12
13
  "files": [
package/src/actions.js CHANGED
@@ -33,6 +33,60 @@ function jsonBodyLimit() {
33
33
  */
34
34
  export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
35
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
+
36
90
  /** Build a serialized Response with webjs content-type. */
37
91
  async function rpcResponse(payload, init = {}) {
38
92
  const s = getSerializer();
@@ -145,6 +199,12 @@ export async function buildActionIndex(appDir, dev) {
145
199
  if (typeof fn !== 'function') continue;
146
200
  const http = getExposed(fn);
147
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;
148
208
  const { pattern, paramNames } = pathToPattern(http.path);
149
209
  httpRoutes.push({
150
210
  method: http.method,
@@ -241,7 +301,8 @@ export function serveServerOnlyStub(relPath) {
241
301
  `Cannot import "${relPath}" from browser code. ` +
242
302
  `This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
243
303
  `Either add 'use server' at the top of the file to expose its exports as RPC, ` +
244
- `or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead.`;
304
+ `or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead. ` +
305
+ `Docs: https://docs.webjs.com/docs/server-actions`;
245
306
  return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
246
307
  throw new Error(${JSON.stringify(msg)});
247
308
  `;
@@ -335,6 +396,30 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
335
396
  const mod = await loadModule(file, idx.dev);
336
397
  const fn = fnName === 'default' ? mod.default : mod[fnName];
337
398
  if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
399
+ // Input validation (#245): a validator attached via `validateInput(fn, ...)`
400
+ // or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
401
+ // this RPC path too, not just the REST path. The RPC stub sends an args
402
+ // array; an action conventionally takes one input object, so validate the
403
+ // FIRST arg (matching how the REST path validates its single merged object).
404
+ const attached = getExposed(fn);
405
+ if (attached && typeof attached.validate === 'function') {
406
+ const v = runValidate(attached.validate, args[0]);
407
+ if (!v.ok) {
408
+ if (v.thrown !== undefined) {
409
+ // A THROWN validator behaves like a thrown action: a sanitized error
410
+ // response (non-200, so the client stub throws), with prod stack
411
+ // sanitization. The onError sink sees the original error too.
412
+ if (typeof onError === 'function') onError(v.thrown);
413
+ return actionErrorResponse(v.thrown, idx.dev);
414
+ }
415
+ // A structured `{ success: false, fieldErrors }` envelope is a NORMAL
416
+ // result the action author renders (`result.fieldErrors`): serialized as
417
+ // a 200 RPC payload so the client stub returns it as a real object rather
418
+ // than throwing, the failure status riding inside the envelope.
419
+ return rpcResponse(v.result);
420
+ }
421
+ args = [v.value, ...args.slice(1)];
422
+ }
338
423
  try {
339
424
  const result = await fn(...args);
340
425
  return rpcResponse(result ?? null);
@@ -470,17 +555,26 @@ export async function invokeExposedAction(idx, route, params, req, onError) {
470
555
  }
471
556
  let arg = { ...query, ...params, ...body };
472
557
  if (route.validate) {
473
- try {
474
- arg = route.validate(arg);
475
- } catch (e) {
476
- const msg = e instanceof Error ? e.message : String(e);
477
- // Many schema libs (zod, valibot) throw structured errors: pass their
478
- // `issues` array through when present for easier client-side handling.
479
- const issues = e && typeof e === 'object' && 'issues' in e
480
- ? /** @type any */ (e).issues
481
- : undefined;
482
- return Response.json({ error: msg, issues }, { status: 400 });
558
+ // Run the validator through the SHARED contract (#245) so the REST path and
559
+ // the RPC path interpret a `{ success, fieldErrors }` envelope, a throw, and
560
+ // a transform-return identically. A structured failure becomes a 422 JSON
561
+ // `{ error?, fieldErrors }` (not just a thrown 400); a throw stays a 400; a
562
+ // transform-return keeps replacing the input (back-compat).
563
+ const v = runValidate(route.validate, arg);
564
+ if (!v.ok) {
565
+ if (v.thrown !== undefined) {
566
+ // A thrown validator (the classic `Schema.parse` style): keep the exact
567
+ // legacy REST shape, including a schema lib's structured `issues` array.
568
+ const msg = v.result.error || 'Invalid input';
569
+ const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
570
+ ? /** @type any */ (v.thrown).issues
571
+ : undefined;
572
+ return Response.json({ error: msg, issues }, { status: 400 });
573
+ }
574
+ const { status, ...payload } = v.result;
575
+ return Response.json(payload, { status });
483
576
  }
577
+ arg = v.value;
484
578
  }
485
579
  const mod = await loadModule(route.file, idx.dev);
486
580
  const fn = mod[route.fnName];
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Content-hash asset URLs for immutable caching (issue #243, feature 1).
3
+ *
4
+ * The problem: every served app module (`.js` / `.ts`) and `public/` asset
5
+ * ships `Cache-Control: public, max-age=3600` because its URL is
6
+ * un-versioned. The framework cannot serve them `immutable` (1 year) without
7
+ * a per-file fingerprint, because a deploy that changes a module's bytes
8
+ * keeps the SAME url, so a year-long immutable copy at an edge CDN would
9
+ * silently brick the next deploy (a real regression, see the core-serve note
10
+ * in `dev.js`). The importmap build id (`publishedBuildId`) does NOT change
11
+ * on an app-module byte change, so it cannot be the per-asset fingerprint.
12
+ *
13
+ * The fix: a PER-FILE content hash computed at serve time (no build step).
14
+ *
15
+ * 1. `withAssetHash(url)` appends `?v=<hash>` to a framework-emitted
16
+ * SAME-ORIGIN absolute url (the importmap targets, the modulepreload
17
+ * hrefs, the boot script specifiers). It is a NO-OP in DEV (so dev
18
+ * output is byte-identical to before), a NO-OP for a CROSS-ORIGIN url
19
+ * (a `https://` jspm vendor target, which jspm already versions and
20
+ * which carries SRI keyed by the un-hashed url), and for a same-origin
21
+ * `/`-rooted url it resolves the underlying file, looks up its hash, and
22
+ * appends `?v=<hash>`.
23
+ *
24
+ * 2. The serve path (`dev.js`) serves any request carrying a `?v` query
25
+ * `Cache-Control: public, max-age=31536000, immutable`. An un-fingerprinted
26
+ * request keeps the 1h fallback. Dev stays `no-cache` regardless.
27
+ *
28
+ * Deploy-busts invariant: a deploy that changes a module's bytes changes its
29
+ * hash, so its emitted url changes, so a returning client fetches the new url
30
+ * instead of serving the stale immutable copy.
31
+ *
32
+ * The hash is a short prefix of a sha-256 over the file BYTES, computed
33
+ * synchronously (so the emit hot path stays sync) and memoized in a
34
+ * `Map<absPath, hash>`. The cache is cleared on the fs.watch rebuild (dev) so
35
+ * a changed file re-hashes, wired next to `clearVendorCache` in `dev.js`.
36
+ *
37
+ * @module asset-hash
38
+ */
39
+
40
+ import { createHash } from 'node:crypto';
41
+ import { readFileSync } from 'node:fs';
42
+ import { join, resolve, sep } from 'node:path';
43
+
44
+ /**
45
+ * Length of the emitted hex hash. 12 hex chars (48 bits) is plenty to
46
+ * distinguish builds of one file while keeping the url short. Collisions
47
+ * across DIFFERENT bytes of the SAME url are the only failure mode, and at
48
+ * 48 bits that is astronomically unlikely; a same-bytes-same-hash is exactly
49
+ * the desired behaviour.
50
+ *
51
+ * @type {number}
52
+ */
53
+ const HASH_LEN = 12;
54
+
55
+ /**
56
+ * Whether fingerprinting is active. Off in dev (so dev output is
57
+ * byte-identical to before this feature) and off until `setAssetRoots`
58
+ * binds the app + core roots. Both gates make `withAssetHash` a pure
59
+ * pass-through, which is what keeps the inert case byte-identical.
60
+ *
61
+ * @type {boolean}
62
+ */
63
+ let _enabled = false;
64
+
65
+ /** @type {string} */
66
+ let _appDir = '';
67
+ /** @type {string} */
68
+ let _coreDir = '';
69
+
70
+ /**
71
+ * A fingerprint of the current elision verdict (the elidable-component +
72
+ * inert-route set), folded into an APP MODULE's hash. An app module's SERVED
73
+ * body is not its raw source: `dev.js` runs `elideImportsFromSource` over it,
74
+ * stripping a side-effect import of a display-only component. That strip is a
75
+ * property of the IMPORTED component's verdict, not of the importer's own
76
+ * bytes, so when a component flips display-only <-> interactive the importer's
77
+ * served body changes while its source is byte-identical. Hashing the source
78
+ * alone would keep the same `?v`, and a returning client would hold the stale
79
+ * immutable copy (the now-interactive component never imported, so never
80
+ * hydrated) for the full immutable TTL. Folding the verdict fingerprint into
81
+ * the hash busts every app module's url on a verdict change, so the stale-copy
82
+ * window cannot open. Empty when no module is elidable (then an app module's
83
+ * hash is exactly `sha256(bytes)`), and only mixed for files under `_appDir`
84
+ * (core / public assets are never elision-transformed). Set by `dev.js`'s
85
+ * `ensureReady` after `analyzeElision`, re-set on every rebuild.
86
+ *
87
+ * @type {string}
88
+ */
89
+ let _elisionFp = '';
90
+
91
+ /**
92
+ * Memoized per-file content hash. Keyed by absolute path. Cleared on the
93
+ * fs.watch rebuild so a changed file re-hashes.
94
+ *
95
+ * @type {Map<string, string>}
96
+ */
97
+ const _hashCache = new Map();
98
+
99
+ /**
100
+ * Bind the asset-hash module to the app + core install roots and enable
101
+ * fingerprinting. Called once at boot by `dev.js` for a PROD server only
102
+ * (`dev === false`); a dev server never calls this, so `_enabled` stays
103
+ * false and `withAssetHash` is a pure no-op.
104
+ *
105
+ * @param {{ appDir: string, coreDir: string, enabled: boolean }} opts
106
+ * @returns {void}
107
+ */
108
+ export function setAssetRoots(opts) {
109
+ _appDir = opts.appDir || '';
110
+ _coreDir = opts.coreDir || '';
111
+ _enabled = !!opts.enabled && !!_appDir && !!_coreDir;
112
+ }
113
+
114
+ /**
115
+ * Set the elision-verdict fingerprint folded into app-module hashes (see
116
+ * `_elisionFp`). A no-op when the value is unchanged; on a change it clears the
117
+ * hash cache so every app module re-hashes against the new verdict. `dev.js`
118
+ * calls this from `ensureReady` after `analyzeElision` (and again on each
119
+ * rebuild), passing a stable digest of the elidable-component + inert-route set
120
+ * (or `''` when nothing is elidable).
121
+ *
122
+ * @param {string} fp
123
+ * @returns {void}
124
+ */
125
+ export function setElisionFingerprint(fp) {
126
+ const next = fp || '';
127
+ if (next === _elisionFp) return;
128
+ _elisionFp = next;
129
+ _hashCache.clear();
130
+ }
131
+
132
+ /**
133
+ * Clear the per-file hash cache. Wired into the dev rebuild path next to
134
+ * `clearVendorCache` so a changed file re-hashes (dev never fingerprints,
135
+ * but a future serve that re-derives the map after a rebuild stays correct,
136
+ * and tests rely on the clear to observe a re-hash after a byte change).
137
+ *
138
+ * @returns {void}
139
+ */
140
+ export function clearAssetHashCache() {
141
+ _hashCache.clear();
142
+ }
143
+
144
+ /**
145
+ * Compute (and memoize) the short content hash for an absolute file path.
146
+ * Returns `''` when the file cannot be read, so the caller emits the url
147
+ * UNCHANGED (no `?v`), failing safe to the 1h fallback rather than breaking
148
+ * the url.
149
+ *
150
+ * @param {string} absPath
151
+ * @returns {string} the short hex hash, or `''` on a read failure
152
+ */
153
+ export function assetHashFor(absPath) {
154
+ const cached = _hashCache.get(absPath);
155
+ if (cached !== undefined) return cached;
156
+ try {
157
+ const bytes = readFileSync(absPath);
158
+ const h = createHash('sha256').update(bytes);
159
+ // An app module's served body is elision-transformed, so fold the verdict
160
+ // fingerprint in (see `_elisionFp`). Core / public files are never
161
+ // transformed, so they hash over their bytes alone. Empty fp leaves an app
162
+ // module's hash at exactly `sha256(bytes)`.
163
+ if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)) {
164
+ h.update('\0');
165
+ h.update(_elisionFp);
166
+ }
167
+ const hash = h.digest('hex').slice(0, HASH_LEN);
168
+ _hashCache.set(absPath, hash);
169
+ return hash;
170
+ } catch {
171
+ // A transient read failure is NOT memoized (returning '' fails safe to the
172
+ // 1h fallback), so a later emit re-attempts instead of pinning the file to
173
+ // the fallback for the process lifetime.
174
+ return '';
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Resolve a framework-emitted same-origin absolute url to the absolute file
180
+ * path it serves, or `null` when the url does not map to a fingerprintable
181
+ * same-origin file. Mirrors the serve-path resolution in `dev.js`:
182
+ * - `/__webjs/core/<rel>` -> `<coreDir>/<rel>` (the core runtime)
183
+ * - any other `/<rel>` -> `<appDir>/<rel>` (app modules + public/)
184
+ *
185
+ * Containment-checked against the respective root (the same trailing-separator
186
+ * boundary guard the serve path uses), so a `..`-laden url that escapes the
187
+ * root resolves to `null` and is emitted unchanged.
188
+ *
189
+ * The `?v` query / `#hash` are stripped before resolution (a freshly-emitted
190
+ * url never carries one, but stay robust). A cross-origin / protocol-relative
191
+ * / relative url is NOT a `/`-rooted same-origin path and returns `null`.
192
+ *
193
+ * @param {string} url
194
+ * @returns {string | null}
195
+ */
196
+ function resolveUrlToFile(url) {
197
+ // Only a single-leading-slash same-origin path. A protocol-relative
198
+ // `//host` or an absolute `scheme://` url (a vendor CDN target) is not ours.
199
+ if (typeof url !== 'string' || url[0] !== '/' || url[1] === '/') return null;
200
+ // Strip any query / hash before mapping to a file.
201
+ let pathname = url;
202
+ const q = pathname.indexOf('?');
203
+ if (q !== -1) pathname = pathname.slice(0, q);
204
+ const h = pathname.indexOf('#');
205
+ if (h !== -1) pathname = pathname.slice(0, h);
206
+ let decoded = pathname;
207
+ try { decoded = decodeURIComponent(pathname); } catch { /* keep raw */ }
208
+
209
+ const CORE_PREFIX = '/__webjs/core/';
210
+ if (decoded.startsWith(CORE_PREFIX)) {
211
+ const rel = decoded.slice(CORE_PREFIX.length);
212
+ const abs = resolve(_coreDir, rel);
213
+ if (abs !== _coreDir && !abs.startsWith(_coreDir + sep)) return null;
214
+ return abs;
215
+ }
216
+ // A `/__webjs/*` path that is not a core module (e.g. a downloaded
217
+ // `/__webjs/vendor/<pkg>@<ver>.js` bundle) is already version-named, so it
218
+ // does not need fingerprinting and is left unchanged.
219
+ if (decoded.startsWith('/__webjs/')) return null;
220
+
221
+ const abs = join(_appDir, decoded);
222
+ if (abs !== _appDir && !abs.startsWith(_appDir + sep)) return null;
223
+ return abs;
224
+ }
225
+
226
+ /**
227
+ * Append `?v=<content-hash>` to a framework-emitted same-origin absolute url
228
+ * for immutable caching. Composes with `withBasePath`: call ORDER is basePath
229
+ * then `withAssetHash` (the `?v` is a query the ingress base-path strip never
230
+ * touches, and the file resolution here works on the post-basePath url because
231
+ * a `<basePath>/app/foo.js` still resolves under `_appDir` only after the
232
+ * basePath is stripped, so callers apply `withAssetHash` to the ALREADY
233
+ * base-path-prefixed url and pass the base path so we strip it for resolution).
234
+ *
235
+ * No-op when:
236
+ * - fingerprinting is disabled (dev, or roots unset) -> byte-identical output;
237
+ * - the url is cross-origin / protocol-relative / relative (a vendor CDN
238
+ * target keeps its exact url, so its SRI key, computed over the un-hashed
239
+ * url in #235, still matches, and jspm already versions it);
240
+ * - the url does not resolve to a readable same-origin file (fail-safe to the
241
+ * 1h fallback).
242
+ *
243
+ * @param {string} url the (possibly base-path-prefixed) same-origin url
244
+ * @param {string} [basePath] the active base path, stripped before file resolution
245
+ * @returns {string}
246
+ */
247
+ export function withAssetHash(url, basePath = '') {
248
+ if (!_enabled) return url;
249
+ if (typeof url !== 'string' || url[0] !== '/' || url[1] === '/') return url;
250
+ // Resolve against the file system using the base-path-stripped url, so a
251
+ // sub-path-deployed `<basePath>/app/foo.js` maps to `<appDir>/app/foo.js`.
252
+ let forResolve = url;
253
+ if (basePath && url.startsWith(basePath + '/')) {
254
+ forResolve = url.slice(basePath.length);
255
+ } else if (basePath && url === basePath) {
256
+ forResolve = '/';
257
+ }
258
+ const abs = resolveUrlToFile(forResolve);
259
+ if (!abs) return url;
260
+ const hash = assetHashFor(abs);
261
+ if (!hash) return url;
262
+ // The freshly-emitted url never already carries a query, but stay robust:
263
+ // merge with `&` if one is somehow present.
264
+ const sepChar = url.includes('?') ? '&' : '?';
265
+ return `${url}${sepChar}v=${hash}`;
266
+ }