@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/README.md +1 -1
- package/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +14 -14
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +3 -3
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -5
- package/index.d.ts +14 -1
- package/index.js +5 -1
- package/package.json +1 -1
- package/src/action-stream.d.ts +12 -0
- package/src/action-stream.js +92 -0
- package/src/render-server.js +6 -2
- package/src/serializable.d.ts +104 -0
- package/src/serialize.js +16 -2
- package/src/expose.js +0 -180
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)
|
|
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
|
@@ -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
|
+
}
|
package/src/render-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|