@getforma/core 1.2.0 → 1.4.0
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/dist/{chunk-YRNYOZF3.js → chunk-AJP4OVCN.js} +27 -7
- package/dist/chunk-AJP4OVCN.js.map +1 -0
- package/dist/{chunk-E3FV2VSU.cjs → chunk-FBM7V4NE.cjs} +27 -6
- package/dist/chunk-FBM7V4NE.cjs.map +1 -0
- package/dist/forma-runtime-csp.js +178 -79
- package/dist/forma-runtime.js +178 -79
- package/dist/formajs-runtime-hardened.global.js +178 -79
- package/dist/formajs-runtime-hardened.global.js.map +1 -1
- package/dist/formajs-runtime.global.js +178 -79
- package/dist/formajs-runtime.global.js.map +1 -1
- package/dist/formajs.global.js +69 -9
- package/dist/formajs.global.js.map +1 -1
- package/dist/index.cjs +59 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +46 -7
- package/dist/index.js.map +1 -1
- package/dist/runtime-hardened.cjs +178 -79
- package/dist/runtime-hardened.cjs.map +1 -1
- package/dist/runtime-hardened.js +178 -79
- package/dist/runtime-hardened.js.map +1 -1
- package/dist/runtime.cjs +172 -77
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +172 -77
- package/dist/runtime.js.map +1 -1
- package/dist/server.cjs +62 -9
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +29 -3
- package/dist/server.d.ts +29 -3
- package/dist/server.js +62 -10
- package/dist/server.js.map +1 -1
- package/dist/ssr/index.cjs +22 -4
- package/dist/ssr/index.cjs.map +1 -1
- package/dist/ssr/index.d.cts +7 -0
- package/dist/ssr/index.d.ts +7 -0
- package/dist/ssr/index.js +22 -4
- package/dist/ssr/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-E3FV2VSU.cjs.map +0 -1
- package/dist/chunk-YRNYOZF3.js.map +0 -1
package/dist/server.cjs
CHANGED
|
@@ -121,18 +121,55 @@ function getServerFunction(endpoint) {
|
|
|
121
121
|
function getRegisteredEndpoints() {
|
|
122
122
|
return [...registry.keys()];
|
|
123
123
|
}
|
|
124
|
+
var globalGuard;
|
|
125
|
+
function setRPCGuard(fn) {
|
|
126
|
+
globalGuard = fn;
|
|
127
|
+
}
|
|
128
|
+
var FORBIDDEN_ARG_KEYS = ["__proto__", "constructor", "prototype"];
|
|
129
|
+
function deepStripForbidden(value) {
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
for (const v of value) deepStripForbidden(v);
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
if (value && typeof value === "object") {
|
|
135
|
+
for (const key of FORBIDDEN_ARG_KEYS) {
|
|
136
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
137
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
138
|
+
if (desc?.configurable) delete value[key];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const k of Object.keys(value)) {
|
|
142
|
+
deepStripForbidden(value[k]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
124
147
|
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
125
|
-
async function handleRPC(endpoint, body, revalidateData) {
|
|
148
|
+
async function handleRPC(endpoint, body, revalidateData, options) {
|
|
126
149
|
const endpointName = endpoint.split("/").pop() ?? "";
|
|
127
150
|
if (FORBIDDEN_KEYS.has(endpointName)) {
|
|
128
|
-
return { error: "Forbidden endpoint name" };
|
|
151
|
+
return { error: "Forbidden endpoint name", status: 403 };
|
|
152
|
+
}
|
|
153
|
+
if (!body || !Array.isArray(body.args)) {
|
|
154
|
+
return { error: "Invalid RPC request: missing args array", status: 400 };
|
|
129
155
|
}
|
|
130
156
|
const fn = registry.get(endpoint);
|
|
131
157
|
if (!fn) {
|
|
132
|
-
return { error: `Unknown server function: ${endpoint}
|
|
158
|
+
return { error: `Unknown server function: ${endpoint}`, status: 404 };
|
|
159
|
+
}
|
|
160
|
+
const args = deepStripForbidden([...body.args]);
|
|
161
|
+
const guard = options?.authorize ?? globalGuard;
|
|
162
|
+
if (guard) {
|
|
163
|
+
let ok;
|
|
164
|
+
try {
|
|
165
|
+
ok = !!await guard(endpoint, args, options?.context ?? {});
|
|
166
|
+
} catch {
|
|
167
|
+
return { error: "Forbidden", status: 403 };
|
|
168
|
+
}
|
|
169
|
+
if (!ok) return { error: "Forbidden", status: 403 };
|
|
133
170
|
}
|
|
134
171
|
try {
|
|
135
|
-
const result = await fn(...
|
|
172
|
+
const result = await fn(...args);
|
|
136
173
|
if (revalidateData) {
|
|
137
174
|
return { data: result, __revalidate: revalidateData };
|
|
138
175
|
}
|
|
@@ -140,25 +177,40 @@ async function handleRPC(endpoint, body, revalidateData) {
|
|
|
140
177
|
} catch (err) {
|
|
141
178
|
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
|
|
142
179
|
const message = isDev && err instanceof Error ? err.message : "Internal server error";
|
|
143
|
-
return { error: message };
|
|
180
|
+
return { error: message, status: 500 };
|
|
144
181
|
}
|
|
145
182
|
}
|
|
146
|
-
function createRPCMiddleware() {
|
|
183
|
+
function createRPCMiddleware(opts) {
|
|
147
184
|
return async (req, res) => {
|
|
148
185
|
if (req.method !== "POST") {
|
|
149
186
|
res.status(405).json({ error: "Method not allowed" });
|
|
150
187
|
return;
|
|
151
188
|
}
|
|
189
|
+
const h = req.headers ?? {};
|
|
190
|
+
if (h["x-forma-rpc"] !== "1") {
|
|
191
|
+
res.status(403).json({ error: "Missing X-Forma-RPC header" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const ct = String(h["content-type"] ?? "");
|
|
195
|
+
if (!ct.includes("application/json")) {
|
|
196
|
+
res.status(415).json({ error: "Unsupported Media Type" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
152
199
|
const body = req.body;
|
|
153
200
|
if (!body || !Array.isArray(body.args)) {
|
|
154
201
|
res.status(400).json({ error: "Invalid RPC request: missing args array" });
|
|
155
202
|
return;
|
|
156
203
|
}
|
|
157
|
-
const
|
|
204
|
+
const path = req.path ?? req.url.split("?")[0];
|
|
205
|
+
const result = await handleRPC(path, body, void 0, {
|
|
206
|
+
authorize: opts?.guard,
|
|
207
|
+
context: { req, headers: h }
|
|
208
|
+
});
|
|
158
209
|
if (result.error) {
|
|
159
|
-
res.status(500).json(result);
|
|
210
|
+
res.status(result.status ?? 500).json({ error: result.error });
|
|
160
211
|
} else {
|
|
161
|
-
|
|
212
|
+
const { status: _status, ...rest } = result;
|
|
213
|
+
res.json(rest);
|
|
162
214
|
}
|
|
163
215
|
};
|
|
164
216
|
}
|
|
@@ -173,6 +225,7 @@ exports.getServerFunction = getServerFunction;
|
|
|
173
225
|
exports.handleRPC = handleRPC;
|
|
174
226
|
exports.registerResource = registerResource;
|
|
175
227
|
exports.registerServerFunction = registerServerFunction;
|
|
228
|
+
exports.setRPCGuard = setRPCGuard;
|
|
176
229
|
exports.unregisterResource = unregisterResource;
|
|
177
230
|
exports.withRevalidation = withRevalidation;
|
|
178
231
|
//# sourceMappingURL=server.cjs.map
|
package/dist/server.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server/action.ts","../src/server/mutation.ts","../src/server/rpc-client.ts","../src/server/rpc-handler.ts"],"names":["createSignal","batch"],"mappings":"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","file":"server.cjs","sourcesContent":["/**\n * FormaJS Server - Action\n *\n * Creates an action that wraps a server function with optimistic UI support.\n * The action immediately applies an optimistic update, then reconciles when\n * the server responds (or rolls back on error).\n */\n\nimport { createSignal, batch } from '../reactive/index.js';\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ActionOptions<Args extends unknown[], Result> {\n /**\n * Apply an optimistic update immediately when the action is called.\n * This runs BEFORE the server function.\n * Store whatever you need to roll back in the return value or via closures.\n */\n optimistic?: (...args: Args) => void;\n\n /**\n * Called when the server function resolves successfully.\n * Use this to apply the server result to local state.\n */\n onSuccess?: (result: Result, ...args: Args) => void;\n\n /**\n * Called when the server function rejects.\n * Use this to roll back the optimistic update.\n */\n onError?: (error: unknown, ...args: Args) => void;\n\n /**\n * Resources to refetch after the action completes successfully.\n * Used with single-flight mutations: if the server response includes\n * revalidation data, these resources are updated without a refetch.\n */\n invalidates?: Resource<unknown>[];\n}\n\nexport interface Action<Args extends unknown[], Result> {\n /** Execute the action. */\n (...args: Args): Promise<Result>;\n /** Whether the action is currently in-flight. */\n pending: () => boolean;\n /** The last error from the action (or undefined). */\n error: () => unknown;\n /** Clear the error state. */\n clearError: () => void;\n}\n\n/**\n * Create an action that wraps a server function with optimistic UI.\n *\n * ```ts\n * const addTodo = createAction(\n * serverCreateTodo,\n * {\n * optimistic: (text) => {\n * // Immediately add to local list\n * setTodos(prev => [...prev, { text, done: false, id: 'temp' }]);\n * },\n * onSuccess: (result) => {\n * // Replace temp item with server result\n * setTodos(prev => prev.map(t => t.id === 'temp' ? result : t));\n * },\n * onError: (err, text) => {\n * // Remove the optimistic item\n * setTodos(prev => prev.filter(t => t.id !== 'temp'));\n * },\n * invalidates: [todosResource],\n * },\n * );\n *\n * // Use:\n * await addTodo('Buy milk');\n * ```\n */\nexport function createAction<Args extends unknown[], Result>(\n serverFn: (...args: Args) => Promise<Result>,\n options?: ActionOptions<Args, Result>,\n): Action<Args, Result> {\n const [pending, setPending] = createSignal(false);\n const [error, setError] = createSignal<unknown>(undefined);\n\n const action = async (...args: Args): Promise<Result> => {\n setPending(true);\n setError(undefined);\n\n // Apply optimistic update immediately\n if (options?.optimistic) {\n try {\n batch(() => options.optimistic!(...args));\n } catch {\n // Swallow errors in optimistic callback\n }\n }\n\n try {\n const result = await serverFn(...args);\n\n // Apply success handler\n if (options?.onSuccess) {\n batch(() => options.onSuccess!(result, ...args));\n }\n\n // Revalidate dependent resources\n if (options?.invalidates) {\n for (const resource of options.invalidates) {\n resource.refetch();\n }\n }\n\n setPending(false);\n return result;\n } catch (err) {\n // Apply error/rollback handler\n if (options?.onError) {\n try {\n batch(() => options.onError!(err, ...args));\n } catch {\n // Swallow errors in rollback\n }\n }\n\n setError(err);\n setPending(false);\n throw err;\n }\n };\n\n // Attach signal accessors to the action function\n const typedAction = action as Action<Args, Result>;\n typedAction.pending = pending;\n typedAction.error = error;\n typedAction.clearError = () => setError(undefined);\n\n return typedAction;\n}\n","/**\n * FormaJS Server - Mutation\n *\n * Single-flight mutation pattern: the server response carries both the\n * mutation result AND fresh data for dependent resources in one round trip.\n *\n * Without single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { id: 1, text: \"Buy milk\" }\n * Client -> Server: GET /api/todos (refetch to update list)\n * Server -> Client: [all todos]\n *\n * With single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { data: {...}, __revalidate: { \"/api/todos\": [all todos] } }\n * (No second request needed!)\n */\n\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface MutationResponse<T> {\n /** The mutation result. */\n data: T;\n /**\n * Fresh data for dependent resources, keyed by resource identifier.\n * When present, the client updates these resources directly instead of refetching.\n */\n __revalidate?: Record<string, unknown>;\n}\n\n// ---------------------------------------------------------------------------\n// Resource registry for revalidation\n// ---------------------------------------------------------------------------\n\nconst resourceRegistry = new Map<string, Resource<unknown>>();\n\n/**\n * Register a resource with a key so it can be revalidated by single-flight mutations.\n *\n * ```ts\n * const todos = createResource(\n * () => true,\n * () => fetch('/api/todos').then(r => r.json()),\n * );\n * registerResource('/api/todos', todos);\n * ```\n */\nexport function registerResource(key: string, resource: Resource<unknown>): void {\n resourceRegistry.set(key, resource);\n}\n\n/**\n * Unregister a resource (call on cleanup/unmount).\n */\nexport function unregisterResource(key: string): void {\n resourceRegistry.delete(key);\n}\n\n/**\n * Apply revalidation data from a single-flight mutation response.\n * For each key in the revalidate map, find the matching resource\n * and mutate it directly with the fresh data (skipping a refetch).\n */\nexport function applyRevalidation(revalidateData: Record<string, unknown>): void {\n for (const [key, freshData] of Object.entries(revalidateData)) {\n const resource = resourceRegistry.get(key);\n if (resource) {\n resource.mutate(freshData);\n }\n }\n}\n\n/**\n * Listen for revalidation events dispatched by $$serverFunction.\n * Call this once during app initialization to enable automatic\n * single-flight mutation handling.\n *\n * ```ts\n * // In your app entry:\n * import { enableAutoRevalidation } from 'forma/server/mutation';\n * enableAutoRevalidation();\n * ```\n */\nexport function enableAutoRevalidation(): () => void {\n if (typeof window === 'undefined') {\n // SSR/Node.js — no-op\n return () => {};\n }\n\n const handler = (event: Event) => {\n const detail = (event as CustomEvent).detail;\n if (detail && typeof detail === 'object') {\n applyRevalidation(detail as Record<string, unknown>);\n }\n };\n\n window.addEventListener('forma:revalidate', handler);\n\n // Return cleanup function\n return () => window.removeEventListener('forma:revalidate', handler);\n}\n\n/**\n * Wrap a server function response to include revalidation data.\n * Use this on the server side to enable single-flight mutations.\n *\n * ```ts\n * // Server-side:\n * async function createTodo(text: string) {\n * \"use server\";\n * const newTodo = await db.insert('todos', { text, done: false });\n * const allTodos = await db.query('todos');\n * return withRevalidation(newTodo, {\n * '/api/todos': allTodos,\n * });\n * }\n * ```\n */\nexport function withRevalidation<T>(data: T, revalidate: Record<string, unknown>): MutationResponse<T> {\n return { data, __revalidate: revalidate };\n}\n","/**\n * FormaJS Server - RPC Client\n *\n * Provides the client-side stub function that replaces \"use server\" function\n * bodies after compilation. Each call becomes a fetch POST to the server endpoint.\n */\n\n/**\n * Create an RPC stub function for a server function.\n * This replaces the original function body on the client side.\n *\n * @param endpoint - The RPC endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @returns An async function that sends args to the server and returns the result\n */\nexport function $$serverFunction<T extends (...args: unknown[]) => Promise<unknown>>(\n endpoint: string,\n): T {\n const rpcFn = async (...args: unknown[]): Promise<unknown> => {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Forma-RPC': '1',\n },\n body: JSON.stringify({ args }),\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(`Server function failed (${response.status}): ${errorText}`);\n }\n\n const result = await response.json();\n\n // If the response includes revalidation data (single-flight mutations),\n // return it alongside the result\n if (result && typeof result === 'object' && '__revalidate' in result) {\n // Dispatch a custom event so createAction can pick up the revalidation data\n if (typeof window !== 'undefined') {\n window.dispatchEvent(new CustomEvent('forma:revalidate', {\n detail: result.__revalidate,\n }));\n }\n return result.data;\n }\n\n return result;\n };\n\n return rpcFn as T;\n}\n","/**\n * FormaJS Server - RPC Handler\n *\n * Server-side registry and request handler for \"use server\" functions.\n * Framework-agnostic — works with any Node.js HTTP server, Express, Hono, etc.\n */\n\nexport type ServerFunction = (...args: unknown[]) => Promise<unknown>;\n\n/** Registry of server functions by endpoint path. */\nconst registry = new Map<string, ServerFunction>();\n\n/**\n * Register a server function at a specific endpoint.\n * Called by the server-side compiled output.\n */\nexport function registerServerFunction(endpoint: string, fn: ServerFunction): void {\n registry.set(endpoint, fn);\n}\n\n/**\n * Get a registered server function by endpoint.\n */\nexport function getServerFunction(endpoint: string): ServerFunction | undefined {\n return registry.get(endpoint);\n}\n\n/**\n * Get all registered server function endpoints.\n */\nexport function getRegisteredEndpoints(): string[] {\n return [...registry.keys()];\n}\n\nexport interface RPCRequest {\n args: unknown[];\n}\n\nexport interface RPCResponse {\n data?: unknown;\n error?: string;\n __revalidate?: Record<string, unknown>;\n}\n\n/**\n * Handle an incoming RPC request.\n * Call this from your HTTP server's request handler.\n *\n * Usage with any HTTP framework:\n * ```ts\n * import { handleRPC } from 'forma/server/rpc-handler';\n *\n * // Express example:\n * app.post('/rpc/:endpoint', async (req, res) => {\n * const result = await handleRPC(req.path, req.body);\n * res.json(result);\n * });\n *\n * // Hono example:\n * app.post('/rpc/*', async (c) => {\n * const body = await c.req.json();\n * const result = await handleRPC(c.req.path, body);\n * return c.json(result);\n * });\n * ```\n *\n * @param endpoint - The full endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @param body - The parsed request body containing { args: [...] }\n * @param revalidateData - Optional revalidation data to include in the response\n * (for single-flight mutations)\n */\n/** \"Crash Barrier\": block prototype pollution attacks via endpoint names. */\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);\n\nexport async function handleRPC(\n endpoint: string,\n body: RPCRequest,\n revalidateData?: Record<string, unknown>,\n): Promise<RPCResponse> {\n // Security: prevent prototype pollution via crafted endpoint names\n const endpointName = endpoint.split('/').pop() ?? '';\n if (FORBIDDEN_KEYS.has(endpointName)) {\n return { error: 'Forbidden endpoint name' };\n }\n\n const fn = registry.get(endpoint);\n if (!fn) {\n return { error: `Unknown server function: ${endpoint}` };\n }\n\n try {\n const result = await fn(...body.args);\n\n if (revalidateData) {\n return { data: result, __revalidate: revalidateData };\n }\n\n return { data: result };\n } catch (err) {\n // Don't leak internal error details to clients\n const isDev = typeof process !== 'undefined'\n && process.env?.NODE_ENV === 'development';\n const message = isDev && err instanceof Error\n ? err.message\n : 'Internal server error';\n return { error: message };\n }\n}\n\n/**\n * Create a middleware-style handler for use with Express-like frameworks.\n *\n * ```ts\n * import { createRPCMiddleware } from 'forma/server/rpc-handler';\n * app.use('/rpc', createRPCMiddleware());\n * ```\n */\nexport function createRPCMiddleware() {\n return async (req: { url: string; method: string; body?: unknown }, res: { json: (data: unknown) => void; status: (code: number) => { json: (data: unknown) => void } }) => {\n if (req.method !== 'POST') {\n res.status(405).json({ error: 'Method not allowed' });\n return;\n }\n\n const body = req.body as RPCRequest;\n if (!body || !Array.isArray(body.args)) {\n res.status(400).json({ error: 'Invalid RPC request: missing args array' });\n return;\n }\n\n const result = await handleRPC(req.url, body);\n if (result.error) {\n res.status(500).json(result);\n } else {\n res.json(result);\n }\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/server/action.ts","../src/server/mutation.ts","../src/server/rpc-client.ts","../src/server/rpc-handler.ts"],"names":["createSignal","batch"],"mappings":"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","file":"server.cjs","sourcesContent":["/**\n * FormaJS Server - Action\n *\n * Creates an action that wraps a server function with optimistic UI support.\n * The action immediately applies an optimistic update, then reconciles when\n * the server responds (or rolls back on error).\n */\n\nimport { createSignal, batch } from '../reactive/index.js';\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ActionOptions<Args extends unknown[], Result> {\n /**\n * Apply an optimistic update immediately when the action is called.\n * This runs BEFORE the server function.\n * Store whatever you need to roll back in the return value or via closures.\n */\n optimistic?: (...args: Args) => void;\n\n /**\n * Called when the server function resolves successfully.\n * Use this to apply the server result to local state.\n */\n onSuccess?: (result: Result, ...args: Args) => void;\n\n /**\n * Called when the server function rejects.\n * Use this to roll back the optimistic update.\n */\n onError?: (error: unknown, ...args: Args) => void;\n\n /**\n * Resources to refetch after the action completes successfully.\n * Used with single-flight mutations: if the server response includes\n * revalidation data, these resources are updated without a refetch.\n */\n invalidates?: Resource<unknown>[];\n}\n\nexport interface Action<Args extends unknown[], Result> {\n /** Execute the action. */\n (...args: Args): Promise<Result>;\n /** Whether the action is currently in-flight. */\n pending: () => boolean;\n /** The last error from the action (or undefined). */\n error: () => unknown;\n /** Clear the error state. */\n clearError: () => void;\n}\n\n/**\n * Create an action that wraps a server function with optimistic UI.\n *\n * ```ts\n * const addTodo = createAction(\n * serverCreateTodo,\n * {\n * optimistic: (text) => {\n * // Immediately add to local list\n * setTodos(prev => [...prev, { text, done: false, id: 'temp' }]);\n * },\n * onSuccess: (result) => {\n * // Replace temp item with server result\n * setTodos(prev => prev.map(t => t.id === 'temp' ? result : t));\n * },\n * onError: (err, text) => {\n * // Remove the optimistic item\n * setTodos(prev => prev.filter(t => t.id !== 'temp'));\n * },\n * invalidates: [todosResource],\n * },\n * );\n *\n * // Use:\n * await addTodo('Buy milk');\n * ```\n */\nexport function createAction<Args extends unknown[], Result>(\n serverFn: (...args: Args) => Promise<Result>,\n options?: ActionOptions<Args, Result>,\n): Action<Args, Result> {\n const [pending, setPending] = createSignal(false);\n const [error, setError] = createSignal<unknown>(undefined);\n\n const action = async (...args: Args): Promise<Result> => {\n setPending(true);\n setError(undefined);\n\n // Apply optimistic update immediately\n if (options?.optimistic) {\n try {\n batch(() => options.optimistic!(...args));\n } catch {\n // Swallow errors in optimistic callback\n }\n }\n\n try {\n const result = await serverFn(...args);\n\n // Apply success handler\n if (options?.onSuccess) {\n batch(() => options.onSuccess!(result, ...args));\n }\n\n // Revalidate dependent resources\n if (options?.invalidates) {\n for (const resource of options.invalidates) {\n resource.refetch();\n }\n }\n\n setPending(false);\n return result;\n } catch (err) {\n // Apply error/rollback handler\n if (options?.onError) {\n try {\n batch(() => options.onError!(err, ...args));\n } catch {\n // Swallow errors in rollback\n }\n }\n\n setError(err);\n setPending(false);\n throw err;\n }\n };\n\n // Attach signal accessors to the action function\n const typedAction = action as Action<Args, Result>;\n typedAction.pending = pending;\n typedAction.error = error;\n typedAction.clearError = () => setError(undefined);\n\n return typedAction;\n}\n","/**\n * FormaJS Server - Mutation\n *\n * Single-flight mutation pattern: the server response carries both the\n * mutation result AND fresh data for dependent resources in one round trip.\n *\n * Without single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { id: 1, text: \"Buy milk\" }\n * Client -> Server: GET /api/todos (refetch to update list)\n * Server -> Client: [all todos]\n *\n * With single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { data: {...}, __revalidate: { \"/api/todos\": [all todos] } }\n * (No second request needed!)\n */\n\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface MutationResponse<T> {\n /** The mutation result. */\n data: T;\n /**\n * Fresh data for dependent resources, keyed by resource identifier.\n * When present, the client updates these resources directly instead of refetching.\n */\n __revalidate?: Record<string, unknown>;\n}\n\n// ---------------------------------------------------------------------------\n// Resource registry for revalidation\n// ---------------------------------------------------------------------------\n\nconst resourceRegistry = new Map<string, Resource<unknown>>();\n\n/**\n * Register a resource with a key so it can be revalidated by single-flight mutations.\n *\n * ```ts\n * const todos = createResource(\n * () => true,\n * () => fetch('/api/todos').then(r => r.json()),\n * );\n * registerResource('/api/todos', todos);\n * ```\n */\nexport function registerResource(key: string, resource: Resource<unknown>): void {\n resourceRegistry.set(key, resource);\n}\n\n/**\n * Unregister a resource (call on cleanup/unmount).\n */\nexport function unregisterResource(key: string): void {\n resourceRegistry.delete(key);\n}\n\n/**\n * Apply revalidation data from a single-flight mutation response.\n * For each key in the revalidate map, find the matching resource\n * and mutate it directly with the fresh data (skipping a refetch).\n */\nexport function applyRevalidation(revalidateData: Record<string, unknown>): void {\n for (const [key, freshData] of Object.entries(revalidateData)) {\n const resource = resourceRegistry.get(key);\n if (resource) {\n resource.mutate(freshData);\n }\n }\n}\n\n/**\n * Listen for revalidation events dispatched by $$serverFunction.\n * Call this once during app initialization to enable automatic\n * single-flight mutation handling.\n *\n * ```ts\n * // In your app entry:\n * import { enableAutoRevalidation } from 'forma/server/mutation';\n * enableAutoRevalidation();\n * ```\n */\nexport function enableAutoRevalidation(): () => void {\n if (typeof window === 'undefined') {\n // SSR/Node.js — no-op\n return () => {};\n }\n\n const handler = (event: Event) => {\n const detail = (event as CustomEvent).detail;\n if (detail && typeof detail === 'object') {\n applyRevalidation(detail as Record<string, unknown>);\n }\n };\n\n window.addEventListener('forma:revalidate', handler);\n\n // Return cleanup function\n return () => window.removeEventListener('forma:revalidate', handler);\n}\n\n/**\n * Wrap a server function response to include revalidation data.\n * Use this on the server side to enable single-flight mutations.\n *\n * ```ts\n * // Server-side:\n * async function createTodo(text: string) {\n * \"use server\";\n * const newTodo = await db.insert('todos', { text, done: false });\n * const allTodos = await db.query('todos');\n * return withRevalidation(newTodo, {\n * '/api/todos': allTodos,\n * });\n * }\n * ```\n */\nexport function withRevalidation<T>(data: T, revalidate: Record<string, unknown>): MutationResponse<T> {\n return { data, __revalidate: revalidate };\n}\n","/**\n * FormaJS Server - RPC Client\n *\n * Provides the client-side stub function that replaces \"use server\" function\n * bodies after compilation. Each call becomes a fetch POST to the server endpoint.\n */\n\n/**\n * Create an RPC stub function for a server function.\n * This replaces the original function body on the client side.\n *\n * @param endpoint - The RPC endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @returns An async function that sends args to the server and returns the result\n */\nexport function $$serverFunction<T extends (...args: unknown[]) => Promise<unknown>>(\n endpoint: string,\n): T {\n const rpcFn = async (...args: unknown[]): Promise<unknown> => {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Forma-RPC': '1',\n },\n body: JSON.stringify({ args }),\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(`Server function failed (${response.status}): ${errorText}`);\n }\n\n const result = await response.json();\n\n // If the response includes revalidation data (single-flight mutations),\n // return it alongside the result\n if (result && typeof result === 'object' && '__revalidate' in result) {\n // Dispatch a custom event so createAction can pick up the revalidation data\n if (typeof window !== 'undefined') {\n window.dispatchEvent(new CustomEvent('forma:revalidate', {\n detail: result.__revalidate,\n }));\n }\n return result.data;\n }\n\n return result;\n };\n\n return rpcFn as T;\n}\n","/**\n * FormaJS Server - RPC Handler\n *\n * Server-side registry and request handler for \"use server\" functions.\n * Framework-agnostic — works with any Node.js HTTP server, Express, Hono, etc.\n */\n\nexport type ServerFunction = (...args: unknown[]) => Promise<unknown>;\n\n/** Registry of server functions by endpoint path. */\nconst registry = new Map<string, ServerFunction>();\n\n/**\n * Register a server function at a specific endpoint.\n * Called by the server-side compiled output.\n */\nexport function registerServerFunction(endpoint: string, fn: ServerFunction): void {\n registry.set(endpoint, fn);\n}\n\n/**\n * Get a registered server function by endpoint.\n */\nexport function getServerFunction(endpoint: string): ServerFunction | undefined {\n return registry.get(endpoint);\n}\n\n/**\n * Get all registered server function endpoints.\n */\nexport function getRegisteredEndpoints(): string[] {\n return [...registry.keys()];\n}\n\nexport interface RPCRequest {\n args: unknown[];\n}\n\nexport interface RPCResponse {\n data?: unknown;\n error?: string;\n /** Suggested HTTP status for the middleware to map (error paths only). Not sent in the body. */\n status?: number;\n __revalidate?: Record<string, unknown>;\n}\n\n/** Context passed to an {@link RPCGuard} (the request and its headers, if available). */\nexport interface RPCContext {\n req?: unknown;\n headers?: Record<string, string | undefined>;\n}\n\n/**\n * Authorization guard for RPC calls. Return false (or a rejected/false promise)\n * to deny the call with 403. handleRPC performs NO authentication by itself —\n * install a guard to authenticate/authorize.\n */\nexport type RPCGuard = (endpoint: string, args: unknown[], ctx: RPCContext) => boolean | Promise<boolean>;\n\nlet globalGuard: RPCGuard | undefined;\n\n/** Install (or clear) a process-global RPC authorization guard. */\nexport function setRPCGuard(fn: RPCGuard | undefined): void {\n globalGuard = fn;\n}\n\n/** Options for {@link handleRPC}. */\nexport interface HandleRPCOptions {\n /** Per-call guard; overrides the global guard when provided. */\n authorize?: RPCGuard;\n /** Context forwarded to the guard. */\n context?: RPCContext;\n}\n\n/** Keys that enable prototype-pollution; stripped from RPC args before invocation. */\nconst FORBIDDEN_ARG_KEYS = ['__proto__', 'constructor', 'prototype'];\n\n/** Recursively delete prototype-pollution keys from parsed RPC arguments (mutating). */\nfunction deepStripForbidden<T>(value: T): T {\n if (Array.isArray(value)) {\n for (const v of value) deepStripForbidden(v);\n return value;\n }\n if (value && typeof value === 'object') {\n for (const key of FORBIDDEN_ARG_KEYS) {\n if (Object.prototype.hasOwnProperty.call(value, key)) {\n // Guard the delete: it throws on a frozen/sealed (non-configurable) prop.\n const desc = Object.getOwnPropertyDescriptor(value, key);\n if (desc?.configurable) delete (value as Record<string, unknown>)[key];\n }\n }\n for (const k of Object.keys(value as Record<string, unknown>)) {\n deepStripForbidden((value as Record<string, unknown>)[k]);\n }\n }\n return value;\n}\n\n/**\n * Handle an incoming RPC request.\n * Call this from your HTTP server's request handler.\n *\n * Usage with any HTTP framework:\n * ```ts\n * import { handleRPC } from 'forma/server/rpc-handler';\n *\n * // Express example:\n * app.post('/rpc/:endpoint', async (req, res) => {\n * const result = await handleRPC(req.path, req.body);\n * res.json(result);\n * });\n *\n * // Hono example:\n * app.post('/rpc/*', async (c) => {\n * const body = await c.req.json();\n * const result = await handleRPC(c.req.path, body);\n * return c.json(result);\n * });\n * ```\n *\n * @param endpoint - The full endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @param body - The parsed request body containing { args: [...] }\n * @param revalidateData - Optional revalidation data to include in the response\n * (for single-flight mutations)\n */\n/** \"Crash Barrier\": block prototype pollution attacks via endpoint names. */\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);\n\nexport async function handleRPC(\n endpoint: string,\n body: RPCRequest,\n revalidateData?: Record<string, unknown>,\n options?: HandleRPCOptions,\n): Promise<RPCResponse> {\n // Security: prevent prototype pollution via crafted endpoint names\n const endpointName = endpoint.split('/').pop() ?? '';\n if (FORBIDDEN_KEYS.has(endpointName)) {\n return { error: 'Forbidden endpoint name', status: 403 };\n }\n\n // Enforce the args-array contract on the direct path too (not just middleware).\n if (!body || !Array.isArray(body.args)) {\n return { error: 'Invalid RPC request: missing args array', status: 400 };\n }\n\n const fn = registry.get(endpoint);\n if (!fn) {\n return { error: `Unknown server function: ${endpoint}`, status: 404 };\n }\n\n // Strip prototype-pollution keys from client-controlled args before invocation.\n const args = deepStripForbidden([...body.args]);\n\n // Authorization is the deployment's responsibility — run any installed guard.\n // A guard that throws or rejects is treated as a denial (fail-closed): return\n // 403 without leaking the guard's error to the client.\n const guard = options?.authorize ?? globalGuard;\n if (guard) {\n let ok: boolean;\n try {\n ok = !!(await guard(endpoint, args, options?.context ?? {}));\n } catch {\n return { error: 'Forbidden', status: 403 };\n }\n if (!ok) return { error: 'Forbidden', status: 403 };\n }\n\n try {\n const result = await fn(...args);\n\n if (revalidateData) {\n return { data: result, __revalidate: revalidateData };\n }\n\n return { data: result };\n } catch (err) {\n // Don't leak internal error details to clients\n const isDev = typeof process !== 'undefined'\n && process.env?.NODE_ENV === 'development';\n const message = isDev && err instanceof Error\n ? err.message\n : 'Internal server error';\n return { error: message, status: 500 };\n }\n}\n\n/**\n * Create a middleware-style handler for use with Express-like frameworks.\n *\n * ```ts\n * import { createRPCMiddleware } from 'forma/server/rpc-handler';\n * app.use('/rpc', createRPCMiddleware());\n * ```\n */\nexport function createRPCMiddleware(opts?: { guard?: RPCGuard }) {\n return async (\n req: { url: string; method: string; path?: string; body?: unknown; headers?: Record<string, string | undefined> },\n res: { json: (data: unknown) => void; status: (code: number) => { json: (data: unknown) => void } },\n ) => {\n if (req.method !== 'POST') {\n res.status(405).json({ error: 'Method not allowed' });\n return;\n }\n\n // CSRF mitigation: require the custom header (which forces a CORS preflight\n // and cannot be set by a cross-site HTML form) and a JSON content type.\n const h = req.headers ?? {};\n if (h['x-forma-rpc'] !== '1') {\n res.status(403).json({ error: 'Missing X-Forma-RPC header' });\n return;\n }\n const ct = String(h['content-type'] ?? '');\n if (!ct.includes('application/json')) {\n res.status(415).json({ error: 'Unsupported Media Type' });\n return;\n }\n\n const body = req.body as RPCRequest;\n if (!body || !Array.isArray(body.args)) {\n res.status(400).json({ error: 'Invalid RPC request: missing args array' });\n return;\n }\n\n // Resolve the endpoint path without the query string.\n const path = req.path ?? req.url.split('?')[0]!;\n const result = await handleRPC(path, body, undefined, {\n authorize: opts?.guard,\n context: { req, headers: h },\n });\n if (result.error) {\n res.status(result.status ?? 500).json({ error: result.error });\n } else {\n // Do not leak the internal `status` discriminator into the success body.\n const { status: _status, ...rest } = result;\n res.json(rest);\n }\n };\n}\n"]}
|
package/dist/server.d.cts
CHANGED
|
@@ -192,9 +192,31 @@ interface RPCRequest {
|
|
|
192
192
|
interface RPCResponse {
|
|
193
193
|
data?: unknown;
|
|
194
194
|
error?: string;
|
|
195
|
+
/** Suggested HTTP status for the middleware to map (error paths only). Not sent in the body. */
|
|
196
|
+
status?: number;
|
|
195
197
|
__revalidate?: Record<string, unknown>;
|
|
196
198
|
}
|
|
197
|
-
|
|
199
|
+
/** Context passed to an {@link RPCGuard} (the request and its headers, if available). */
|
|
200
|
+
interface RPCContext {
|
|
201
|
+
req?: unknown;
|
|
202
|
+
headers?: Record<string, string | undefined>;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Authorization guard for RPC calls. Return false (or a rejected/false promise)
|
|
206
|
+
* to deny the call with 403. handleRPC performs NO authentication by itself —
|
|
207
|
+
* install a guard to authenticate/authorize.
|
|
208
|
+
*/
|
|
209
|
+
type RPCGuard = (endpoint: string, args: unknown[], ctx: RPCContext) => boolean | Promise<boolean>;
|
|
210
|
+
/** Install (or clear) a process-global RPC authorization guard. */
|
|
211
|
+
declare function setRPCGuard(fn: RPCGuard | undefined): void;
|
|
212
|
+
/** Options for {@link handleRPC}. */
|
|
213
|
+
interface HandleRPCOptions {
|
|
214
|
+
/** Per-call guard; overrides the global guard when provided. */
|
|
215
|
+
authorize?: RPCGuard;
|
|
216
|
+
/** Context forwarded to the guard. */
|
|
217
|
+
context?: RPCContext;
|
|
218
|
+
}
|
|
219
|
+
declare function handleRPC(endpoint: string, body: RPCRequest, revalidateData?: Record<string, unknown>, options?: HandleRPCOptions): Promise<RPCResponse>;
|
|
198
220
|
/**
|
|
199
221
|
* Create a middleware-style handler for use with Express-like frameworks.
|
|
200
222
|
*
|
|
@@ -203,10 +225,14 @@ declare function handleRPC(endpoint: string, body: RPCRequest, revalidateData?:
|
|
|
203
225
|
* app.use('/rpc', createRPCMiddleware());
|
|
204
226
|
* ```
|
|
205
227
|
*/
|
|
206
|
-
declare function createRPCMiddleware(
|
|
228
|
+
declare function createRPCMiddleware(opts?: {
|
|
229
|
+
guard?: RPCGuard;
|
|
230
|
+
}): (req: {
|
|
207
231
|
url: string;
|
|
208
232
|
method: string;
|
|
233
|
+
path?: string;
|
|
209
234
|
body?: unknown;
|
|
235
|
+
headers?: Record<string, string | undefined>;
|
|
210
236
|
}, res: {
|
|
211
237
|
json: (data: unknown) => void;
|
|
212
238
|
status: (code: number) => {
|
|
@@ -214,4 +240,4 @@ declare function createRPCMiddleware(): (req: {
|
|
|
214
240
|
};
|
|
215
241
|
}) => Promise<void>;
|
|
216
242
|
|
|
217
|
-
export { $$serverFunction, type Action, type ActionOptions, type MutationResponse, type RPCRequest, type RPCResponse, type ServerFunction, applyRevalidation, createAction, createRPCMiddleware, enableAutoRevalidation, getRegisteredEndpoints, getServerFunction, handleRPC, registerResource, registerServerFunction, unregisterResource, withRevalidation };
|
|
243
|
+
export { $$serverFunction, type Action, type ActionOptions, type HandleRPCOptions, type MutationResponse, type RPCContext, type RPCGuard, type RPCRequest, type RPCResponse, type ServerFunction, applyRevalidation, createAction, createRPCMiddleware, enableAutoRevalidation, getRegisteredEndpoints, getServerFunction, handleRPC, registerResource, registerServerFunction, setRPCGuard, unregisterResource, withRevalidation };
|
package/dist/server.d.ts
CHANGED
|
@@ -192,9 +192,31 @@ interface RPCRequest {
|
|
|
192
192
|
interface RPCResponse {
|
|
193
193
|
data?: unknown;
|
|
194
194
|
error?: string;
|
|
195
|
+
/** Suggested HTTP status for the middleware to map (error paths only). Not sent in the body. */
|
|
196
|
+
status?: number;
|
|
195
197
|
__revalidate?: Record<string, unknown>;
|
|
196
198
|
}
|
|
197
|
-
|
|
199
|
+
/** Context passed to an {@link RPCGuard} (the request and its headers, if available). */
|
|
200
|
+
interface RPCContext {
|
|
201
|
+
req?: unknown;
|
|
202
|
+
headers?: Record<string, string | undefined>;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Authorization guard for RPC calls. Return false (or a rejected/false promise)
|
|
206
|
+
* to deny the call with 403. handleRPC performs NO authentication by itself —
|
|
207
|
+
* install a guard to authenticate/authorize.
|
|
208
|
+
*/
|
|
209
|
+
type RPCGuard = (endpoint: string, args: unknown[], ctx: RPCContext) => boolean | Promise<boolean>;
|
|
210
|
+
/** Install (or clear) a process-global RPC authorization guard. */
|
|
211
|
+
declare function setRPCGuard(fn: RPCGuard | undefined): void;
|
|
212
|
+
/** Options for {@link handleRPC}. */
|
|
213
|
+
interface HandleRPCOptions {
|
|
214
|
+
/** Per-call guard; overrides the global guard when provided. */
|
|
215
|
+
authorize?: RPCGuard;
|
|
216
|
+
/** Context forwarded to the guard. */
|
|
217
|
+
context?: RPCContext;
|
|
218
|
+
}
|
|
219
|
+
declare function handleRPC(endpoint: string, body: RPCRequest, revalidateData?: Record<string, unknown>, options?: HandleRPCOptions): Promise<RPCResponse>;
|
|
198
220
|
/**
|
|
199
221
|
* Create a middleware-style handler for use with Express-like frameworks.
|
|
200
222
|
*
|
|
@@ -203,10 +225,14 @@ declare function handleRPC(endpoint: string, body: RPCRequest, revalidateData?:
|
|
|
203
225
|
* app.use('/rpc', createRPCMiddleware());
|
|
204
226
|
* ```
|
|
205
227
|
*/
|
|
206
|
-
declare function createRPCMiddleware(
|
|
228
|
+
declare function createRPCMiddleware(opts?: {
|
|
229
|
+
guard?: RPCGuard;
|
|
230
|
+
}): (req: {
|
|
207
231
|
url: string;
|
|
208
232
|
method: string;
|
|
233
|
+
path?: string;
|
|
209
234
|
body?: unknown;
|
|
235
|
+
headers?: Record<string, string | undefined>;
|
|
210
236
|
}, res: {
|
|
211
237
|
json: (data: unknown) => void;
|
|
212
238
|
status: (code: number) => {
|
|
@@ -214,4 +240,4 @@ declare function createRPCMiddleware(): (req: {
|
|
|
214
240
|
};
|
|
215
241
|
}) => Promise<void>;
|
|
216
242
|
|
|
217
|
-
export { $$serverFunction, type Action, type ActionOptions, type MutationResponse, type RPCRequest, type RPCResponse, type ServerFunction, applyRevalidation, createAction, createRPCMiddleware, enableAutoRevalidation, getRegisteredEndpoints, getServerFunction, handleRPC, registerResource, registerServerFunction, unregisterResource, withRevalidation };
|
|
243
|
+
export { $$serverFunction, type Action, type ActionOptions, type HandleRPCOptions, type MutationResponse, type RPCContext, type RPCGuard, type RPCRequest, type RPCResponse, type ServerFunction, applyRevalidation, createAction, createRPCMiddleware, enableAutoRevalidation, getRegisteredEndpoints, getServerFunction, handleRPC, registerResource, registerServerFunction, setRPCGuard, unregisterResource, withRevalidation };
|
package/dist/server.js
CHANGED
|
@@ -119,18 +119,55 @@ function getServerFunction(endpoint) {
|
|
|
119
119
|
function getRegisteredEndpoints() {
|
|
120
120
|
return [...registry.keys()];
|
|
121
121
|
}
|
|
122
|
+
var globalGuard;
|
|
123
|
+
function setRPCGuard(fn) {
|
|
124
|
+
globalGuard = fn;
|
|
125
|
+
}
|
|
126
|
+
var FORBIDDEN_ARG_KEYS = ["__proto__", "constructor", "prototype"];
|
|
127
|
+
function deepStripForbidden(value) {
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
for (const v of value) deepStripForbidden(v);
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
if (value && typeof value === "object") {
|
|
133
|
+
for (const key of FORBIDDEN_ARG_KEYS) {
|
|
134
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
135
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
136
|
+
if (desc?.configurable) delete value[key];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const k of Object.keys(value)) {
|
|
140
|
+
deepStripForbidden(value[k]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
122
145
|
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
123
|
-
async function handleRPC(endpoint, body, revalidateData) {
|
|
146
|
+
async function handleRPC(endpoint, body, revalidateData, options) {
|
|
124
147
|
const endpointName = endpoint.split("/").pop() ?? "";
|
|
125
148
|
if (FORBIDDEN_KEYS.has(endpointName)) {
|
|
126
|
-
return { error: "Forbidden endpoint name" };
|
|
149
|
+
return { error: "Forbidden endpoint name", status: 403 };
|
|
150
|
+
}
|
|
151
|
+
if (!body || !Array.isArray(body.args)) {
|
|
152
|
+
return { error: "Invalid RPC request: missing args array", status: 400 };
|
|
127
153
|
}
|
|
128
154
|
const fn = registry.get(endpoint);
|
|
129
155
|
if (!fn) {
|
|
130
|
-
return { error: `Unknown server function: ${endpoint}
|
|
156
|
+
return { error: `Unknown server function: ${endpoint}`, status: 404 };
|
|
157
|
+
}
|
|
158
|
+
const args = deepStripForbidden([...body.args]);
|
|
159
|
+
const guard = options?.authorize ?? globalGuard;
|
|
160
|
+
if (guard) {
|
|
161
|
+
let ok;
|
|
162
|
+
try {
|
|
163
|
+
ok = !!await guard(endpoint, args, options?.context ?? {});
|
|
164
|
+
} catch {
|
|
165
|
+
return { error: "Forbidden", status: 403 };
|
|
166
|
+
}
|
|
167
|
+
if (!ok) return { error: "Forbidden", status: 403 };
|
|
131
168
|
}
|
|
132
169
|
try {
|
|
133
|
-
const result = await fn(...
|
|
170
|
+
const result = await fn(...args);
|
|
134
171
|
if (revalidateData) {
|
|
135
172
|
return { data: result, __revalidate: revalidateData };
|
|
136
173
|
}
|
|
@@ -138,29 +175,44 @@ async function handleRPC(endpoint, body, revalidateData) {
|
|
|
138
175
|
} catch (err) {
|
|
139
176
|
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
|
|
140
177
|
const message = isDev && err instanceof Error ? err.message : "Internal server error";
|
|
141
|
-
return { error: message };
|
|
178
|
+
return { error: message, status: 500 };
|
|
142
179
|
}
|
|
143
180
|
}
|
|
144
|
-
function createRPCMiddleware() {
|
|
181
|
+
function createRPCMiddleware(opts) {
|
|
145
182
|
return async (req, res) => {
|
|
146
183
|
if (req.method !== "POST") {
|
|
147
184
|
res.status(405).json({ error: "Method not allowed" });
|
|
148
185
|
return;
|
|
149
186
|
}
|
|
187
|
+
const h = req.headers ?? {};
|
|
188
|
+
if (h["x-forma-rpc"] !== "1") {
|
|
189
|
+
res.status(403).json({ error: "Missing X-Forma-RPC header" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const ct = String(h["content-type"] ?? "");
|
|
193
|
+
if (!ct.includes("application/json")) {
|
|
194
|
+
res.status(415).json({ error: "Unsupported Media Type" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
150
197
|
const body = req.body;
|
|
151
198
|
if (!body || !Array.isArray(body.args)) {
|
|
152
199
|
res.status(400).json({ error: "Invalid RPC request: missing args array" });
|
|
153
200
|
return;
|
|
154
201
|
}
|
|
155
|
-
const
|
|
202
|
+
const path = req.path ?? req.url.split("?")[0];
|
|
203
|
+
const result = await handleRPC(path, body, void 0, {
|
|
204
|
+
authorize: opts?.guard,
|
|
205
|
+
context: { req, headers: h }
|
|
206
|
+
});
|
|
156
207
|
if (result.error) {
|
|
157
|
-
res.status(500).json(result);
|
|
208
|
+
res.status(result.status ?? 500).json({ error: result.error });
|
|
158
209
|
} else {
|
|
159
|
-
|
|
210
|
+
const { status: _status, ...rest } = result;
|
|
211
|
+
res.json(rest);
|
|
160
212
|
}
|
|
161
213
|
};
|
|
162
214
|
}
|
|
163
215
|
|
|
164
|
-
export { $$serverFunction, applyRevalidation, createAction, createRPCMiddleware, enableAutoRevalidation, getRegisteredEndpoints, getServerFunction, handleRPC, registerResource, registerServerFunction, unregisterResource, withRevalidation };
|
|
216
|
+
export { $$serverFunction, applyRevalidation, createAction, createRPCMiddleware, enableAutoRevalidation, getRegisteredEndpoints, getServerFunction, handleRPC, registerResource, registerServerFunction, setRPCGuard, unregisterResource, withRevalidation };
|
|
165
217
|
//# sourceMappingURL=server.js.map
|
|
166
218
|
//# sourceMappingURL=server.js.map
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server/action.ts","../src/server/mutation.ts","../src/server/rpc-client.ts","../src/server/rpc-handler.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["/**\n * FormaJS Server - Action\n *\n * Creates an action that wraps a server function with optimistic UI support.\n * The action immediately applies an optimistic update, then reconciles when\n * the server responds (or rolls back on error).\n */\n\nimport { createSignal, batch } from '../reactive/index.js';\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ActionOptions<Args extends unknown[], Result> {\n /**\n * Apply an optimistic update immediately when the action is called.\n * This runs BEFORE the server function.\n * Store whatever you need to roll back in the return value or via closures.\n */\n optimistic?: (...args: Args) => void;\n\n /**\n * Called when the server function resolves successfully.\n * Use this to apply the server result to local state.\n */\n onSuccess?: (result: Result, ...args: Args) => void;\n\n /**\n * Called when the server function rejects.\n * Use this to roll back the optimistic update.\n */\n onError?: (error: unknown, ...args: Args) => void;\n\n /**\n * Resources to refetch after the action completes successfully.\n * Used with single-flight mutations: if the server response includes\n * revalidation data, these resources are updated without a refetch.\n */\n invalidates?: Resource<unknown>[];\n}\n\nexport interface Action<Args extends unknown[], Result> {\n /** Execute the action. */\n (...args: Args): Promise<Result>;\n /** Whether the action is currently in-flight. */\n pending: () => boolean;\n /** The last error from the action (or undefined). */\n error: () => unknown;\n /** Clear the error state. */\n clearError: () => void;\n}\n\n/**\n * Create an action that wraps a server function with optimistic UI.\n *\n * ```ts\n * const addTodo = createAction(\n * serverCreateTodo,\n * {\n * optimistic: (text) => {\n * // Immediately add to local list\n * setTodos(prev => [...prev, { text, done: false, id: 'temp' }]);\n * },\n * onSuccess: (result) => {\n * // Replace temp item with server result\n * setTodos(prev => prev.map(t => t.id === 'temp' ? result : t));\n * },\n * onError: (err, text) => {\n * // Remove the optimistic item\n * setTodos(prev => prev.filter(t => t.id !== 'temp'));\n * },\n * invalidates: [todosResource],\n * },\n * );\n *\n * // Use:\n * await addTodo('Buy milk');\n * ```\n */\nexport function createAction<Args extends unknown[], Result>(\n serverFn: (...args: Args) => Promise<Result>,\n options?: ActionOptions<Args, Result>,\n): Action<Args, Result> {\n const [pending, setPending] = createSignal(false);\n const [error, setError] = createSignal<unknown>(undefined);\n\n const action = async (...args: Args): Promise<Result> => {\n setPending(true);\n setError(undefined);\n\n // Apply optimistic update immediately\n if (options?.optimistic) {\n try {\n batch(() => options.optimistic!(...args));\n } catch {\n // Swallow errors in optimistic callback\n }\n }\n\n try {\n const result = await serverFn(...args);\n\n // Apply success handler\n if (options?.onSuccess) {\n batch(() => options.onSuccess!(result, ...args));\n }\n\n // Revalidate dependent resources\n if (options?.invalidates) {\n for (const resource of options.invalidates) {\n resource.refetch();\n }\n }\n\n setPending(false);\n return result;\n } catch (err) {\n // Apply error/rollback handler\n if (options?.onError) {\n try {\n batch(() => options.onError!(err, ...args));\n } catch {\n // Swallow errors in rollback\n }\n }\n\n setError(err);\n setPending(false);\n throw err;\n }\n };\n\n // Attach signal accessors to the action function\n const typedAction = action as Action<Args, Result>;\n typedAction.pending = pending;\n typedAction.error = error;\n typedAction.clearError = () => setError(undefined);\n\n return typedAction;\n}\n","/**\n * FormaJS Server - Mutation\n *\n * Single-flight mutation pattern: the server response carries both the\n * mutation result AND fresh data for dependent resources in one round trip.\n *\n * Without single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { id: 1, text: \"Buy milk\" }\n * Client -> Server: GET /api/todos (refetch to update list)\n * Server -> Client: [all todos]\n *\n * With single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { data: {...}, __revalidate: { \"/api/todos\": [all todos] } }\n * (No second request needed!)\n */\n\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface MutationResponse<T> {\n /** The mutation result. */\n data: T;\n /**\n * Fresh data for dependent resources, keyed by resource identifier.\n * When present, the client updates these resources directly instead of refetching.\n */\n __revalidate?: Record<string, unknown>;\n}\n\n// ---------------------------------------------------------------------------\n// Resource registry for revalidation\n// ---------------------------------------------------------------------------\n\nconst resourceRegistry = new Map<string, Resource<unknown>>();\n\n/**\n * Register a resource with a key so it can be revalidated by single-flight mutations.\n *\n * ```ts\n * const todos = createResource(\n * () => true,\n * () => fetch('/api/todos').then(r => r.json()),\n * );\n * registerResource('/api/todos', todos);\n * ```\n */\nexport function registerResource(key: string, resource: Resource<unknown>): void {\n resourceRegistry.set(key, resource);\n}\n\n/**\n * Unregister a resource (call on cleanup/unmount).\n */\nexport function unregisterResource(key: string): void {\n resourceRegistry.delete(key);\n}\n\n/**\n * Apply revalidation data from a single-flight mutation response.\n * For each key in the revalidate map, find the matching resource\n * and mutate it directly with the fresh data (skipping a refetch).\n */\nexport function applyRevalidation(revalidateData: Record<string, unknown>): void {\n for (const [key, freshData] of Object.entries(revalidateData)) {\n const resource = resourceRegistry.get(key);\n if (resource) {\n resource.mutate(freshData);\n }\n }\n}\n\n/**\n * Listen for revalidation events dispatched by $$serverFunction.\n * Call this once during app initialization to enable automatic\n * single-flight mutation handling.\n *\n * ```ts\n * // In your app entry:\n * import { enableAutoRevalidation } from 'forma/server/mutation';\n * enableAutoRevalidation();\n * ```\n */\nexport function enableAutoRevalidation(): () => void {\n if (typeof window === 'undefined') {\n // SSR/Node.js — no-op\n return () => {};\n }\n\n const handler = (event: Event) => {\n const detail = (event as CustomEvent).detail;\n if (detail && typeof detail === 'object') {\n applyRevalidation(detail as Record<string, unknown>);\n }\n };\n\n window.addEventListener('forma:revalidate', handler);\n\n // Return cleanup function\n return () => window.removeEventListener('forma:revalidate', handler);\n}\n\n/**\n * Wrap a server function response to include revalidation data.\n * Use this on the server side to enable single-flight mutations.\n *\n * ```ts\n * // Server-side:\n * async function createTodo(text: string) {\n * \"use server\";\n * const newTodo = await db.insert('todos', { text, done: false });\n * const allTodos = await db.query('todos');\n * return withRevalidation(newTodo, {\n * '/api/todos': allTodos,\n * });\n * }\n * ```\n */\nexport function withRevalidation<T>(data: T, revalidate: Record<string, unknown>): MutationResponse<T> {\n return { data, __revalidate: revalidate };\n}\n","/**\n * FormaJS Server - RPC Client\n *\n * Provides the client-side stub function that replaces \"use server\" function\n * bodies after compilation. Each call becomes a fetch POST to the server endpoint.\n */\n\n/**\n * Create an RPC stub function for a server function.\n * This replaces the original function body on the client side.\n *\n * @param endpoint - The RPC endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @returns An async function that sends args to the server and returns the result\n */\nexport function $$serverFunction<T extends (...args: unknown[]) => Promise<unknown>>(\n endpoint: string,\n): T {\n const rpcFn = async (...args: unknown[]): Promise<unknown> => {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Forma-RPC': '1',\n },\n body: JSON.stringify({ args }),\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(`Server function failed (${response.status}): ${errorText}`);\n }\n\n const result = await response.json();\n\n // If the response includes revalidation data (single-flight mutations),\n // return it alongside the result\n if (result && typeof result === 'object' && '__revalidate' in result) {\n // Dispatch a custom event so createAction can pick up the revalidation data\n if (typeof window !== 'undefined') {\n window.dispatchEvent(new CustomEvent('forma:revalidate', {\n detail: result.__revalidate,\n }));\n }\n return result.data;\n }\n\n return result;\n };\n\n return rpcFn as T;\n}\n","/**\n * FormaJS Server - RPC Handler\n *\n * Server-side registry and request handler for \"use server\" functions.\n * Framework-agnostic — works with any Node.js HTTP server, Express, Hono, etc.\n */\n\nexport type ServerFunction = (...args: unknown[]) => Promise<unknown>;\n\n/** Registry of server functions by endpoint path. */\nconst registry = new Map<string, ServerFunction>();\n\n/**\n * Register a server function at a specific endpoint.\n * Called by the server-side compiled output.\n */\nexport function registerServerFunction(endpoint: string, fn: ServerFunction): void {\n registry.set(endpoint, fn);\n}\n\n/**\n * Get a registered server function by endpoint.\n */\nexport function getServerFunction(endpoint: string): ServerFunction | undefined {\n return registry.get(endpoint);\n}\n\n/**\n * Get all registered server function endpoints.\n */\nexport function getRegisteredEndpoints(): string[] {\n return [...registry.keys()];\n}\n\nexport interface RPCRequest {\n args: unknown[];\n}\n\nexport interface RPCResponse {\n data?: unknown;\n error?: string;\n __revalidate?: Record<string, unknown>;\n}\n\n/**\n * Handle an incoming RPC request.\n * Call this from your HTTP server's request handler.\n *\n * Usage with any HTTP framework:\n * ```ts\n * import { handleRPC } from 'forma/server/rpc-handler';\n *\n * // Express example:\n * app.post('/rpc/:endpoint', async (req, res) => {\n * const result = await handleRPC(req.path, req.body);\n * res.json(result);\n * });\n *\n * // Hono example:\n * app.post('/rpc/*', async (c) => {\n * const body = await c.req.json();\n * const result = await handleRPC(c.req.path, body);\n * return c.json(result);\n * });\n * ```\n *\n * @param endpoint - The full endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @param body - The parsed request body containing { args: [...] }\n * @param revalidateData - Optional revalidation data to include in the response\n * (for single-flight mutations)\n */\n/** \"Crash Barrier\": block prototype pollution attacks via endpoint names. */\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);\n\nexport async function handleRPC(\n endpoint: string,\n body: RPCRequest,\n revalidateData?: Record<string, unknown>,\n): Promise<RPCResponse> {\n // Security: prevent prototype pollution via crafted endpoint names\n const endpointName = endpoint.split('/').pop() ?? '';\n if (FORBIDDEN_KEYS.has(endpointName)) {\n return { error: 'Forbidden endpoint name' };\n }\n\n const fn = registry.get(endpoint);\n if (!fn) {\n return { error: `Unknown server function: ${endpoint}` };\n }\n\n try {\n const result = await fn(...body.args);\n\n if (revalidateData) {\n return { data: result, __revalidate: revalidateData };\n }\n\n return { data: result };\n } catch (err) {\n // Don't leak internal error details to clients\n const isDev = typeof process !== 'undefined'\n && process.env?.NODE_ENV === 'development';\n const message = isDev && err instanceof Error\n ? err.message\n : 'Internal server error';\n return { error: message };\n }\n}\n\n/**\n * Create a middleware-style handler for use with Express-like frameworks.\n *\n * ```ts\n * import { createRPCMiddleware } from 'forma/server/rpc-handler';\n * app.use('/rpc', createRPCMiddleware());\n * ```\n */\nexport function createRPCMiddleware() {\n return async (req: { url: string; method: string; body?: unknown }, res: { json: (data: unknown) => void; status: (code: number) => { json: (data: unknown) => void } }) => {\n if (req.method !== 'POST') {\n res.status(405).json({ error: 'Method not allowed' });\n return;\n }\n\n const body = req.body as RPCRequest;\n if (!body || !Array.isArray(body.args)) {\n res.status(400).json({ error: 'Invalid RPC request: missing args array' });\n return;\n }\n\n const result = await handleRPC(req.url, body);\n if (result.error) {\n res.status(500).json(result);\n } else {\n res.json(result);\n }\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/server/action.ts","../src/server/mutation.ts","../src/server/rpc-client.ts","../src/server/rpc-handler.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["/**\n * FormaJS Server - Action\n *\n * Creates an action that wraps a server function with optimistic UI support.\n * The action immediately applies an optimistic update, then reconciles when\n * the server responds (or rolls back on error).\n */\n\nimport { createSignal, batch } from '../reactive/index.js';\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ActionOptions<Args extends unknown[], Result> {\n /**\n * Apply an optimistic update immediately when the action is called.\n * This runs BEFORE the server function.\n * Store whatever you need to roll back in the return value or via closures.\n */\n optimistic?: (...args: Args) => void;\n\n /**\n * Called when the server function resolves successfully.\n * Use this to apply the server result to local state.\n */\n onSuccess?: (result: Result, ...args: Args) => void;\n\n /**\n * Called when the server function rejects.\n * Use this to roll back the optimistic update.\n */\n onError?: (error: unknown, ...args: Args) => void;\n\n /**\n * Resources to refetch after the action completes successfully.\n * Used with single-flight mutations: if the server response includes\n * revalidation data, these resources are updated without a refetch.\n */\n invalidates?: Resource<unknown>[];\n}\n\nexport interface Action<Args extends unknown[], Result> {\n /** Execute the action. */\n (...args: Args): Promise<Result>;\n /** Whether the action is currently in-flight. */\n pending: () => boolean;\n /** The last error from the action (or undefined). */\n error: () => unknown;\n /** Clear the error state. */\n clearError: () => void;\n}\n\n/**\n * Create an action that wraps a server function with optimistic UI.\n *\n * ```ts\n * const addTodo = createAction(\n * serverCreateTodo,\n * {\n * optimistic: (text) => {\n * // Immediately add to local list\n * setTodos(prev => [...prev, { text, done: false, id: 'temp' }]);\n * },\n * onSuccess: (result) => {\n * // Replace temp item with server result\n * setTodos(prev => prev.map(t => t.id === 'temp' ? result : t));\n * },\n * onError: (err, text) => {\n * // Remove the optimistic item\n * setTodos(prev => prev.filter(t => t.id !== 'temp'));\n * },\n * invalidates: [todosResource],\n * },\n * );\n *\n * // Use:\n * await addTodo('Buy milk');\n * ```\n */\nexport function createAction<Args extends unknown[], Result>(\n serverFn: (...args: Args) => Promise<Result>,\n options?: ActionOptions<Args, Result>,\n): Action<Args, Result> {\n const [pending, setPending] = createSignal(false);\n const [error, setError] = createSignal<unknown>(undefined);\n\n const action = async (...args: Args): Promise<Result> => {\n setPending(true);\n setError(undefined);\n\n // Apply optimistic update immediately\n if (options?.optimistic) {\n try {\n batch(() => options.optimistic!(...args));\n } catch {\n // Swallow errors in optimistic callback\n }\n }\n\n try {\n const result = await serverFn(...args);\n\n // Apply success handler\n if (options?.onSuccess) {\n batch(() => options.onSuccess!(result, ...args));\n }\n\n // Revalidate dependent resources\n if (options?.invalidates) {\n for (const resource of options.invalidates) {\n resource.refetch();\n }\n }\n\n setPending(false);\n return result;\n } catch (err) {\n // Apply error/rollback handler\n if (options?.onError) {\n try {\n batch(() => options.onError!(err, ...args));\n } catch {\n // Swallow errors in rollback\n }\n }\n\n setError(err);\n setPending(false);\n throw err;\n }\n };\n\n // Attach signal accessors to the action function\n const typedAction = action as Action<Args, Result>;\n typedAction.pending = pending;\n typedAction.error = error;\n typedAction.clearError = () => setError(undefined);\n\n return typedAction;\n}\n","/**\n * FormaJS Server - Mutation\n *\n * Single-flight mutation pattern: the server response carries both the\n * mutation result AND fresh data for dependent resources in one round trip.\n *\n * Without single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { id: 1, text: \"Buy milk\" }\n * Client -> Server: GET /api/todos (refetch to update list)\n * Server -> Client: [all todos]\n *\n * With single-flight:\n * Client -> Server: createTodo(\"Buy milk\")\n * Server -> Client: { data: {...}, __revalidate: { \"/api/todos\": [all todos] } }\n * (No second request needed!)\n */\n\nimport type { Resource } from '../reactive/resource.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface MutationResponse<T> {\n /** The mutation result. */\n data: T;\n /**\n * Fresh data for dependent resources, keyed by resource identifier.\n * When present, the client updates these resources directly instead of refetching.\n */\n __revalidate?: Record<string, unknown>;\n}\n\n// ---------------------------------------------------------------------------\n// Resource registry for revalidation\n// ---------------------------------------------------------------------------\n\nconst resourceRegistry = new Map<string, Resource<unknown>>();\n\n/**\n * Register a resource with a key so it can be revalidated by single-flight mutations.\n *\n * ```ts\n * const todos = createResource(\n * () => true,\n * () => fetch('/api/todos').then(r => r.json()),\n * );\n * registerResource('/api/todos', todos);\n * ```\n */\nexport function registerResource(key: string, resource: Resource<unknown>): void {\n resourceRegistry.set(key, resource);\n}\n\n/**\n * Unregister a resource (call on cleanup/unmount).\n */\nexport function unregisterResource(key: string): void {\n resourceRegistry.delete(key);\n}\n\n/**\n * Apply revalidation data from a single-flight mutation response.\n * For each key in the revalidate map, find the matching resource\n * and mutate it directly with the fresh data (skipping a refetch).\n */\nexport function applyRevalidation(revalidateData: Record<string, unknown>): void {\n for (const [key, freshData] of Object.entries(revalidateData)) {\n const resource = resourceRegistry.get(key);\n if (resource) {\n resource.mutate(freshData);\n }\n }\n}\n\n/**\n * Listen for revalidation events dispatched by $$serverFunction.\n * Call this once during app initialization to enable automatic\n * single-flight mutation handling.\n *\n * ```ts\n * // In your app entry:\n * import { enableAutoRevalidation } from 'forma/server/mutation';\n * enableAutoRevalidation();\n * ```\n */\nexport function enableAutoRevalidation(): () => void {\n if (typeof window === 'undefined') {\n // SSR/Node.js — no-op\n return () => {};\n }\n\n const handler = (event: Event) => {\n const detail = (event as CustomEvent).detail;\n if (detail && typeof detail === 'object') {\n applyRevalidation(detail as Record<string, unknown>);\n }\n };\n\n window.addEventListener('forma:revalidate', handler);\n\n // Return cleanup function\n return () => window.removeEventListener('forma:revalidate', handler);\n}\n\n/**\n * Wrap a server function response to include revalidation data.\n * Use this on the server side to enable single-flight mutations.\n *\n * ```ts\n * // Server-side:\n * async function createTodo(text: string) {\n * \"use server\";\n * const newTodo = await db.insert('todos', { text, done: false });\n * const allTodos = await db.query('todos');\n * return withRevalidation(newTodo, {\n * '/api/todos': allTodos,\n * });\n * }\n * ```\n */\nexport function withRevalidation<T>(data: T, revalidate: Record<string, unknown>): MutationResponse<T> {\n return { data, __revalidate: revalidate };\n}\n","/**\n * FormaJS Server - RPC Client\n *\n * Provides the client-side stub function that replaces \"use server\" function\n * bodies after compilation. Each call becomes a fetch POST to the server endpoint.\n */\n\n/**\n * Create an RPC stub function for a server function.\n * This replaces the original function body on the client side.\n *\n * @param endpoint - The RPC endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @returns An async function that sends args to the server and returns the result\n */\nexport function $$serverFunction<T extends (...args: unknown[]) => Promise<unknown>>(\n endpoint: string,\n): T {\n const rpcFn = async (...args: unknown[]): Promise<unknown> => {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Forma-RPC': '1',\n },\n body: JSON.stringify({ args }),\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n throw new Error(`Server function failed (${response.status}): ${errorText}`);\n }\n\n const result = await response.json();\n\n // If the response includes revalidation data (single-flight mutations),\n // return it alongside the result\n if (result && typeof result === 'object' && '__revalidate' in result) {\n // Dispatch a custom event so createAction can pick up the revalidation data\n if (typeof window !== 'undefined') {\n window.dispatchEvent(new CustomEvent('forma:revalidate', {\n detail: result.__revalidate,\n }));\n }\n return result.data;\n }\n\n return result;\n };\n\n return rpcFn as T;\n}\n","/**\n * FormaJS Server - RPC Handler\n *\n * Server-side registry and request handler for \"use server\" functions.\n * Framework-agnostic — works with any Node.js HTTP server, Express, Hono, etc.\n */\n\nexport type ServerFunction = (...args: unknown[]) => Promise<unknown>;\n\n/** Registry of server functions by endpoint path. */\nconst registry = new Map<string, ServerFunction>();\n\n/**\n * Register a server function at a specific endpoint.\n * Called by the server-side compiled output.\n */\nexport function registerServerFunction(endpoint: string, fn: ServerFunction): void {\n registry.set(endpoint, fn);\n}\n\n/**\n * Get a registered server function by endpoint.\n */\nexport function getServerFunction(endpoint: string): ServerFunction | undefined {\n return registry.get(endpoint);\n}\n\n/**\n * Get all registered server function endpoints.\n */\nexport function getRegisteredEndpoints(): string[] {\n return [...registry.keys()];\n}\n\nexport interface RPCRequest {\n args: unknown[];\n}\n\nexport interface RPCResponse {\n data?: unknown;\n error?: string;\n /** Suggested HTTP status for the middleware to map (error paths only). Not sent in the body. */\n status?: number;\n __revalidate?: Record<string, unknown>;\n}\n\n/** Context passed to an {@link RPCGuard} (the request and its headers, if available). */\nexport interface RPCContext {\n req?: unknown;\n headers?: Record<string, string | undefined>;\n}\n\n/**\n * Authorization guard for RPC calls. Return false (or a rejected/false promise)\n * to deny the call with 403. handleRPC performs NO authentication by itself —\n * install a guard to authenticate/authorize.\n */\nexport type RPCGuard = (endpoint: string, args: unknown[], ctx: RPCContext) => boolean | Promise<boolean>;\n\nlet globalGuard: RPCGuard | undefined;\n\n/** Install (or clear) a process-global RPC authorization guard. */\nexport function setRPCGuard(fn: RPCGuard | undefined): void {\n globalGuard = fn;\n}\n\n/** Options for {@link handleRPC}. */\nexport interface HandleRPCOptions {\n /** Per-call guard; overrides the global guard when provided. */\n authorize?: RPCGuard;\n /** Context forwarded to the guard. */\n context?: RPCContext;\n}\n\n/** Keys that enable prototype-pollution; stripped from RPC args before invocation. */\nconst FORBIDDEN_ARG_KEYS = ['__proto__', 'constructor', 'prototype'];\n\n/** Recursively delete prototype-pollution keys from parsed RPC arguments (mutating). */\nfunction deepStripForbidden<T>(value: T): T {\n if (Array.isArray(value)) {\n for (const v of value) deepStripForbidden(v);\n return value;\n }\n if (value && typeof value === 'object') {\n for (const key of FORBIDDEN_ARG_KEYS) {\n if (Object.prototype.hasOwnProperty.call(value, key)) {\n // Guard the delete: it throws on a frozen/sealed (non-configurable) prop.\n const desc = Object.getOwnPropertyDescriptor(value, key);\n if (desc?.configurable) delete (value as Record<string, unknown>)[key];\n }\n }\n for (const k of Object.keys(value as Record<string, unknown>)) {\n deepStripForbidden((value as Record<string, unknown>)[k]);\n }\n }\n return value;\n}\n\n/**\n * Handle an incoming RPC request.\n * Call this from your HTTP server's request handler.\n *\n * Usage with any HTTP framework:\n * ```ts\n * import { handleRPC } from 'forma/server/rpc-handler';\n *\n * // Express example:\n * app.post('/rpc/:endpoint', async (req, res) => {\n * const result = await handleRPC(req.path, req.body);\n * res.json(result);\n * });\n *\n * // Hono example:\n * app.post('/rpc/*', async (c) => {\n * const body = await c.req.json();\n * const result = await handleRPC(c.req.path, body);\n * return c.json(result);\n * });\n * ```\n *\n * @param endpoint - The full endpoint path (e.g. \"/rpc/createTodo_a1b2c3\")\n * @param body - The parsed request body containing { args: [...] }\n * @param revalidateData - Optional revalidation data to include in the response\n * (for single-flight mutations)\n */\n/** \"Crash Barrier\": block prototype pollution attacks via endpoint names. */\nconst FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);\n\nexport async function handleRPC(\n endpoint: string,\n body: RPCRequest,\n revalidateData?: Record<string, unknown>,\n options?: HandleRPCOptions,\n): Promise<RPCResponse> {\n // Security: prevent prototype pollution via crafted endpoint names\n const endpointName = endpoint.split('/').pop() ?? '';\n if (FORBIDDEN_KEYS.has(endpointName)) {\n return { error: 'Forbidden endpoint name', status: 403 };\n }\n\n // Enforce the args-array contract on the direct path too (not just middleware).\n if (!body || !Array.isArray(body.args)) {\n return { error: 'Invalid RPC request: missing args array', status: 400 };\n }\n\n const fn = registry.get(endpoint);\n if (!fn) {\n return { error: `Unknown server function: ${endpoint}`, status: 404 };\n }\n\n // Strip prototype-pollution keys from client-controlled args before invocation.\n const args = deepStripForbidden([...body.args]);\n\n // Authorization is the deployment's responsibility — run any installed guard.\n // A guard that throws or rejects is treated as a denial (fail-closed): return\n // 403 without leaking the guard's error to the client.\n const guard = options?.authorize ?? globalGuard;\n if (guard) {\n let ok: boolean;\n try {\n ok = !!(await guard(endpoint, args, options?.context ?? {}));\n } catch {\n return { error: 'Forbidden', status: 403 };\n }\n if (!ok) return { error: 'Forbidden', status: 403 };\n }\n\n try {\n const result = await fn(...args);\n\n if (revalidateData) {\n return { data: result, __revalidate: revalidateData };\n }\n\n return { data: result };\n } catch (err) {\n // Don't leak internal error details to clients\n const isDev = typeof process !== 'undefined'\n && process.env?.NODE_ENV === 'development';\n const message = isDev && err instanceof Error\n ? err.message\n : 'Internal server error';\n return { error: message, status: 500 };\n }\n}\n\n/**\n * Create a middleware-style handler for use with Express-like frameworks.\n *\n * ```ts\n * import { createRPCMiddleware } from 'forma/server/rpc-handler';\n * app.use('/rpc', createRPCMiddleware());\n * ```\n */\nexport function createRPCMiddleware(opts?: { guard?: RPCGuard }) {\n return async (\n req: { url: string; method: string; path?: string; body?: unknown; headers?: Record<string, string | undefined> },\n res: { json: (data: unknown) => void; status: (code: number) => { json: (data: unknown) => void } },\n ) => {\n if (req.method !== 'POST') {\n res.status(405).json({ error: 'Method not allowed' });\n return;\n }\n\n // CSRF mitigation: require the custom header (which forces a CORS preflight\n // and cannot be set by a cross-site HTML form) and a JSON content type.\n const h = req.headers ?? {};\n if (h['x-forma-rpc'] !== '1') {\n res.status(403).json({ error: 'Missing X-Forma-RPC header' });\n return;\n }\n const ct = String(h['content-type'] ?? '');\n if (!ct.includes('application/json')) {\n res.status(415).json({ error: 'Unsupported Media Type' });\n return;\n }\n\n const body = req.body as RPCRequest;\n if (!body || !Array.isArray(body.args)) {\n res.status(400).json({ error: 'Invalid RPC request: missing args array' });\n return;\n }\n\n // Resolve the endpoint path without the query string.\n const path = req.path ?? req.url.split('?')[0]!;\n const result = await handleRPC(path, body, undefined, {\n authorize: opts?.guard,\n context: { req, headers: h },\n });\n if (result.error) {\n res.status(result.status ?? 500).json({ error: result.error });\n } else {\n // Do not leak the internal `status` discriminator into the success body.\n const { status: _status, ...rest } = result;\n res.json(rest);\n }\n };\n}\n"]}
|
package/dist/ssr/index.cjs
CHANGED
|
@@ -266,6 +266,23 @@ function shSuspense(fallback, asyncChildren) {
|
|
|
266
266
|
children: [asyncChildren]
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
|
+
var TIMED_OUT = /* @__PURE__ */ Symbol("forma-suspense-timeout");
|
|
270
|
+
function raceTimeout(p, ms) {
|
|
271
|
+
if (ms == null || ms <= 0) return p;
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const t = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
274
|
+
p.then(
|
|
275
|
+
(v) => {
|
|
276
|
+
clearTimeout(t);
|
|
277
|
+
resolve(v);
|
|
278
|
+
},
|
|
279
|
+
(e) => {
|
|
280
|
+
clearTimeout(t);
|
|
281
|
+
reject(e);
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
269
286
|
async function* renderToStreamNew(node, options) {
|
|
270
287
|
const state = {
|
|
271
288
|
suspenseCounter: 0,
|
|
@@ -281,17 +298,18 @@ async function* renderToStreamNew(node, options) {
|
|
|
281
298
|
if (options?.bootstrapScript) {
|
|
282
299
|
yield options.bootstrapScript;
|
|
283
300
|
}
|
|
301
|
+
const suspenseTimeout = options?.suspenseTimeout;
|
|
284
302
|
while (state.pendingBoundaries.length > 0) {
|
|
285
303
|
const pending = [...state.pendingBoundaries];
|
|
286
304
|
state.pendingBoundaries = [];
|
|
287
305
|
const results = await Promise.allSettled(
|
|
288
306
|
pending.map(async (boundary) => {
|
|
289
|
-
const html = await boundary.promise;
|
|
307
|
+
const html = await raceTimeout(boundary.promise, suspenseTimeout);
|
|
290
308
|
return { id: boundary.id, html };
|
|
291
309
|
})
|
|
292
310
|
);
|
|
293
311
|
for (const result of results) {
|
|
294
|
-
if (result.status === "fulfilled") {
|
|
312
|
+
if (result.status === "fulfilled" && result.value.html !== TIMED_OUT) {
|
|
295
313
|
yield getSwapTag(result.value.id, result.value.html);
|
|
296
314
|
}
|
|
297
315
|
}
|
|
@@ -322,9 +340,9 @@ function renderStreamNode(node, parts, state) {
|
|
|
322
340
|
parts.push(`<div id="${id}" style="display:contents">`);
|
|
323
341
|
renderSync(fallback, parts);
|
|
324
342
|
parts.push("</div>");
|
|
325
|
-
const promise =
|
|
343
|
+
const promise = Promise.resolve().then(asyncFn).then((resolved) => {
|
|
326
344
|
const resolvedParts = [];
|
|
327
|
-
|
|
345
|
+
renderStreamNode(resolved, resolvedParts, state);
|
|
328
346
|
return resolvedParts.join("");
|
|
329
347
|
});
|
|
330
348
|
state.pendingBoundaries.push({ id, promise });
|