@cfast/actions 0.1.1 → 0.1.3

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 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-CJpjon5s.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-CJpjon5s.js';
3
+ export { e as ActionContext, f as ActionPermissionsMap, C as ClientDescriptor, D as DispatchArgs, R as RequestArgs, S as Serializable } from './types-CJpjon5s.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,9 +102,14 @@ 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);
106
+ };
107
+ const dispatch = async (args) => {
108
+ const { ctx, input } = args;
109
+ const operation = handler(ctx.db, input, ctx);
110
+ return operation.run();
93
111
  };
94
- return { action, loader, client, buildOperation };
112
+ return { action, dispatch, loader, client, buildOperation };
95
113
  }
96
114
  function composeActions(actions) {
97
115
  const actionNames = Object.keys(actions);
@@ -108,7 +126,7 @@ function createActions(config) {
108
126
  return async (args) => {
109
127
  const [loaderData, ctx] = await Promise.all([
110
128
  loaderFn(args),
111
- config.getContext(args)
129
+ resolveContext(args)
112
130
  ]);
113
131
  const permissions = {};
114
132
  for (const [name, actionDef] of Object.entries(actions)) {
@@ -135,7 +153,196 @@ function createActions(config) {
135
153
  }
136
154
  return { createAction, composeActions };
137
155
  }
156
+
157
+ // src/input-schema.ts
158
+ var InvalidInputError = class extends Error {
159
+ /** Field-keyed map of validation errors. */
160
+ errors;
161
+ constructor(errors) {
162
+ const summary = Object.entries(errors).map(([field, msg]) => `${field}: ${msg}`).join("; ");
163
+ super(`Invalid input: ${summary}`);
164
+ this.name = "InvalidInputError";
165
+ this.errors = errors;
166
+ }
167
+ };
168
+ function isMissing(raw) {
169
+ return raw === void 0 || raw === null || raw === "";
170
+ }
171
+ function stringField() {
172
+ return {
173
+ parse(raw) {
174
+ if (isMissing(raw)) {
175
+ return { ok: false, error: "is required" };
176
+ }
177
+ if (typeof raw === "string") {
178
+ return { ok: true, value: raw };
179
+ }
180
+ return { ok: false, error: "must be a string" };
181
+ }
182
+ };
183
+ }
184
+ function numberField() {
185
+ return {
186
+ parse(raw) {
187
+ if (isMissing(raw)) {
188
+ return { ok: false, error: "is required" };
189
+ }
190
+ let parsed;
191
+ if (typeof raw === "number") {
192
+ parsed = raw;
193
+ } else if (typeof raw === "string") {
194
+ const trimmed = raw.trim();
195
+ if (trimmed === "") return { ok: false, error: "is required" };
196
+ parsed = Number(trimmed);
197
+ } else {
198
+ return { ok: false, error: "must be a number" };
199
+ }
200
+ if (!Number.isFinite(parsed)) {
201
+ return { ok: false, error: "must be a finite number" };
202
+ }
203
+ return { ok: true, value: parsed };
204
+ }
205
+ };
206
+ }
207
+ function integerField() {
208
+ const num = numberField();
209
+ return {
210
+ parse(raw) {
211
+ const result = num.parse(raw);
212
+ if (!result.ok) return result;
213
+ if (!Number.isInteger(result.value)) {
214
+ return { ok: false, error: "must be an integer" };
215
+ }
216
+ return result;
217
+ }
218
+ };
219
+ }
220
+ function booleanField() {
221
+ return {
222
+ parse(raw) {
223
+ if (isMissing(raw)) {
224
+ return { ok: false, error: "is required" };
225
+ }
226
+ if (typeof raw === "boolean") return { ok: true, value: raw };
227
+ if (typeof raw === "string") {
228
+ const lowered = raw.toLowerCase();
229
+ if (lowered === "true" || lowered === "on" || lowered === "1") {
230
+ return { ok: true, value: true };
231
+ }
232
+ if (lowered === "false" || lowered === "off" || lowered === "0") {
233
+ return { ok: true, value: false };
234
+ }
235
+ }
236
+ return { ok: false, error: "must be a boolean" };
237
+ }
238
+ };
239
+ }
240
+ function optionalField(inner) {
241
+ return {
242
+ parse(raw) {
243
+ if (isMissing(raw)) return { ok: true, value: void 0 };
244
+ return inner.parse(raw);
245
+ }
246
+ };
247
+ }
248
+ function nullableField(inner) {
249
+ return {
250
+ parse(raw) {
251
+ if (isMissing(raw)) return { ok: true, value: null };
252
+ return inner.parse(raw);
253
+ }
254
+ };
255
+ }
256
+ function customField(parser) {
257
+ return {
258
+ parse(raw) {
259
+ try {
260
+ return { ok: true, value: parser(raw) };
261
+ } catch (e) {
262
+ const message = e instanceof Error ? e.message : "is invalid";
263
+ return { ok: false, error: message };
264
+ }
265
+ }
266
+ };
267
+ }
268
+ var z = {
269
+ string: stringField,
270
+ number: numberField,
271
+ integer: integerField,
272
+ boolean: booleanField,
273
+ optional: optionalField,
274
+ nullable: nullableField,
275
+ custom: customField
276
+ };
277
+ function defineInput(schema) {
278
+ return (raw) => {
279
+ const result = {};
280
+ const errors = {};
281
+ for (const [field, validator] of Object.entries(schema)) {
282
+ const parsed = validator.parse(raw[field]);
283
+ if (parsed.ok) {
284
+ result[field] = parsed.value;
285
+ } else {
286
+ errors[field] = parsed.error;
287
+ }
288
+ }
289
+ if (Object.keys(errors).length > 0) {
290
+ throw new InvalidInputError(errors);
291
+ }
292
+ return result;
293
+ };
294
+ }
295
+
296
+ // src/forward-request.ts
297
+ function forwardRequest(original, init = {}) {
298
+ const headers = new Headers(original.headers);
299
+ if (init.headers) {
300
+ const overrides = new Headers(init.headers);
301
+ overrides.forEach((value, key) => {
302
+ headers.set(key, value);
303
+ });
304
+ }
305
+ const method = init.method ?? original.method;
306
+ const url = init.url ?? original.url;
307
+ const bodylessMethod = method === "GET" || method === "HEAD";
308
+ if (bodylessMethod && init.body !== void 0 && init.body !== null) {
309
+ throw new TypeError(
310
+ `forwardRequest: cannot attach a body to a ${method} request. Either omit "body" or set "method" to a body-bearing verb (e.g. POST).`
311
+ );
312
+ }
313
+ let body;
314
+ if (init.body !== void 0) {
315
+ body = init.body;
316
+ const isStructuredBody = typeof FormData !== "undefined" && body instanceof FormData || typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams;
317
+ const callerSetContentType = init.headers !== void 0 && new Headers(init.headers).has("content-type");
318
+ if (isStructuredBody && !callerSetContentType) {
319
+ headers.delete("content-type");
320
+ headers.delete("content-length");
321
+ }
322
+ } else if (bodylessMethod) {
323
+ body = null;
324
+ } else {
325
+ body = original.body;
326
+ }
327
+ const requestInit = {
328
+ method,
329
+ headers,
330
+ body,
331
+ redirect: original.redirect,
332
+ referrer: original.referrer,
333
+ referrerPolicy: original.referrerPolicy,
334
+ signal: original.signal
335
+ };
336
+ if (body !== null && typeof body.getReader === "function") {
337
+ requestInit.duplex = "half";
338
+ }
339
+ return new Request(url, requestInit);
340
+ }
138
341
  export {
342
+ InvalidInputError,
139
343
  checkPermissionStatus,
140
- createActions
344
+ createActions,
345
+ defineInput,
346
+ forwardRequest,
347
+ z
141
348
  };
@@ -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`.
@@ -49,15 +80,54 @@ type RequestArgs = {
49
80
  /** Optional context object (e.g., Cloudflare Workers env via `context.cloudflare.env`). */
50
81
  context?: unknown;
51
82
  };
83
+ /**
84
+ * Arguments for {@link ActionDefinition.dispatch}, which lets a parent action
85
+ * invoke a sub-action **without** constructing a new `Request`.
86
+ *
87
+ * The parent passes its already-resolved {@link ActionContext} and the typed
88
+ * input directly. This avoids re-running `getContext()` (and thus the
89
+ * cookie-based session lookup) for the sub-action, which is both faster and
90
+ * eliminates the class of bugs described in issue #185 where a manually-built
91
+ * `Request` forgets to forward the `Cookie` header.
92
+ *
93
+ * @typeParam TInput - The expected input shape for the target action.
94
+ * @typeParam TUser - The shape of the authenticated user object.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const parent = createAction((db, input, ctx) => ({
99
+ * permissions: [],
100
+ * async run() {
101
+ * // No Request needed — ctx is reused directly.
102
+ * const result = await child.dispatch({ ctx, input: { title: "Hello" } });
103
+ * return result;
104
+ * },
105
+ * }));
106
+ * ```
107
+ */
108
+ type DispatchArgs<TInput, TUser> = {
109
+ /** The parent action's already-resolved context, reused as-is. */
110
+ ctx: ActionContext<TUser>;
111
+ /** Typed input for the sub-action. */
112
+ input: TInput;
113
+ };
52
114
  /**
53
115
  * Configuration for the {@link createActions} factory.
54
116
  *
55
117
  * Provides a `getContext` callback that resolves the per-request
56
- * {@link ActionContext} (database, user, grants) for every action invocation.
118
+ * {@link ActionContext} (database, user, grants) for every action
119
+ * invocation, plus an optional `services` bag that is injected into
120
+ * every action's context as `ctx.services`.
121
+ *
122
+ * `getContext` is allowed to return an `ActionContext` without the
123
+ * `services` field (the backward-compatible shape) — the factory will
124
+ * fill it in with the registered services (or an empty object) so that
125
+ * every handler can always rely on `ctx.services` being defined.
57
126
  *
58
127
  * @typeParam TUser - The shape of the authenticated user object.
128
+ * @typeParam TServices - The shape of the registered services map.
59
129
  *
60
- * @example
130
+ * @example Basic usage without services
61
131
  * ```ts
62
132
  * const config: ActionsConfig<AppUser> = {
63
133
  * getContext: async ({ request }) => {
@@ -67,10 +137,39 @@ type RequestArgs = {
67
137
  * },
68
138
  * };
69
139
  * ```
140
+ *
141
+ * @example With service injection
142
+ * ```ts
143
+ * const { createAction } = createActions<AppUser, { nutritionApi: NutritionApi }>({
144
+ * getContext: async ({ request }) => { ... },
145
+ * services: { nutritionApi: createNutritionApi(env.NUTRITION_API_KEY) },
146
+ * });
147
+ *
148
+ * const lookupFood = createAction((db, input, ctx) => ({
149
+ * permissions: [],
150
+ * run: async () => ctx.services.nutritionApi.lookup(input.name),
151
+ * }));
152
+ * ```
70
153
  */
71
- type ActionsConfig<TUser> = {
72
- /** Resolves the per-request action context from the route handler arguments. */
73
- getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
154
+ type ActionsConfig<TUser, TServices extends ActionServices = ActionServices> = {
155
+ /**
156
+ * Resolves the per-request action context from the route handler arguments.
157
+ *
158
+ * The returned context does not need to include `services` — the factory
159
+ * merges the registered `services` config in automatically. This keeps
160
+ * the callback ergonomic for the common case (auth + DB only) while
161
+ * still letting advanced users return a fully-formed context.
162
+ */
163
+ getContext: (args: RequestArgs) => Promise<Omit<ActionContext<TUser, TServices>, "services"> & {
164
+ services?: TServices;
165
+ }>;
166
+ /**
167
+ * External services made available to every action handler as
168
+ * `ctx.services`. Registering services here keeps handlers free of
169
+ * module-level imports of third-party APIs and makes it trivial to
170
+ * mock them in tests. Defaults to `{}` when omitted.
171
+ */
172
+ services?: TServices;
74
173
  };
75
174
  /**
76
175
  * A function that builds a database {@link Operation} for an action.
@@ -162,6 +261,30 @@ type ClientDescriptor = {
162
261
  type ActionDefinition<TInput, TResult, TUser> = {
163
262
  /** React Router action handler. Parses input, resolves context, and runs the operation. */
164
263
  action: (args: RequestArgs) => Promise<TResult>;
264
+ /**
265
+ * Dispatches the action using an already-resolved {@link ActionContext},
266
+ * bypassing `getContext()` entirely.
267
+ *
268
+ * Use this when a parent action handler needs to invoke a sub-action.
269
+ * Because the parent's `ctx` is reused directly, cookies, user session,
270
+ * and grants are inherited without constructing a new `Request` — which
271
+ * eliminates the cookie-forwarding bug described in issue #185.
272
+ *
273
+ * @param args - The {@link DispatchArgs} containing the parent's `ctx`
274
+ * and the typed input for this action.
275
+ * @returns The action's result, same as calling `.action()`.
276
+ *
277
+ * @example
278
+ * ```ts
279
+ * const parent = createAction((db, input, ctx) => ({
280
+ * permissions: [],
281
+ * async run() {
282
+ * return child.dispatch({ ctx, input: { title: "Hello" } });
283
+ * },
284
+ * }));
285
+ * ```
286
+ */
287
+ dispatch: (args: DispatchArgs<TInput, TUser>) => Promise<TResult>;
165
288
  /**
166
289
  * Wraps a loader function to inject {@link ActionPermissionsMap} into its return value.
167
290
  *
@@ -217,4 +340,4 @@ type ComposedActions<TActions extends Record<string, ActionDefinition<any, any,
217
340
  actions: TActions;
218
341
  };
219
342
 
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 };
343
+ export type { ActionPermissionStatus as A, ClientDescriptor as C, DispatchArgs as D, 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,71 @@ 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. Dispatch sub-actions with `action.dispatch()` (preferred)
191
+
192
+ `action.dispatch({ ctx, input })` calls an action's operation directly,
193
+ bypassing Request construction entirely. No cookie forwarding, no FormData
194
+ serialisation, no `forwardRequest()` boilerplate -- just pass the context
195
+ and input you already have:
196
+
197
+ ```typescript
198
+ import { createRow } from "~/actions/rows";
199
+
200
+ export const importCsv = createAction(async (db, input, ctx) => ({
201
+ permissions: createRow.buildOperation(db, {} as never, ctx).permissions,
202
+ async run() {
203
+ for (const row of input.rows) {
204
+ await createRow.dispatch({
205
+ ctx: { db, user: ctx.user, grants: ctx.grants, services: {} },
206
+ input: { title: row.title },
207
+ });
208
+ }
209
+ return { ok: true };
210
+ },
211
+ }));
212
+ ```
213
+
214
+ `dispatch()` reuses the caller's `db`, `user`, and `grants` directly, so
215
+ permission checks still run on the sub-action's operation. Use `dispatch()`
216
+ for all server-to-server sub-action calls. `forwardRequest()` still works
217
+ but is only needed when you must go through the full HTTP action handler
218
+ (e.g., calling an action in a different Worker).
219
+
220
+ ### 6. Use on the client
149
221
  ```typescript
150
222
  import { useActions } from "@cfast/actions/client";
151
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/actions",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.6.0",
41
43
  "react": "^19.0.0",
42
44
  "react-router": "^7.0.0"
43
45
  },
44
- "dependencies": {
45
- "@cfast/db": "0.1.1",
46
- "@cfast/permissions": "0.1.0"
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.1",
63
+ "@cfast/permissions": "0.5.1"
56
64
  },
57
65
  "scripts": {
58
66
  "build": "tsup src/index.ts src/client.ts --format esm --dts",