@cfast/actions 0.1.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Schmidt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as ClientDescriptor, S as Serializable } from './types-Dolh-eut.js';
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, b as ActionDefinition, c as ComposedActions } from './types-Dolh-eut.js';
3
- export { d as ActionContext, e as ActionPermissionsMap, C as ClientDescriptor, R as RequestArgs, S as Serializable } from './types-Dolh-eut.js';
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
- * @param config - The {@link ActionsConfig} providing the `getContext` callback.
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>(operationsFn: OperationsFn<TInput, TResult, TUser>) => ActionDefinition<TInput, TResult, TUser>;
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
- export { ActionDefinition, ActionPermissionStatus, ActionsConfig, ComposedActions, OperationsFn, checkPermissionStatus, createActions };
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
- function createAction(operationsFn) {
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 config.getContext(args);
65
- const input = await parseInput(args.request);
66
- const operation = operationsFn(ctx.db, input, ctx);
67
- return operation.run({});
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
- config.getContext(args)
86
+ resolveContext(args)
74
87
  ]);
75
- const operation = operationsFn(ctx.db, {}, ctx);
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 operationsFn(db, input, ctx);
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
- config.getContext(args)
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 invocation.
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
- /** Resolves the per-request action context from the route handler arguments. */
73
- getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
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, ActionsConfig as a, ActionDefinition as b, ComposedActions as c, ActionContext as d, ActionPermissionsMap as e };
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. Use on the client
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
 
@@ -174,3 +216,8 @@ function PostActions({ postId }) {
174
216
  - Using `createAction` directly without first calling `createActions` to set up the factory. `createAction` is returned by `createActions`, not a standalone export.
175
217
  - Omitting the `_action` hidden input in forms when using `composeActions`. The discriminator field is required to route to the correct handler.
176
218
  - Calling `useActions` with the wrong descriptor. Use `composed.client` for composed actions, or `singleAction.client` for a single action.
219
+
220
+ ## See Also
221
+
222
+ - `@cfast/db` -- Permission descriptors come from Operation results.
223
+ - `@cfast/permissions` -- Defines the grants enforced by action permissions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/actions",
3
- "version": "0.1.0",
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",
@@ -37,20 +37,19 @@
37
37
  "publishConfig": {
38
38
  "access": "public"
39
39
  },
40
- "scripts": {
41
- "build": "tsup src/index.ts src/client.ts --format esm --dts",
42
- "dev": "tsup src/index.ts --format esm --dts --watch",
43
- "typecheck": "tsc --noEmit",
44
- "lint": "eslint src/",
45
- "test": "vitest run"
46
- },
47
40
  "peerDependencies": {
41
+ "@cfast/db": ">=0.3.0 <0.5.0",
42
+ "@cfast/permissions": ">=0.3.0 <0.5.0",
48
43
  "react": "^19.0.0",
49
44
  "react-router": "^7.0.0"
50
45
  },
51
- "dependencies": {
52
- "@cfast/db": "workspace:*",
53
- "@cfast/permissions": "workspace:*"
46
+ "peerDependenciesMeta": {
47
+ "@cfast/db": {
48
+ "optional": false
49
+ },
50
+ "@cfast/permissions": {
51
+ "optional": false
52
+ }
54
53
  },
55
54
  "devDependencies": {
56
55
  "@cloudflare/workers-types": "^4.20260305.1",
@@ -59,6 +58,15 @@
59
58
  "react-router": "^7.6.0",
60
59
  "tsup": "^8",
61
60
  "typescript": "^5.7",
62
- "vitest": "^4.1.0"
61
+ "vitest": "^4.1.0",
62
+ "@cfast/db": "0.4.0",
63
+ "@cfast/permissions": "0.4.0"
64
+ },
65
+ "scripts": {
66
+ "build": "tsup src/index.ts src/client.ts --format esm --dts",
67
+ "dev": "tsup src/index.ts --format esm --dts --watch",
68
+ "typecheck": "tsc --noEmit",
69
+ "lint": "eslint src/",
70
+ "test": "vitest run"
63
71
  }
64
- }
72
+ }