@cfast/actions 0.1.1 → 0.1.2
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/client.d.ts +1 -1
- package/dist/index.d.ts +279 -7
- package/dist/index.js +212 -10
- package/dist/{types-Dolh-eut.d.ts → types-ogCcbQm3.d.ts} +77 -9
- package/llms.txt +43 -1
- package/package.json +13 -5
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as ClientDescriptor, S as Serializable } from './types-
|
|
1
|
+
import { C as ClientDescriptor, S as Serializable } from './types-ogCcbQm3.js';
|
|
2
2
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
3
3
|
import { Form } from 'react-router';
|
|
4
4
|
import { ComponentProps, ReactNode } from 'react';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,156 @@
|
|
|
1
1
|
import { Grant, PermissionDescriptor } from '@cfast/permissions';
|
|
2
|
-
import { A as ActionPermissionStatus, a as ActionsConfig, O as OperationsFn,
|
|
3
|
-
export {
|
|
2
|
+
import { A as ActionPermissionStatus, a as ActionServices, b as ActionsConfig, O as OperationsFn, c as ActionDefinition, d as ComposedActions } from './types-ogCcbQm3.js';
|
|
3
|
+
export { e as ActionContext, f as ActionPermissionsMap, C as ClientDescriptor, R as RequestArgs, S as Serializable } from './types-ogCcbQm3.js';
|
|
4
4
|
import '@cfast/db';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Tiny schema-based input parser for `createAction`.
|
|
8
|
+
*
|
|
9
|
+
* The motivation (#159) is that `Number(formData.get("position"))` silently
|
|
10
|
+
* returns `NaN` for missing or non-numeric inputs and quietly persists garbage.
|
|
11
|
+
* This module exposes a minimal Zod-like field DSL whose `.parse()` method
|
|
12
|
+
* coerces and validates raw form/JSON input into a typed object, throwing an
|
|
13
|
+
* {@link InvalidInputError} (with field-keyed messages) on failure instead of
|
|
14
|
+
* letting the bad value reach the database.
|
|
15
|
+
*
|
|
16
|
+
* The DSL intentionally only covers the field types `@cfast/forms` already
|
|
17
|
+
* derives from a Drizzle schema (string / number / integer / boolean) plus
|
|
18
|
+
* optional/nullable wrappers. Anything more exotic should fall back to a real
|
|
19
|
+
* validator like Zod via the `custom()` escape hatch.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Thrown by {@link InputSchema.parse} when the raw input fails validation.
|
|
23
|
+
*
|
|
24
|
+
* `errors` is keyed by field name and contains the human-readable reason for
|
|
25
|
+
* each failure. Action handlers can re-throw the error directly from a
|
|
26
|
+
* `createAction` body to surface form-level errors to the client.
|
|
27
|
+
*/
|
|
28
|
+
declare class InvalidInputError extends Error {
|
|
29
|
+
/** Field-keyed map of validation errors. */
|
|
30
|
+
readonly errors: Record<string, string>;
|
|
31
|
+
constructor(errors: Record<string, string>);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A single field validator. Implementations receive the raw value (string from
|
|
35
|
+
* a `FormData`, anything from a JSON body, or `undefined` if the field is
|
|
36
|
+
* missing) and return either a parsed value or a validation error message.
|
|
37
|
+
*/
|
|
38
|
+
type InputField<T> = {
|
|
39
|
+
/**
|
|
40
|
+
* Parses a single raw input value. Returns `{ ok: true, value }` on success
|
|
41
|
+
* or `{ ok: false, error }` with a human-readable explanation on failure.
|
|
42
|
+
*/
|
|
43
|
+
parse(raw: unknown): {
|
|
44
|
+
ok: true;
|
|
45
|
+
value: T;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
error: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* String field. Coerces `FormDataEntryValue` (which is `File | string`) to a
|
|
53
|
+
* string. Rejects missing values unless wrapped in {@link optional}.
|
|
54
|
+
*/
|
|
55
|
+
declare function stringField(): InputField<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Number field. The core fix for #159: parses a numeric value and rejects
|
|
58
|
+
* `NaN`, `Infinity`, and unparseable strings outright. Used by every action
|
|
59
|
+
* handler that previously did `Number(formData.get("x"))`.
|
|
60
|
+
*/
|
|
61
|
+
declare function numberField(): InputField<number>;
|
|
62
|
+
/**
|
|
63
|
+
* Integer field. Same semantics as {@link numberField} but additionally
|
|
64
|
+
* rejects fractional values. Maps to Drizzle's `integer()` columns.
|
|
65
|
+
*/
|
|
66
|
+
declare function integerField(): InputField<number>;
|
|
67
|
+
/**
|
|
68
|
+
* Boolean field. Accepts the form-typical strings (`"true"`, `"false"`,
|
|
69
|
+
* `"on"`, `"off"`, `"1"`, `"0"`) plus native booleans from JSON bodies.
|
|
70
|
+
* Rejects everything else so a typo doesn't silently coerce to `false`.
|
|
71
|
+
*/
|
|
72
|
+
declare function booleanField(): InputField<boolean>;
|
|
73
|
+
/**
|
|
74
|
+
* Wraps a field so missing values produce `undefined` instead of an error.
|
|
75
|
+
* Use for genuinely optional inputs.
|
|
76
|
+
*/
|
|
77
|
+
declare function optionalField<T>(inner: InputField<T>): InputField<T | undefined>;
|
|
78
|
+
/**
|
|
79
|
+
* Wraps a field so missing values produce `null` instead of an error.
|
|
80
|
+
* Use for nullable database columns.
|
|
81
|
+
*/
|
|
82
|
+
declare function nullableField<T>(inner: InputField<T>): InputField<T | null>;
|
|
83
|
+
/**
|
|
84
|
+
* Custom field — escape hatch for cases the built-in fields don't cover
|
|
85
|
+
* (enums, regex matches, etc.). The callback receives the raw value and
|
|
86
|
+
* returns either the parsed result or throws to signal failure.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* const slugField = z.custom<string>((raw) => {
|
|
91
|
+
* if (typeof raw !== "string") throw new Error("must be a string");
|
|
92
|
+
* if (!/^[a-z0-9-]+$/.test(raw)) throw new Error("must be slug-safe");
|
|
93
|
+
* return raw;
|
|
94
|
+
* });
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
declare function customField<T>(parser: (raw: unknown) => T): InputField<T>;
|
|
98
|
+
/**
|
|
99
|
+
* Minimal Zod-like field DSL exposed from `@cfast/actions`. Pair with
|
|
100
|
+
* {@link defineInput} to build a typed parser for an action's input.
|
|
101
|
+
*
|
|
102
|
+
* Names match Zod (`z.string`, `z.number`, ...) so callers familiar with
|
|
103
|
+
* Zod can read the schema at a glance, but this is intentionally a
|
|
104
|
+
* tiny standalone implementation so `@cfast/actions` doesn't pull in
|
|
105
|
+
* Zod's bundle weight on the worker.
|
|
106
|
+
*/
|
|
107
|
+
declare const z: {
|
|
108
|
+
string: typeof stringField;
|
|
109
|
+
number: typeof numberField;
|
|
110
|
+
integer: typeof integerField;
|
|
111
|
+
boolean: typeof booleanField;
|
|
112
|
+
optional: typeof optionalField;
|
|
113
|
+
nullable: typeof nullableField;
|
|
114
|
+
custom: typeof customField;
|
|
115
|
+
};
|
|
116
|
+
/** A schema is a record of field names to validators. */
|
|
117
|
+
type InputSchema<T> = {
|
|
118
|
+
[K in keyof T]: InputField<T[K]>;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Result type produced by an {@link InputParser} — the typed object the
|
|
122
|
+
* caller's action handler receives.
|
|
123
|
+
*/
|
|
124
|
+
type InferInput<S> = S extends InputSchema<infer T> ? T : never;
|
|
125
|
+
/**
|
|
126
|
+
* A function that parses a raw input record into a typed object, throwing
|
|
127
|
+
* {@link InvalidInputError} on failure. Returned by {@link defineInput}.
|
|
128
|
+
*/
|
|
129
|
+
type InputParser<T> = (raw: Record<string, unknown>) => T;
|
|
130
|
+
/**
|
|
131
|
+
* Builds a typed parser from a {@link InputSchema}. The returned function
|
|
132
|
+
* walks every field, collects errors, and either returns the typed object
|
|
133
|
+
* or throws {@link InvalidInputError} with all field errors at once.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* import { createAction, defineInput, z } from "@cfast/actions";
|
|
138
|
+
*
|
|
139
|
+
* const moveCard = createAction({
|
|
140
|
+
* input: defineInput({
|
|
141
|
+
* cardId: z.string(),
|
|
142
|
+
* position: z.integer(), // rejects NaN, fractional, missing
|
|
143
|
+
* pinned: z.optional(z.boolean()),
|
|
144
|
+
* }),
|
|
145
|
+
* handler: (db, input, ctx) =>
|
|
146
|
+
* db.update(cards).set({ position: input.position }).where(eq(cards.id, input.cardId)),
|
|
147
|
+
* });
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
declare function defineInput<S extends InputSchema<unknown>>(schema: S): InputParser<{
|
|
151
|
+
[K in keyof S]: S[K] extends InputField<infer V> ? V : never;
|
|
152
|
+
}>;
|
|
153
|
+
|
|
6
154
|
/**
|
|
7
155
|
* Checks a user's {@link Grant | grants} against a set of permission descriptors
|
|
8
156
|
* and returns an {@link ActionPermissionStatus}.
|
|
@@ -34,11 +182,20 @@ declare function checkPermissionStatus(grants: Grant[], descriptors: PermissionD
|
|
|
34
182
|
* same `getContext` callback. This ensures every action in the application resolves
|
|
35
183
|
* its database, user, and grants consistently.
|
|
36
184
|
*
|
|
185
|
+
* Services registered via the optional `services` config are injected into
|
|
186
|
+
* every action's context as `ctx.services`, so handlers can access shared
|
|
187
|
+
* dependencies (HTTP clients, mailers, third-party APIs) without importing
|
|
188
|
+
* module-level singletons. Services are always defined on `ctx.services`
|
|
189
|
+
* — the factory fills in `{}` when no services are configured so handlers
|
|
190
|
+
* can destructure safely.
|
|
191
|
+
*
|
|
37
192
|
* @typeParam TUser - The shape of the authenticated user object.
|
|
38
|
-
* @
|
|
193
|
+
* @typeParam TServices - The shape of the registered services map.
|
|
194
|
+
* @param config - The {@link ActionsConfig} providing the `getContext` callback
|
|
195
|
+
* and optional `services` bag.
|
|
39
196
|
* @returns An object with `createAction` and `composeActions` functions.
|
|
40
197
|
*
|
|
41
|
-
* @example
|
|
198
|
+
* @example Basic usage
|
|
42
199
|
* ```ts
|
|
43
200
|
* import { createActions } from "@cfast/actions";
|
|
44
201
|
*
|
|
@@ -50,10 +207,125 @@ declare function checkPermissionStatus(grants: Grant[], descriptors: PermissionD
|
|
|
50
207
|
* },
|
|
51
208
|
* });
|
|
52
209
|
* ```
|
|
210
|
+
*
|
|
211
|
+
* @example With service injection
|
|
212
|
+
* ```ts
|
|
213
|
+
* type Services = { nutritionApi: NutritionApi };
|
|
214
|
+
*
|
|
215
|
+
* export const { createAction } = createActions<AppUser, Services>({
|
|
216
|
+
* getContext: async ({ request }) => { ... },
|
|
217
|
+
* services: { nutritionApi: createNutritionApi(env.NUTRITION_API_KEY) },
|
|
218
|
+
* });
|
|
219
|
+
*
|
|
220
|
+
* const lookupFood = createAction((db, input, ctx) => ({
|
|
221
|
+
* permissions: [],
|
|
222
|
+
* run: async () => ctx.services.nutritionApi.lookup(input.name),
|
|
223
|
+
* }));
|
|
224
|
+
* ```
|
|
53
225
|
*/
|
|
54
|
-
declare function createActions<TUser = any>(config: ActionsConfig<TUser>): {
|
|
55
|
-
createAction: <TInput, TResult>(
|
|
226
|
+
declare function createActions<TUser = any, TServices extends ActionServices = ActionServices>(config: ActionsConfig<TUser, TServices>): {
|
|
227
|
+
createAction: <TInput, TResult>(config: OperationsFn<TInput, TResult, TUser> | {
|
|
228
|
+
/**
|
|
229
|
+
* Optional typed input parser, e.g. produced by `defineInput({ ... })`.
|
|
230
|
+
*
|
|
231
|
+
* When set, the parser runs over the request's raw input before the
|
|
232
|
+
* handler is invoked. Validation failures throw {@link InvalidInputError}
|
|
233
|
+
* with field-keyed messages -- the call this fix exists for is
|
|
234
|
+
* `Number(formData.get("position"))` silently producing NaN, which
|
|
235
|
+
* `z.number()` / `z.integer()` rejects upfront.
|
|
236
|
+
*/
|
|
237
|
+
input?: InputParser<TInput>;
|
|
238
|
+
/** The operation builder. Same shape as the legacy positional form. */
|
|
239
|
+
handler: OperationsFn<TInput, TResult, TUser>;
|
|
240
|
+
}) => ActionDefinition<TInput, TResult, TUser>;
|
|
56
241
|
composeActions: <TActions extends Record<string, ActionDefinition<any, any, any>>>(actions: TActions) => ComposedActions<TActions>;
|
|
57
242
|
};
|
|
58
243
|
|
|
59
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Options accepted by {@link forwardRequest}.
|
|
246
|
+
*
|
|
247
|
+
* Pass `body` to replace the original body (typical when an action handler
|
|
248
|
+
* builds a sub-action's payload), `method` to override the HTTP method, or
|
|
249
|
+
* `url` to dispatch to a different route. Any field omitted defaults to the
|
|
250
|
+
* value from the source request, so the new request stays a faithful clone
|
|
251
|
+
* of the original — including cookies, `Authorization`, `X-CSRF-Token`,
|
|
252
|
+
* and any other headers your auth or middleware layer relies on.
|
|
253
|
+
*/
|
|
254
|
+
type ForwardRequestInit = {
|
|
255
|
+
/**
|
|
256
|
+
* Replacement body for the forwarded request. Accepts anything `Request`
|
|
257
|
+
* itself accepts (including `FormData`, `URLSearchParams`, `string`, and
|
|
258
|
+
* `ReadableStream`). Pass `null` to forward without a body.
|
|
259
|
+
*
|
|
260
|
+
* Defaults to the source request's body. Note that streaming bodies are
|
|
261
|
+
* single-use, so the source request will be drained when forwarding the
|
|
262
|
+
* default body — clone the source request first if you need to read it
|
|
263
|
+
* elsewhere.
|
|
264
|
+
*/
|
|
265
|
+
body?: BodyInit | null;
|
|
266
|
+
/** HTTP method override. Defaults to the source request's method. */
|
|
267
|
+
method?: string;
|
|
268
|
+
/** URL override. Defaults to the source request's URL. */
|
|
269
|
+
url?: string;
|
|
270
|
+
/**
|
|
271
|
+
* Additional headers to merge on top of the forwarded headers. Use this
|
|
272
|
+
* to set `Content-Type` (e.g. when replacing the body with JSON) without
|
|
273
|
+
* having to assemble a full `Headers` instance manually.
|
|
274
|
+
*/
|
|
275
|
+
headers?: HeadersInit;
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* Builds a new {@link Request} that mirrors `original` (URL, method, headers,
|
|
279
|
+
* cookies) with optional overrides for body, URL, method, or extra headers.
|
|
280
|
+
*
|
|
281
|
+
* Use this when an action handler needs to dispatch a sub-action — for
|
|
282
|
+
* example, an "import CSV" action that builds many "create row" sub-action
|
|
283
|
+
* requests. Sub-actions resolved with the framework's `getContext()` callback
|
|
284
|
+
* see a `null` user unless the request still carries the original cookies,
|
|
285
|
+
* which is exactly what `forwardRequest` preserves.
|
|
286
|
+
*
|
|
287
|
+
* The returned request is a fresh `Request`, not a `clone()` of the source.
|
|
288
|
+
* That means:
|
|
289
|
+
*
|
|
290
|
+
* - The forwarded request has its own (drainable) body. Reading it does not
|
|
291
|
+
* affect the source request.
|
|
292
|
+
* - Headers are deep-copied. Mutating the returned request's headers does
|
|
293
|
+
* not leak back into the source.
|
|
294
|
+
* - When `body` is omitted, the source request's body stream is consumed.
|
|
295
|
+
* If you need to keep the source readable, call `original.clone()` before
|
|
296
|
+
* passing it in, or supply a fresh `body` explicitly.
|
|
297
|
+
*
|
|
298
|
+
* @param original - The incoming request to forward.
|
|
299
|
+
* @param init - Optional overrides for body, method, URL, and headers.
|
|
300
|
+
* @returns A new `Request` ready to hand to a sub-action.
|
|
301
|
+
*
|
|
302
|
+
* @example Basic sub-action dispatch
|
|
303
|
+
* ```ts
|
|
304
|
+
* import { forwardRequest } from "@cfast/actions";
|
|
305
|
+
*
|
|
306
|
+
* const importCsv = createAction(async (db, input, ctx) => ({
|
|
307
|
+
* permissions: createRow.buildOperation(db, {} as never, ctx).permissions,
|
|
308
|
+
* async run() {
|
|
309
|
+
* for (const row of input.rows) {
|
|
310
|
+
* const subFormData = new FormData();
|
|
311
|
+
* subFormData.set("_action", "createRow");
|
|
312
|
+
* subFormData.set("title", row.title);
|
|
313
|
+
* const subRequest = forwardRequest(ctx.request, { body: subFormData });
|
|
314
|
+
* await createRow.action({ request: subRequest, params: {}, context: undefined });
|
|
315
|
+
* }
|
|
316
|
+
* return { ok: true };
|
|
317
|
+
* },
|
|
318
|
+
* }));
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @example JSON body with explicit Content-Type
|
|
322
|
+
* ```ts
|
|
323
|
+
* const subRequest = forwardRequest(originalRequest, {
|
|
324
|
+
* body: JSON.stringify({ title: "Hello" }),
|
|
325
|
+
* headers: { "Content-Type": "application/json" },
|
|
326
|
+
* });
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
declare function forwardRequest(original: Request, init?: ForwardRequestInit): Request;
|
|
330
|
+
|
|
331
|
+
export { ActionDefinition, ActionPermissionStatus, ActionServices, ActionsConfig, ComposedActions, type ForwardRequestInit, type InferInput, type InputField, type InputParser, type InputSchema, InvalidInputError, OperationsFn, checkPermissionStatus, createActions, defineInput, forwardRequest, z };
|
package/dist/index.js
CHANGED
|
@@ -58,21 +58,34 @@ function checkPermissionStatus(grants, descriptors) {
|
|
|
58
58
|
}
|
|
59
59
|
function createActions(config) {
|
|
60
60
|
let counter = 0;
|
|
61
|
-
|
|
61
|
+
const services = config.services ?? {};
|
|
62
|
+
async function resolveContext(args) {
|
|
63
|
+
const ctx = await config.getContext(args);
|
|
64
|
+
return {
|
|
65
|
+
db: ctx.db,
|
|
66
|
+
user: ctx.user,
|
|
67
|
+
grants: ctx.grants,
|
|
68
|
+
services: ctx.services ?? services
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function createAction(config2) {
|
|
62
72
|
const actionId = `action_${++counter}`;
|
|
73
|
+
const handler = typeof config2 === "function" ? config2 : config2.handler;
|
|
74
|
+
const parser = typeof config2 === "function" ? void 0 : config2.input;
|
|
63
75
|
const action = async (args) => {
|
|
64
|
-
const ctx = await
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
76
|
+
const ctx = await resolveContext(args);
|
|
77
|
+
const rawInput = await parseInput(args.request);
|
|
78
|
+
const input = parser ? parser(rawInput) : rawInput;
|
|
79
|
+
const operation = handler(ctx.db, input, ctx);
|
|
80
|
+
return operation.run();
|
|
68
81
|
};
|
|
69
82
|
const loader = (loaderFn) => {
|
|
70
83
|
return async (args) => {
|
|
71
84
|
const [loaderData, ctx] = await Promise.all([
|
|
72
85
|
loaderFn(args),
|
|
73
|
-
|
|
86
|
+
resolveContext(args)
|
|
74
87
|
]);
|
|
75
|
-
const operation =
|
|
88
|
+
const operation = handler(ctx.db, {}, ctx);
|
|
76
89
|
const status = checkPermissionStatus(ctx.grants, operation.permissions);
|
|
77
90
|
const permissions = {
|
|
78
91
|
[actionId]: status
|
|
@@ -89,7 +102,7 @@ function createActions(config) {
|
|
|
89
102
|
permissionsKey: "_actionPermissions"
|
|
90
103
|
};
|
|
91
104
|
const buildOperation = (db, input, ctx) => {
|
|
92
|
-
return
|
|
105
|
+
return handler(db, input, ctx);
|
|
93
106
|
};
|
|
94
107
|
return { action, loader, client, buildOperation };
|
|
95
108
|
}
|
|
@@ -108,7 +121,7 @@ function createActions(config) {
|
|
|
108
121
|
return async (args) => {
|
|
109
122
|
const [loaderData, ctx] = await Promise.all([
|
|
110
123
|
loaderFn(args),
|
|
111
|
-
|
|
124
|
+
resolveContext(args)
|
|
112
125
|
]);
|
|
113
126
|
const permissions = {};
|
|
114
127
|
for (const [name, actionDef] of Object.entries(actions)) {
|
|
@@ -135,7 +148,196 @@ function createActions(config) {
|
|
|
135
148
|
}
|
|
136
149
|
return { createAction, composeActions };
|
|
137
150
|
}
|
|
151
|
+
|
|
152
|
+
// src/input-schema.ts
|
|
153
|
+
var InvalidInputError = class extends Error {
|
|
154
|
+
/** Field-keyed map of validation errors. */
|
|
155
|
+
errors;
|
|
156
|
+
constructor(errors) {
|
|
157
|
+
const summary = Object.entries(errors).map(([field, msg]) => `${field}: ${msg}`).join("; ");
|
|
158
|
+
super(`Invalid input: ${summary}`);
|
|
159
|
+
this.name = "InvalidInputError";
|
|
160
|
+
this.errors = errors;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
function isMissing(raw) {
|
|
164
|
+
return raw === void 0 || raw === null || raw === "";
|
|
165
|
+
}
|
|
166
|
+
function stringField() {
|
|
167
|
+
return {
|
|
168
|
+
parse(raw) {
|
|
169
|
+
if (isMissing(raw)) {
|
|
170
|
+
return { ok: false, error: "is required" };
|
|
171
|
+
}
|
|
172
|
+
if (typeof raw === "string") {
|
|
173
|
+
return { ok: true, value: raw };
|
|
174
|
+
}
|
|
175
|
+
return { ok: false, error: "must be a string" };
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function numberField() {
|
|
180
|
+
return {
|
|
181
|
+
parse(raw) {
|
|
182
|
+
if (isMissing(raw)) {
|
|
183
|
+
return { ok: false, error: "is required" };
|
|
184
|
+
}
|
|
185
|
+
let parsed;
|
|
186
|
+
if (typeof raw === "number") {
|
|
187
|
+
parsed = raw;
|
|
188
|
+
} else if (typeof raw === "string") {
|
|
189
|
+
const trimmed = raw.trim();
|
|
190
|
+
if (trimmed === "") return { ok: false, error: "is required" };
|
|
191
|
+
parsed = Number(trimmed);
|
|
192
|
+
} else {
|
|
193
|
+
return { ok: false, error: "must be a number" };
|
|
194
|
+
}
|
|
195
|
+
if (!Number.isFinite(parsed)) {
|
|
196
|
+
return { ok: false, error: "must be a finite number" };
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, value: parsed };
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function integerField() {
|
|
203
|
+
const num = numberField();
|
|
204
|
+
return {
|
|
205
|
+
parse(raw) {
|
|
206
|
+
const result = num.parse(raw);
|
|
207
|
+
if (!result.ok) return result;
|
|
208
|
+
if (!Number.isInteger(result.value)) {
|
|
209
|
+
return { ok: false, error: "must be an integer" };
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function booleanField() {
|
|
216
|
+
return {
|
|
217
|
+
parse(raw) {
|
|
218
|
+
if (isMissing(raw)) {
|
|
219
|
+
return { ok: false, error: "is required" };
|
|
220
|
+
}
|
|
221
|
+
if (typeof raw === "boolean") return { ok: true, value: raw };
|
|
222
|
+
if (typeof raw === "string") {
|
|
223
|
+
const lowered = raw.toLowerCase();
|
|
224
|
+
if (lowered === "true" || lowered === "on" || lowered === "1") {
|
|
225
|
+
return { ok: true, value: true };
|
|
226
|
+
}
|
|
227
|
+
if (lowered === "false" || lowered === "off" || lowered === "0") {
|
|
228
|
+
return { ok: true, value: false };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { ok: false, error: "must be a boolean" };
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function optionalField(inner) {
|
|
236
|
+
return {
|
|
237
|
+
parse(raw) {
|
|
238
|
+
if (isMissing(raw)) return { ok: true, value: void 0 };
|
|
239
|
+
return inner.parse(raw);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function nullableField(inner) {
|
|
244
|
+
return {
|
|
245
|
+
parse(raw) {
|
|
246
|
+
if (isMissing(raw)) return { ok: true, value: null };
|
|
247
|
+
return inner.parse(raw);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function customField(parser) {
|
|
252
|
+
return {
|
|
253
|
+
parse(raw) {
|
|
254
|
+
try {
|
|
255
|
+
return { ok: true, value: parser(raw) };
|
|
256
|
+
} catch (e) {
|
|
257
|
+
const message = e instanceof Error ? e.message : "is invalid";
|
|
258
|
+
return { ok: false, error: message };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
var z = {
|
|
264
|
+
string: stringField,
|
|
265
|
+
number: numberField,
|
|
266
|
+
integer: integerField,
|
|
267
|
+
boolean: booleanField,
|
|
268
|
+
optional: optionalField,
|
|
269
|
+
nullable: nullableField,
|
|
270
|
+
custom: customField
|
|
271
|
+
};
|
|
272
|
+
function defineInput(schema) {
|
|
273
|
+
return (raw) => {
|
|
274
|
+
const result = {};
|
|
275
|
+
const errors = {};
|
|
276
|
+
for (const [field, validator] of Object.entries(schema)) {
|
|
277
|
+
const parsed = validator.parse(raw[field]);
|
|
278
|
+
if (parsed.ok) {
|
|
279
|
+
result[field] = parsed.value;
|
|
280
|
+
} else {
|
|
281
|
+
errors[field] = parsed.error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (Object.keys(errors).length > 0) {
|
|
285
|
+
throw new InvalidInputError(errors);
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/forward-request.ts
|
|
292
|
+
function forwardRequest(original, init = {}) {
|
|
293
|
+
const headers = new Headers(original.headers);
|
|
294
|
+
if (init.headers) {
|
|
295
|
+
const overrides = new Headers(init.headers);
|
|
296
|
+
overrides.forEach((value, key) => {
|
|
297
|
+
headers.set(key, value);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const method = init.method ?? original.method;
|
|
301
|
+
const url = init.url ?? original.url;
|
|
302
|
+
const bodylessMethod = method === "GET" || method === "HEAD";
|
|
303
|
+
if (bodylessMethod && init.body !== void 0 && init.body !== null) {
|
|
304
|
+
throw new TypeError(
|
|
305
|
+
`forwardRequest: cannot attach a body to a ${method} request. Either omit "body" or set "method" to a body-bearing verb (e.g. POST).`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
let body;
|
|
309
|
+
if (init.body !== void 0) {
|
|
310
|
+
body = init.body;
|
|
311
|
+
const isStructuredBody = typeof FormData !== "undefined" && body instanceof FormData || typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams;
|
|
312
|
+
const callerSetContentType = init.headers !== void 0 && new Headers(init.headers).has("content-type");
|
|
313
|
+
if (isStructuredBody && !callerSetContentType) {
|
|
314
|
+
headers.delete("content-type");
|
|
315
|
+
headers.delete("content-length");
|
|
316
|
+
}
|
|
317
|
+
} else if (bodylessMethod) {
|
|
318
|
+
body = null;
|
|
319
|
+
} else {
|
|
320
|
+
body = original.body;
|
|
321
|
+
}
|
|
322
|
+
const requestInit = {
|
|
323
|
+
method,
|
|
324
|
+
headers,
|
|
325
|
+
body,
|
|
326
|
+
redirect: original.redirect,
|
|
327
|
+
referrer: original.referrer,
|
|
328
|
+
referrerPolicy: original.referrerPolicy,
|
|
329
|
+
signal: original.signal
|
|
330
|
+
};
|
|
331
|
+
if (body !== null && typeof body.getReader === "function") {
|
|
332
|
+
requestInit.duplex = "half";
|
|
333
|
+
}
|
|
334
|
+
return new Request(url, requestInit);
|
|
335
|
+
}
|
|
138
336
|
export {
|
|
337
|
+
InvalidInputError,
|
|
139
338
|
checkPermissionStatus,
|
|
140
|
-
createActions
|
|
339
|
+
createActions,
|
|
340
|
+
defineInput,
|
|
341
|
+
forwardRequest,
|
|
342
|
+
z
|
|
141
343
|
};
|
|
@@ -10,30 +10,61 @@ import { Grant } from '@cfast/permissions';
|
|
|
10
10
|
type Serializable = string | number | boolean | null | Serializable[] | {
|
|
11
11
|
[key: string]: Serializable;
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* A record of arbitrary external services an action handler may depend on.
|
|
15
|
+
*
|
|
16
|
+
* Services are registered once at {@link createActions} time and made
|
|
17
|
+
* available on every action's {@link ActionContext} via `ctx.services`.
|
|
18
|
+
* Use this to inject HTTP clients, email providers, cache adapters,
|
|
19
|
+
* third-party APIs, etc., without each handler having to import them
|
|
20
|
+
* directly — which both improves testability (services can be mocked
|
|
21
|
+
* per-suite) and keeps action modules decoupled from concrete providers.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* type Services = {
|
|
26
|
+
* nutritionApi: { lookup: (name: string) => Promise<Nutrition> };
|
|
27
|
+
* mailer: { sendWelcome: (to: string) => Promise<void> };
|
|
28
|
+
* };
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
type ActionServices = Record<string, any>;
|
|
13
32
|
/**
|
|
14
33
|
* Context provided to every action's {@link OperationsFn}.
|
|
15
34
|
*
|
|
16
35
|
* Created by the `getContext` callback in {@link ActionsConfig} and passed
|
|
17
|
-
* alongside the database instance and parsed input to each action.
|
|
36
|
+
* alongside the database instance and parsed input to each action. When
|
|
37
|
+
* `services` are registered with {@link createActions}, they appear on
|
|
38
|
+
* `ctx.services` so handlers can access them without importing module-
|
|
39
|
+
* level singletons.
|
|
18
40
|
*
|
|
19
41
|
* @typeParam TUser - The shape of the authenticated user object.
|
|
42
|
+
* @typeParam TServices - The shape of the registered services map. Defaults
|
|
43
|
+
* to an empty record when no services are registered.
|
|
20
44
|
*
|
|
21
45
|
* @example
|
|
22
46
|
* ```ts
|
|
23
|
-
* const ctx: ActionContext<{ id: string; role: string }> = {
|
|
47
|
+
* const ctx: ActionContext<{ id: string; role: string }, { nutritionApi: NutritionApi }> = {
|
|
24
48
|
* db,
|
|
25
49
|
* user: { id: "u_1", role: "author" },
|
|
26
50
|
* grants: [{ action: "manage", subject: "all" }],
|
|
51
|
+
* services: { nutritionApi },
|
|
27
52
|
* };
|
|
28
53
|
* ```
|
|
29
54
|
*/
|
|
30
|
-
type ActionContext<TUser> = {
|
|
55
|
+
type ActionContext<TUser, TServices extends ActionServices = ActionServices> = {
|
|
31
56
|
/** The Drizzle database instance from `@cfast/db`. */
|
|
32
57
|
db: Db;
|
|
33
58
|
/** The authenticated user for the current request. */
|
|
34
59
|
user: TUser;
|
|
35
60
|
/** The user's permission {@link Grant | grants}, used for permission checking. */
|
|
36
61
|
grants: Grant[];
|
|
62
|
+
/**
|
|
63
|
+
* External services registered with {@link createActions}. Defaults to
|
|
64
|
+
* an empty object `{}` when no services are provided at factory time,
|
|
65
|
+
* so handlers can always safely destructure `ctx.services`.
|
|
66
|
+
*/
|
|
67
|
+
services: TServices;
|
|
37
68
|
};
|
|
38
69
|
/**
|
|
39
70
|
* Subset of React Router loader/action arguments consumed by `@cfast/actions`.
|
|
@@ -53,11 +84,19 @@ type RequestArgs = {
|
|
|
53
84
|
* Configuration for the {@link createActions} factory.
|
|
54
85
|
*
|
|
55
86
|
* Provides a `getContext` callback that resolves the per-request
|
|
56
|
-
* {@link ActionContext} (database, user, grants) for every action
|
|
87
|
+
* {@link ActionContext} (database, user, grants) for every action
|
|
88
|
+
* invocation, plus an optional `services` bag that is injected into
|
|
89
|
+
* every action's context as `ctx.services`.
|
|
90
|
+
*
|
|
91
|
+
* `getContext` is allowed to return an `ActionContext` without the
|
|
92
|
+
* `services` field (the backward-compatible shape) — the factory will
|
|
93
|
+
* fill it in with the registered services (or an empty object) so that
|
|
94
|
+
* every handler can always rely on `ctx.services` being defined.
|
|
57
95
|
*
|
|
58
96
|
* @typeParam TUser - The shape of the authenticated user object.
|
|
97
|
+
* @typeParam TServices - The shape of the registered services map.
|
|
59
98
|
*
|
|
60
|
-
* @example
|
|
99
|
+
* @example Basic usage without services
|
|
61
100
|
* ```ts
|
|
62
101
|
* const config: ActionsConfig<AppUser> = {
|
|
63
102
|
* getContext: async ({ request }) => {
|
|
@@ -67,10 +106,39 @@ type RequestArgs = {
|
|
|
67
106
|
* },
|
|
68
107
|
* };
|
|
69
108
|
* ```
|
|
109
|
+
*
|
|
110
|
+
* @example With service injection
|
|
111
|
+
* ```ts
|
|
112
|
+
* const { createAction } = createActions<AppUser, { nutritionApi: NutritionApi }>({
|
|
113
|
+
* getContext: async ({ request }) => { ... },
|
|
114
|
+
* services: { nutritionApi: createNutritionApi(env.NUTRITION_API_KEY) },
|
|
115
|
+
* });
|
|
116
|
+
*
|
|
117
|
+
* const lookupFood = createAction((db, input, ctx) => ({
|
|
118
|
+
* permissions: [],
|
|
119
|
+
* run: async () => ctx.services.nutritionApi.lookup(input.name),
|
|
120
|
+
* }));
|
|
121
|
+
* ```
|
|
70
122
|
*/
|
|
71
|
-
type ActionsConfig<TUser> = {
|
|
72
|
-
/**
|
|
73
|
-
|
|
123
|
+
type ActionsConfig<TUser, TServices extends ActionServices = ActionServices> = {
|
|
124
|
+
/**
|
|
125
|
+
* Resolves the per-request action context from the route handler arguments.
|
|
126
|
+
*
|
|
127
|
+
* The returned context does not need to include `services` — the factory
|
|
128
|
+
* merges the registered `services` config in automatically. This keeps
|
|
129
|
+
* the callback ergonomic for the common case (auth + DB only) while
|
|
130
|
+
* still letting advanced users return a fully-formed context.
|
|
131
|
+
*/
|
|
132
|
+
getContext: (args: RequestArgs) => Promise<Omit<ActionContext<TUser, TServices>, "services"> & {
|
|
133
|
+
services?: TServices;
|
|
134
|
+
}>;
|
|
135
|
+
/**
|
|
136
|
+
* External services made available to every action handler as
|
|
137
|
+
* `ctx.services`. Registering services here keeps handlers free of
|
|
138
|
+
* module-level imports of third-party APIs and makes it trivial to
|
|
139
|
+
* mock them in tests. Defaults to `{}` when omitted.
|
|
140
|
+
*/
|
|
141
|
+
services?: TServices;
|
|
74
142
|
};
|
|
75
143
|
/**
|
|
76
144
|
* A function that builds a database {@link Operation} for an action.
|
|
@@ -217,4 +285,4 @@ type ComposedActions<TActions extends Record<string, ActionDefinition<any, any,
|
|
|
217
285
|
actions: TActions;
|
|
218
286
|
};
|
|
219
287
|
|
|
220
|
-
export type { ActionPermissionStatus as A, ClientDescriptor as C, OperationsFn as O, RequestArgs as R, Serializable as S,
|
|
288
|
+
export type { ActionPermissionStatus as A, ClientDescriptor as C, OperationsFn as O, RequestArgs as R, Serializable as S, ActionServices as a, ActionsConfig as b, ActionDefinition as c, ComposedActions as d, ActionContext as e, ActionPermissionsMap as f };
|
package/llms.txt
CHANGED
|
@@ -29,6 +29,14 @@ function createActions<TUser>(config: ActionsConfig<TUser>): {
|
|
|
29
29
|
|
|
30
30
|
function checkPermissionStatus(grants: Grant[], descriptors: PermissionDescriptor[]): ActionPermissionStatus;
|
|
31
31
|
|
|
32
|
+
function forwardRequest(original: Request, init?: ForwardRequestInit): Request;
|
|
33
|
+
type ForwardRequestInit = {
|
|
34
|
+
body?: BodyInit | null; // override body (FormData / JSON / null)
|
|
35
|
+
method?: string; // override HTTP method
|
|
36
|
+
url?: string; // override URL
|
|
37
|
+
headers?: HeadersInit; // merged on top of original headers
|
|
38
|
+
};
|
|
39
|
+
|
|
32
40
|
type ActionsConfig<TUser> = {
|
|
33
41
|
getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
|
|
34
42
|
};
|
|
@@ -145,7 +153,41 @@ export const loader = composed.loader(async ({ params }) => {
|
|
|
145
153
|
});
|
|
146
154
|
```
|
|
147
155
|
|
|
148
|
-
### 4.
|
|
156
|
+
### 4. Dispatch sub-actions with `forwardRequest`
|
|
157
|
+
|
|
158
|
+
When an action handler builds a sub-request to call another action, the
|
|
159
|
+
sub-action's `getContext()` will see a `null` user unless the sub-request
|
|
160
|
+
carries the original cookies. `forwardRequest()` clones the original request
|
|
161
|
+
with new body / method / URL while preserving cookies, auth headers, and
|
|
162
|
+
other middleware state:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { forwardRequest } from "@cfast/actions";
|
|
166
|
+
import { createRow } from "~/actions/rows";
|
|
167
|
+
|
|
168
|
+
export const importCsv = createAction(async (db, input, ctx) => ({
|
|
169
|
+
permissions: createRow.buildOperation(db, {} as never, ctx).permissions,
|
|
170
|
+
async run() {
|
|
171
|
+
for (const row of input.rows) {
|
|
172
|
+
const subFormData = new FormData();
|
|
173
|
+
subFormData.set("_action", "createRow");
|
|
174
|
+
subFormData.set("title", row.title);
|
|
175
|
+
|
|
176
|
+
// Cookies, Authorization, X-CSRF-Token, etc. are all preserved.
|
|
177
|
+
const subRequest = forwardRequest(ctx.request, { body: subFormData });
|
|
178
|
+
await createRow.action({ request: subRequest, params: {}, context: undefined });
|
|
179
|
+
}
|
|
180
|
+
return { ok: true };
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`forwardRequest` strips the original `Content-Type` automatically when the
|
|
186
|
+
new body is `FormData` or `URLSearchParams`, so the platform can attach the
|
|
187
|
+
correct multipart boundary. Pass an explicit `headers: { "content-type": ... }`
|
|
188
|
+
to override.
|
|
189
|
+
|
|
190
|
+
### 5. Use on the client
|
|
149
191
|
```typescript
|
|
150
192
|
import { useActions } from "@cfast/actions/client";
|
|
151
193
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfast/actions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Multi-action routes and permission-aware action definitions for React Router",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cfast",
|
|
@@ -38,12 +38,18 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
+
"@cfast/db": ">=0.3.0 <0.5.0",
|
|
42
|
+
"@cfast/permissions": ">=0.3.0 <0.5.0",
|
|
41
43
|
"react": "^19.0.0",
|
|
42
44
|
"react-router": "^7.0.0"
|
|
43
45
|
},
|
|
44
|
-
"
|
|
45
|
-
"@cfast/db":
|
|
46
|
-
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@cfast/db": {
|
|
48
|
+
"optional": false
|
|
49
|
+
},
|
|
50
|
+
"@cfast/permissions": {
|
|
51
|
+
"optional": false
|
|
52
|
+
}
|
|
47
53
|
},
|
|
48
54
|
"devDependencies": {
|
|
49
55
|
"@cloudflare/workers-types": "^4.20260305.1",
|
|
@@ -52,7 +58,9 @@
|
|
|
52
58
|
"react-router": "^7.6.0",
|
|
53
59
|
"tsup": "^8",
|
|
54
60
|
"typescript": "^5.7",
|
|
55
|
-
"vitest": "^4.1.0"
|
|
61
|
+
"vitest": "^4.1.0",
|
|
62
|
+
"@cfast/db": "0.4.0",
|
|
63
|
+
"@cfast/permissions": "0.4.0"
|
|
56
64
|
},
|
|
57
65
|
"scripts": {
|
|
58
66
|
"build": "tsup src/index.ts src/client.ts --format esm --dts",
|