@webjsdev/core 0.7.19 → 0.7.21

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-browser.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Mirror of `./index.js` but with strictly server-only exports
5
5
  * removed so the browser bundle never drags `src/render-server.js`
6
- * (~1100 lines) or `src/expose.js` (~90 lines) over the wire.
6
+ * (~1100 lines) over the wire.
7
7
  *
8
8
  * Stripped:
9
9
  * - `renderToString`, `renderToStream` (server SSR pipeline; reach
@@ -11,10 +11,6 @@
11
11
  * - `setCspNonceProvider` (server-side wiring called by
12
12
  * `@webjsdev/server`'s context module; `cspNonce()` stays
13
13
  * because layouts can call it).
14
- * - `expose`, `getExposed` (live inside `.server.{js,ts}` files;
15
- * framework rewrites the import to an RPC stub on the client
16
- * side, so the real module never legitimately reaches the
17
- * browser).
18
14
  *
19
15
  * The framework's own SSR pipeline runs on Node and resolves the
20
16
  * package via the package.json `"default"` condition, which still
@@ -61,6 +57,10 @@ export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } f
61
57
  // Client action-abort plumbing (#492): a superseded async render aborts its
62
58
  // in-flight action fetches. Inert server-side.
63
59
  export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
60
+ // Streaming RPC wire protocol (#489): the byte framing the client stub decodes.
61
+ export {
62
+ STREAM_CONTENT_TYPE, FRAME_CHUNK, FRAME_END, FRAME_ERROR, encodeFrame, createFrameDecoder,
63
+ } from './src/action-stream.js';
64
64
 
65
65
  // Directives, also available via '@webjsdev/core/directives'. The full
66
66
  // lit-html-parity set is re-exported here so the dist browser bundle (which
package/index.d.ts CHANGED
@@ -54,6 +54,16 @@ export type {
54
54
  WebjsCspConfig,
55
55
  } from './src/webjs-config.d.ts';
56
56
 
57
+ // Compile-time serializability typing for server actions (#488): the opt-in
58
+ // guard that makes a non-serializable action arg / return a type error.
59
+ export type {
60
+ Serializable,
61
+ SerializableArgs,
62
+ SerializableResult,
63
+ SerializableActionFn,
64
+ NonSerializable,
65
+ } from './src/serializable.d.ts';
66
+
57
67
  export { html, isTemplate, MARKER } from './src/html.js';
58
68
  export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
59
69
  export { register, lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
@@ -61,7 +71,6 @@ export { renderToString, renderToStream } from './src/render-server.js';
61
71
  export { render } from './src/render-client.js';
62
72
  export { escapeText, escapeAttr } from './src/escape.js';
63
73
  export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
64
- export { expose, getExposed, validateInput } from './src/expose.js';
65
74
  export { repeat, isRepeat } from './src/repeat.js';
66
75
  export { Suspense, isSuspense } from './src/suspense.js';
67
76
  export { connectWS } from './src/websocket-client.js';
@@ -96,6 +105,10 @@ export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } f
96
105
  // Client action-abort plumbing (#492): a superseded async render aborts its
97
106
  // in-flight action fetches. Inert server-side.
98
107
  export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
108
+ // Streaming RPC wire protocol (#489).
109
+ export {
110
+ STREAM_CONTENT_TYPE, FRAME_CHUNK, FRAME_END, FRAME_ERROR, encodeFrame, createFrameDecoder,
111
+ } from './src/action-stream.js';
99
112
  export { stringify, parse, serialize, deserialize } from './src/serialize.js';
100
113
  export { WebjsFrame } from './src/webjs-frame.js';
101
114
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
package/index.js CHANGED
@@ -15,7 +15,6 @@ export { render } from './src/render-client.js';
15
15
  export { escapeText, escapeAttr } from './src/escape.js';
16
16
  export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
17
17
  export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
18
- export { expose, getExposed, validateInput } from './src/expose.js';
19
18
  export { repeat, isRepeat } from './src/repeat.js';
20
19
  export { Suspense, isSuspense } from './src/suspense.js';
21
20
  export { connectWS } from './src/websocket-client.js';
@@ -43,6 +42,11 @@ export { markStale, registerKeyTags, consumeStale, parseTagHeader, fetchMark } f
43
42
  // Client action-abort plumbing (#492): a superseded async render aborts its
44
43
  // in-flight action fetches. Inert server-side.
45
44
  export { setActiveActionSignal, activeActionSignal } from './src/action-abort-client.js';
45
+ // Streaming RPC wire protocol (#489): the byte framing the server encodes a
46
+ // streamed action result with and the client stub decodes. Pure byte ops.
47
+ export {
48
+ STREAM_CONTENT_TYPE, FRAME_CHUNK, FRAME_END, FRAME_ERROR, encodeFrame, createFrameDecoder,
49
+ } from './src/action-stream.js';
46
50
 
47
51
  // Directives, also available via '@webjsdev/core/directives'. The full
48
52
  // lit-html-parity set is re-exported so the bare specifier exposes the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -0,0 +1,12 @@
1
+ /** Streaming RPC wire protocol (#489). See action-stream.js. */
2
+
3
+ export declare const STREAM_CONTENT_TYPE: string;
4
+ export declare const FRAME_CHUNK: number;
5
+ export declare const FRAME_END: number;
6
+ export declare const FRAME_ERROR: number;
7
+
8
+ export declare function encodeFrame(type: number, payload?: Uint8Array): Uint8Array;
9
+
10
+ export declare function createFrameDecoder(): {
11
+ push(chunk: Uint8Array): Array<{ type: number; payload: Uint8Array }>;
12
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Streaming RPC wire protocol (#489), shared by the server (which frames an
3
+ * action's streamed result) and the client RPC stub (which decodes it). It lives
4
+ * in core because both sides need the EXACT same byte format; the server imports
5
+ * `encodeFrame` and the stub imports `createFrameDecoder` from `@webjsdev/core`.
6
+ *
7
+ * An action that returns a `ReadableStream` / async iterable / async generator
8
+ * streams its chunks over a single RPC response (one fetch, not multiplexed, so
9
+ * no stream-id is needed). The response carries `STREAM_CONTENT_TYPE`, and its
10
+ * body is a sequence of length-prefixed frames:
11
+ *
12
+ * ┌────────┬──────────────┬──────────────────────┐
13
+ * │ type:1 │ length:4 BE │ payload: length bytes│
14
+ * └────────┴──────────────┴──────────────────────┘
15
+ *
16
+ * - CHUNK (1): payload is one serialized chunk value (the same wire format the
17
+ * RPC envelope uses, so a rich value round-trips). The client deserializes it
18
+ * and yields it from the async iterable the stub returns.
19
+ * - END (2): length 0, no payload. Clean completion; the iterable returns.
20
+ * - ERROR (3): payload is the (author-controlled) error message string. The
21
+ * stream errored after the first byte, so it cannot change the HTTP status;
22
+ * the client throws this message from the iterable instead.
23
+ *
24
+ * Pure byte manipulation, no DOM and no `node:*` deps, so it is safe in the
25
+ * browser bundle and on the server alike.
26
+ */
27
+
28
+ /** The RPC content type for a framed streamed result. */
29
+ export const STREAM_CONTENT_TYPE = 'application/vnd.webjs+stream';
30
+
31
+ /** Frame type: one serialized chunk value. */
32
+ export const FRAME_CHUNK = 1;
33
+ /** Frame type: clean end of stream (empty payload). */
34
+ export const FRAME_END = 2;
35
+ /** Frame type: the stream errored mid-flight (payload is the message). */
36
+ export const FRAME_ERROR = 3;
37
+
38
+ const EMPTY = new Uint8Array(0);
39
+
40
+ /**
41
+ * Frame a payload as `[type:1][length:4 BE][payload]`. `payload` may be omitted
42
+ * for a zero-length frame (END).
43
+ * @param {number} type one of FRAME_CHUNK / FRAME_END / FRAME_ERROR
44
+ * @param {Uint8Array} [payload]
45
+ * @returns {Uint8Array}
46
+ */
47
+ export function encodeFrame(type, payload) {
48
+ const body = payload || EMPTY;
49
+ const len = body.length;
50
+ const out = new Uint8Array(5 + len);
51
+ out[0] = type & 0xff;
52
+ out[1] = (len >>> 24) & 0xff;
53
+ out[2] = (len >>> 16) & 0xff;
54
+ out[3] = (len >>> 8) & 0xff;
55
+ out[4] = len & 0xff;
56
+ if (len) out.set(body, 5);
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * A stateful decoder that turns a stream of arbitrary byte chunks back into
62
+ * whole frames, buffering across chunk boundaries (a frame may be split over two
63
+ * network reads, or two frames may arrive in one read). `push(chunk)` returns the
64
+ * array of complete frames it could parse from the accumulated buffer so far;
65
+ * partial trailing bytes stay buffered for the next `push`.
66
+ * @returns {{ push(chunk: Uint8Array): Array<{ type: number, payload: Uint8Array }> }}
67
+ */
68
+ export function createFrameDecoder() {
69
+ let buf = EMPTY;
70
+ return {
71
+ push(chunk) {
72
+ if (chunk && chunk.length) {
73
+ const next = new Uint8Array(buf.length + chunk.length);
74
+ next.set(buf, 0);
75
+ next.set(chunk, buf.length);
76
+ buf = next;
77
+ }
78
+ const frames = [];
79
+ // A frame needs at least the 5-byte header before its length is known.
80
+ while (buf.length >= 5) {
81
+ const type = buf[0];
82
+ const len = ((buf[1] << 24) | (buf[2] << 16) | (buf[3] << 8) | buf[4]) >>> 0;
83
+ if (buf.length < 5 + len) break; // payload not fully arrived yet
84
+ // Copy the payload out so it does not alias the (soon-reassigned) buffer.
85
+ const payload = buf.slice(5, 5 + len);
86
+ frames.push({ type, payload });
87
+ buf = buf.slice(5 + len);
88
+ }
89
+ return frames;
90
+ },
91
+ };
92
+ }
@@ -361,11 +361,15 @@ const SSR_HTMLELEMENT_METHODS = new Set([
361
361
  */
362
362
  function browserMemberHint(e) {
363
363
  const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
364
- let m = /^(\w+) is not defined$/.exec(msg);
364
+ // Match on a word boundary, NOT end-of-string: V8 (Node) ends the message at
365
+ // "is not defined" / "is not a function", but JSC (Bun) appends a detail
366
+ // clause (e.g. ". (In '({}).querySelector(\"p\")', '...' is undefined)"), so an
367
+ // anchored `$` would miss the Bun message and drop the actionable hint.
368
+ let m = /^(\w+) is not defined\b/.exec(msg);
365
369
  if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
366
370
  return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
367
371
  }
368
- m = /\.(\w+) is not a function$/.exec(msg);
372
+ m = /\.(\w+) is not a function\b/.exec(msg);
369
373
  if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
370
374
  return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
371
375
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Compile-time serializability typing for server actions (#488).
3
+ *
4
+ * A `'use server'` action's arguments and result cross the RPC wire through the
5
+ * webjs serializer, which round-trips a SPECIFIC set of rich types (`Date`,
6
+ * `Map`, `Set`, `BigInt`, `Error`, `RegExp`, `URL`, typed arrays, `ArrayBuffer`,
7
+ * `Blob`, `File`, `FormData`, registered `Symbol`s, plain objects / arrays, and
8
+ * cycles). It does NOT round-trip a FUNCTION or a class instance's METHODS: a
9
+ * function silently vanishes from the wire, and a class instance arrives as a
10
+ * plain object with its prototype (so its methods) gone. That is a runtime
11
+ * surprise the author never asked for.
12
+ *
13
+ * `Serializable<T>` makes it a TYPE error instead. It maps a fully serializable
14
+ * `T` to itself, and a `T` that carries a function (a method, a callback prop)
15
+ * to a branded `NonSerializable<...>` marker, so assigning the offending value
16
+ * fails to typecheck with a message that names the problem. `SerializableArgs`
17
+ * / `SerializableResult` apply it across an action's parameter tuple and its
18
+ * (possibly promised) return.
19
+ *
20
+ * This is OPT-IN by design: webjs actions stay plain `export async function`s
21
+ * with no wrapper (the framework rewrites the client import to an RPC stub at
22
+ * runtime, it does not wrap the authored function). An author who wants the
23
+ * guard annotates the action with `SerializableActionFn`, e.g.
24
+ *
25
+ * import type { SerializableActionFn } from '@webjsdev/core';
26
+ * export const getUser: SerializableActionFn<(id: number) => Promise<User>> =
27
+ * async (id) => db.user.find(id);
28
+ *
29
+ * If `User` (or any arg) is not serializable, the annotation is a compile-time
30
+ * error pointing at the offending member. Types only, erased at runtime, zero
31
+ * cost.
32
+ */
33
+
34
+ /** A branded marker a non-serializable position resolves to, so the error names it. */
35
+ export type NonSerializable<Reason extends string = 'this value is not serializable over the webjs RPC wire'> = {
36
+ readonly __webjsNonSerializable: Reason;
37
+ };
38
+
39
+ /** Primitives the wire carries verbatim. */
40
+ type SerializablePrimitive = string | number | boolean | bigint | null | undefined;
41
+
42
+ /**
43
+ * Built-in rich types the webjs serializer round-trips as themselves (their
44
+ * identity / methods survive because the serializer reconstructs the instance).
45
+ * `Map` / `Set` recurse into their members via the top-level mapper.
46
+ */
47
+ type SerializableBuiltin =
48
+ | Date
49
+ | RegExp
50
+ | URL
51
+ | Error
52
+ | ArrayBuffer
53
+ | Int8Array | Uint8Array | Uint8ClampedArray
54
+ | Int16Array | Uint16Array
55
+ | Int32Array | Uint32Array
56
+ | Float32Array | Float64Array
57
+ | BigInt64Array | BigUint64Array
58
+ | Blob
59
+ | File
60
+ | FormData;
61
+
62
+ /**
63
+ * Map `T` to itself when it is fully serializable, else to a `NonSerializable`
64
+ * marker at the offending position. A function (a method or a callback-valued
65
+ * property) is the canonical non-serializable case. Objects and arrays recurse;
66
+ * `Map` / `Set` recurse into their type parameters.
67
+ */
68
+ export type Serializable<T> =
69
+ T extends SerializablePrimitive ? T :
70
+ // A function never survives the wire (it just disappears).
71
+ T extends (...args: any[]) => any ? NonSerializable<'a function is not serializable over the webjs RPC wire'> :
72
+ T extends SerializableBuiltin ? T :
73
+ T extends Map<infer K, infer V> ? Map<Serializable<K>, Serializable<V>> :
74
+ T extends Set<infer U> ? Set<Serializable<U>> :
75
+ T extends Promise<infer U> ? Promise<Serializable<U>> :
76
+ T extends ReadonlyArray<infer U> ? (T extends Array<infer _W> ? Array<Serializable<U>> : ReadonlyArray<Serializable<U>>) :
77
+ T extends object ? { [K in keyof T]: Serializable<T[K]> } :
78
+ T;
79
+
80
+ /** Apply {@link Serializable} across an action's parameter tuple. */
81
+ export type SerializableArgs<A extends readonly unknown[]> = {
82
+ [K in keyof A]: Serializable<A[K]>;
83
+ };
84
+
85
+ /**
86
+ * Apply {@link Serializable} to an action's return, unwrapping a `Promise` so an
87
+ * async action is checked against its resolved value (the wire carries the
88
+ * resolved value, never the promise).
89
+ */
90
+ export type SerializableResult<R> =
91
+ R extends Promise<infer U> ? Promise<Serializable<U>> : Serializable<R>;
92
+
93
+ /**
94
+ * The opt-in annotation type for a server action: it preserves the function's
95
+ * exact call signature while constraining every argument and the result to be
96
+ * serializable. A non-serializable arg or return makes the annotation a
97
+ * compile-time error.
98
+ *
99
+ * @example
100
+ * export const getUser: SerializableActionFn<(id: number) => Promise<User>> =
101
+ * async (id) => db.user.find(id);
102
+ */
103
+ export type SerializableActionFn<F extends (...args: any[]) => any> =
104
+ (...args: SerializableArgs<Parameters<F>>) => SerializableResult<ReturnType<F>>;
package/src/serialize.js CHANGED
@@ -142,6 +142,12 @@ function newEncodeCtx() {
142
142
  emitted: new Set(), // ids already emitted as the canonical encoding
143
143
  nextId: 0,
144
144
  blobBytes: new Map(), // Blob → Uint8Array (precomputed during countRefs)
145
+ // FormData → its entries captured ONCE. Bun returns a FRESH Blob/File
146
+ // identity on every FormData.entries()/get() call (Node returns a stable
147
+ // one), so countRefs and encode must iterate the SAME captured array or the
148
+ // identity-keyed blobBytes lookup misses on Bun (crash). Captured here so
149
+ // both passes see the same Blob objects on every runtime.
150
+ fdEntries: new Map(),
145
151
  };
146
152
  }
147
153
 
@@ -164,7 +170,11 @@ async function countRefs(value, ctx) {
164
170
  continue;
165
171
  }
166
172
  if (typeof FormData !== 'undefined' && v instanceof FormData) {
167
- for (const [, val] of v.entries()) {
173
+ // Capture entries ONCE (see fdEntries note) and traverse THOSE Blob
174
+ // objects, so encode reuses the exact instances whose bytes we precompute.
175
+ const captured = ctx.fdEntries.get(v) || [...v.entries()];
176
+ ctx.fdEntries.set(v, captured);
177
+ for (const [, val] of captured) {
168
178
  if (typeof val !== 'string') stack.push(val);
169
179
  }
170
180
  continue;
@@ -233,8 +243,12 @@ function encodeOne(v, ctx) {
233
243
  out = { [TAG]: 'Blob', v: bytesToB64(bytes), t: v.type || '' };
234
244
  }
235
245
  } else if (typeof FormData !== 'undefined' && v instanceof FormData) {
246
+ // Reuse the entries captured in countRefs so the Blob/File instances match
247
+ // the ones whose bytes were precomputed (Bun yields fresh identities per
248
+ // entries() call; see the fdEntries note in newEncodeCtx).
249
+ const captured = ctx.fdEntries.get(v) || [...v.entries()];
236
250
  const entries = [];
237
- for (const [k, val] of v.entries()) entries.push([k, encodeOne(val, ctx)]);
251
+ for (const [k, val] of captured) entries.push([k, encodeOne(val, ctx)]);
238
252
  out = { [TAG]: 'FD', v: entries };
239
253
  } else if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) {
240
254
  out = encodeBinary(v);
package/src/expose.js DELETED
@@ -1,180 +0,0 @@
1
- /**
2
- * Expose a server action as a first-class HTTP endpoint in addition to its
3
- * internal RPC URL.
4
- *
5
- * ```js
6
- * // actions/posts.server.js
7
- * 'use server';
8
- * import { expose } from '@webjsdev/core';
9
- *
10
- * export const createPost = expose('POST /api/posts', async ({ title, body }) => {
11
- * // same function body you'd write without expose()
12
- * });
13
- * ```
14
- *
15
- * The action is reachable two ways, both backed by the exact same code:
16
- * - from a client component: `import { createPost } from '.../posts.server.js'`
17
- * - from curl / another service: `POST /api/posts` with a JSON body
18
- *
19
- * Adapter rules when invoked over HTTP:
20
- * - URL path params ({`:slug`}) and query string are merged into a single
21
- * object argument.
22
- * - For methods with a body (POST/PUT/PATCH/DELETE), the parsed JSON body
23
- * is merged on top of params/query. The function receives ONE argument:
24
- * the merged object.
25
- * - If `opts.validate` is provided, it runs BEFORE the function on EVERY
26
- * call path (the REST path here AND the internal RPC path), so a single
27
- * validator declared once guards both. The framework calls `validate(input)`
28
- * and interprets the return (see the validator contract below):
29
- * - a `{ success: true, data? }` envelope passes (the action receives
30
- * `data` when present, else the original input);
31
- * - a `{ success: false, fieldErrors, message? }` envelope FAILS with a
32
- * structured field-error result (422), without calling the action;
33
- * - a THROW fails (→ 400 on REST, sanitized error on RPC), preserving the
34
- * classic `Schema.parse`-style contract;
35
- * - any OTHER returned plain value is treated as the transformed input
36
- * (back-compat with the `validate: Schema.parse` transform style).
37
- * The zod adapter is three lines and keeps the framework zod-free:
38
- * `validate: (i) => { const r = Schema.safeParse(i);
39
- * return r.success ? { success: true, data: r.data }
40
- * : { success: false, fieldErrors: r.error.flatten().fieldErrors }; }`
41
- * - Return value becomes a JSON `Response`; throw or return a `Response`
42
- * directly for full control.
43
- *
44
- *
45
- * `cors` toggles browser CORS support for cross-origin callers:
46
- * - `true` - allow any origin (`*`), reflects requested headers
47
- * - string - allow that single origin (sets `Access-Control-Allow-Credentials: true`)
48
- * - string[] - allow-list; non-matching origins are not granted
49
- * - { origin, credentials, maxAge, headers }: full control
50
- *
51
- * When CORS is enabled, an `OPTIONS` preflight at the same path is auto-served
52
- * with `Access-Control-Allow-Methods: <route's method>, OPTIONS`.
53
- *
54
- * @param {string} pattern e.g. `"POST /api/posts"` or `"GET /api/posts/:slug"`
55
- * @param {Function} fn the async implementation
56
- * @param {{
57
- * validate?: (input: any) => any,
58
- * cors?: boolean | string | string[] | { origin: string | string[], credentials?: boolean, maxAge?: number, headers?: string[] }
59
- * }} [opts]
60
- * @returns {Function} same function, tagged with HTTP metadata
61
- */
62
- export function expose(pattern, fn, opts) {
63
- const match = /^\s*([A-Z]+)\s+(\/\S*)\s*$/.exec(pattern);
64
- if (!match) {
65
- throw new Error(
66
- `expose(): bad pattern ${JSON.stringify(pattern)}: expected "METHOD /path"`
67
- );
68
- }
69
- const [, method, path] = match;
70
- /** @type any */ (fn).__webjsHttp = {
71
- method,
72
- path,
73
- validate: opts && typeof opts.validate === 'function' ? opts.validate : null,
74
- cors: opts && 'cors' in opts ? normaliseCors(opts.cors) : null,
75
- };
76
- return fn;
77
- }
78
-
79
- /** @param {any} c */
80
- function normaliseCors(c) {
81
- if (!c) return null;
82
- if (c === true) {
83
- return { origin: '*', credentials: false, maxAge: 86400, headers: null };
84
- }
85
- if (typeof c === 'string') {
86
- return { origin: c, credentials: true, maxAge: 86400, headers: null };
87
- }
88
- if (Array.isArray(c)) {
89
- return { origin: c, credentials: true, maxAge: 86400, headers: null };
90
- }
91
- return {
92
- origin: c.origin,
93
- credentials: !!c.credentials,
94
- maxAge: typeof c.maxAge === 'number' ? c.maxAge : 86400,
95
- headers: Array.isArray(c.headers) ? c.headers : null,
96
- };
97
- }
98
-
99
- /**
100
- * Attach an input validator to a server action WITHOUT exposing it as a REST
101
- * endpoint. The validator runs SERVER-SIDE before the action body on BOTH call
102
- * paths: the internal RPC path (a client component importing the action) AND
103
- * the `expose()` REST path (if the action is also exposed). This is the shared,
104
- * declare-once validation surface (#245).
105
- *
106
- * It reuses the EXACT same `__webjsHttp` storage `expose()` writes, so the
107
- * action index's `getExposed(fn)` already surfaces the validator with no new
108
- * plumbing. The action stays a plain callable function (a direct unit-test
109
- * import still works, just without the framework running validate).
110
- *
111
- * If the action is already `expose()`d, prefer passing `{ validate }` to
112
- * `expose()` directly; calling both keeps the LAST validator attached.
113
- *
114
- * ```js
115
- * // actions/posts.server.js
116
- * 'use server';
117
- * import { validateInput } from '@webjsdev/core';
118
- *
119
- * export const createPost = validateInput(
120
- * async ({ title }) => { ... },
121
- * (input) => {
122
- * const errs = {};
123
- * if (!input?.title?.trim()) errs.title = 'Title is required';
124
- * return Object.keys(errs).length
125
- * ? { success: false, fieldErrors: errs }
126
- * : { success: true };
127
- * },
128
- * );
129
- * ```
130
- *
131
- * The validator contract (the framework calls `validate(input)`):
132
- * - returns `{ success: true, data? }` → valid; the action is called with
133
- * `data` if present, else the original input;
134
- * - returns `{ success: false, fieldErrors, message? }` (an object with a
135
- * boolean `success` of `false`, or with a `fieldErrors`) → FAILED; the
136
- * framework returns a structured `ActionResult`
137
- * `{ success: false, fieldErrors, error: message?, status: 422 }` without
138
- * calling the action body;
139
- * - THROWS → mapped to a sanitized failure result (the classic
140
- * `Schema.parse` contract);
141
- * - returns ANY OTHER plain value → treated as the validated/coerced input
142
- * and passed to the action (the `validate: Schema.parse` transform style).
143
- *
144
- * Disambiguation: a return is a result-envelope ONLY when it is an object with
145
- * a boolean `success` property OR a `fieldErrors` property; otherwise it is a
146
- * transformed input value. Caveat: if your validator returns the input
147
- * VERBATIM (the bare-transform style) and that input can itself carry a
148
- * `success` boolean or a `fieldErrors` key, it would be misread as an envelope.
149
- * Return the explicit `{ success: true, data }` envelope in that case (the zod
150
- * adapter and the recommended hand-rolled form already do, so they never
151
- * collide).
152
- *
153
- * Server-side only; import inside `.server.{js,ts}` files. The bare
154
- * `@webjsdev/core` specifier resolves to the browser entry, which excludes
155
- * `validateInput` (like `expose`), so an import from client-bound code reads
156
- * `undefined`. The validator itself lives in the `.server` file and never
157
- * reaches the client.
158
- *
159
- * @template {Function} F
160
- * @param {F} fn the async action implementation
161
- * @param {(input: any) => any} validate the validator (a plain function or a
162
- * zod-`safeParse` adapter)
163
- * @returns {F} the same function, tagged with the validator
164
- */
165
- export function validateInput(fn, validate) {
166
- if (typeof fn !== 'function') {
167
- throw new Error('validateInput(): first argument must be the action function');
168
- }
169
- if (typeof validate !== 'function') {
170
- throw new Error('validateInput(): second argument must be a validator function');
171
- }
172
- const existing = /** @type any */ (fn).__webjsHttp || {};
173
- /** @type any */ (fn).__webjsHttp = { ...existing, validate };
174
- return fn;
175
- }
176
-
177
- /** @param {unknown} fn */
178
- export function getExposed(fn) {
179
- return fn && typeof fn === 'function' ? /** @type any */ (fn).__webjsHttp || null : null;
180
- }