@cfast/actions 0.0.1 → 0.1.1
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 +29 -1
- package/dist/client.js +13 -0
- package/llms.txt +181 -0
- package/package.json +13 -4
- package/dist/types-CNqDBI0X.d.ts +0 -49
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { C as ClientDescriptor, S as Serializable } from './types-Dolh-eut.js';
|
|
2
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
3
|
+
import { Form } from 'react-router';
|
|
4
|
+
import { ComponentProps, ReactNode } from 'react';
|
|
2
5
|
import '@cfast/db';
|
|
3
6
|
import '@cfast/permissions';
|
|
4
7
|
|
|
@@ -19,4 +22,29 @@ type ActionHookResult = {
|
|
|
19
22
|
};
|
|
20
23
|
declare function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
|
|
26
|
+
/** Object with `_action` key and input fields to inject as hidden inputs. */
|
|
27
|
+
action: Record<string, string | number | boolean | null | undefined>;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* A Form wrapper that auto-injects hidden fields from an action descriptor.
|
|
32
|
+
*
|
|
33
|
+
* Replaces the manual pattern of:
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <Form method="post">
|
|
36
|
+
* <input type="hidden" name="_action" value="addComment" />
|
|
37
|
+
* <input type="hidden" name="postId" value={post.id} />
|
|
38
|
+
* </Form>
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* With:
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <ActionForm action={{ _action: "addComment", postId: post.id }} method="post">
|
|
44
|
+
* ...
|
|
45
|
+
* </ActionForm>
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
declare function ActionForm({ action, children, ...formProps }: ActionFormProps): react_jsx_runtime.JSX.Element;
|
|
49
|
+
|
|
50
|
+
export { ActionForm, type ActionHookResult, ClientDescriptor, clientDescriptor, useActions };
|
package/dist/client.js
CHANGED
|
@@ -66,7 +66,20 @@ function useActions(descriptor) {
|
|
|
66
66
|
}
|
|
67
67
|
return result;
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
// src/client/action-form.tsx
|
|
71
|
+
import { Form } from "react-router";
|
|
72
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
73
|
+
function ActionForm({ action, children, ...formProps }) {
|
|
74
|
+
return /* @__PURE__ */ jsxs(Form, { ...formProps, children: [
|
|
75
|
+
Object.entries(action).map(
|
|
76
|
+
([key, value]) => value != null ? /* @__PURE__ */ jsx("input", { type: "hidden", name: key, value: String(value) }, key) : null
|
|
77
|
+
),
|
|
78
|
+
children
|
|
79
|
+
] });
|
|
80
|
+
}
|
|
69
81
|
export {
|
|
82
|
+
ActionForm,
|
|
70
83
|
clientDescriptor,
|
|
71
84
|
useActions
|
|
72
85
|
};
|
package/llms.txt
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# @cfast/actions
|
|
2
|
+
|
|
3
|
+
> Reusable, type-safe, permission-aware actions for React Router. Define operations once -- permissions and execution come from the same place.
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
Use this package when you need React Router route actions that automatically check user permissions before execution and expose permission status to the client for UI gating (disable/hide buttons).
|
|
7
|
+
|
|
8
|
+
## Key concepts
|
|
9
|
+
- **Action factory**: `createActions()` binds a `getContext` callback that resolves `{ db, user, grants }` per request. All actions share the same context provider.
|
|
10
|
+
- **Operations**: Each action is defined as an `OperationsFn` that receives `(db, input, ctx)` and returns an `Operation<TResult>` from `@cfast/db`. The operation carries both the mutation logic and permission descriptors.
|
|
11
|
+
- **Four facets**: Every action definition exposes `.action` (route handler), `.loader()` (permission-injecting loader wrapper), `.client` (descriptor for the hook), and `.buildOperation()` (for cross-action composition).
|
|
12
|
+
- **Composition**: `composeActions()` merges multiple actions into one route handler, dispatching by the `_action` discriminator field.
|
|
13
|
+
- **Permission flow**: Loader wraps extract permission descriptors from operations, check them against user grants, and inject `_actionPermissions` into loader data. The `useActions` hook reads this on the client.
|
|
14
|
+
|
|
15
|
+
## API Reference
|
|
16
|
+
|
|
17
|
+
### Server (`@cfast/actions`)
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
function createActions<TUser>(config: ActionsConfig<TUser>): {
|
|
21
|
+
createAction: <TInput, TResult>(
|
|
22
|
+
operationsFn: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>
|
|
23
|
+
) => ActionDefinition<TInput, TResult, TUser>;
|
|
24
|
+
|
|
25
|
+
composeActions: <TActions extends Record<string, ActionDefinition<any, any, any>>>(
|
|
26
|
+
actions: TActions
|
|
27
|
+
) => ComposedActions<TActions>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function checkPermissionStatus(grants: Grant[], descriptors: PermissionDescriptor[]): ActionPermissionStatus;
|
|
31
|
+
|
|
32
|
+
type ActionsConfig<TUser> = {
|
|
33
|
+
getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ActionContext<TUser> = { db: Db; user: TUser; grants: Grant[] };
|
|
37
|
+
type RequestArgs = { request: Request; params: Record<string, string | undefined>; context?: unknown };
|
|
38
|
+
|
|
39
|
+
type ActionDefinition<TInput, TResult, TUser> = {
|
|
40
|
+
action: (args: RequestArgs) => Promise<TResult>;
|
|
41
|
+
loader: <T extends Record<string, Serializable>>(
|
|
42
|
+
loaderFn: (args: RequestArgs) => Promise<T>
|
|
43
|
+
) => (args: RequestArgs) => Promise<T & { _actionPermissions: ActionPermissionsMap }>;
|
|
44
|
+
client: ClientDescriptor;
|
|
45
|
+
buildOperation: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type ComposedActions<TActions> = {
|
|
49
|
+
action: (args: RequestArgs) => Promise<unknown>;
|
|
50
|
+
loader: <T extends Record<string, Serializable>>(
|
|
51
|
+
loaderFn: (args: RequestArgs) => Promise<T>
|
|
52
|
+
) => (args: RequestArgs) => Promise<T & { _actionPermissions: ActionPermissionsMap }>;
|
|
53
|
+
client: ClientDescriptor;
|
|
54
|
+
actions: TActions;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type ActionPermissionStatus = { permitted: boolean; invisible: boolean; reason: string | null };
|
|
58
|
+
type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Client (`@cfast/actions/client`)
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
|
|
65
|
+
function clientDescriptor(actionNames: readonly string[]): ClientDescriptor;
|
|
66
|
+
|
|
67
|
+
function ActionForm(props: ActionFormProps): JSX.Element;
|
|
68
|
+
|
|
69
|
+
type ActionHookResult = {
|
|
70
|
+
permitted: boolean; // user has required permissions
|
|
71
|
+
invisible: boolean; // user lacks ALL permissions (hide the UI)
|
|
72
|
+
reason: string | null; // human-readable denial reason
|
|
73
|
+
submit: () => void; // submits via fetcher with _action discriminator
|
|
74
|
+
pending: boolean; // fetcher in flight
|
|
75
|
+
data: unknown; // action return value
|
|
76
|
+
error: unknown; // error if action failed
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ActionFormProps = Omit<ComponentProps<typeof Form>, "children" | "action"> & {
|
|
80
|
+
action: Record<string, string | number | boolean | null | undefined>;
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`ActionForm` is a form wrapper that auto-injects hidden fields from an action descriptor object. Replaces manual `<input type="hidden">` patterns when using `composeActions`.
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import { ActionForm } from "@cfast/actions/client";
|
|
89
|
+
|
|
90
|
+
<ActionForm action={{ _action: "addComment", postId: post.id }} method="post">
|
|
91
|
+
<textarea name="content" />
|
|
92
|
+
<button type="submit">Comment</button>
|
|
93
|
+
</ActionForm>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Each key/value pair in the `action` object becomes a hidden input. The above is equivalent to:
|
|
97
|
+
```tsx
|
|
98
|
+
<Form method="post">
|
|
99
|
+
<input type="hidden" name="_action" value="addComment" />
|
|
100
|
+
<input type="hidden" name="postId" value={post.id} />
|
|
101
|
+
<textarea name="content" />
|
|
102
|
+
<button type="submit">Comment</button>
|
|
103
|
+
</Form>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Usage Examples
|
|
107
|
+
|
|
108
|
+
### 1. Setup the factory (once per app)
|
|
109
|
+
```typescript
|
|
110
|
+
// app/actions.server.ts
|
|
111
|
+
import { createActions } from "@cfast/actions";
|
|
112
|
+
|
|
113
|
+
export const { createAction, composeActions } = createActions({
|
|
114
|
+
getContext: async ({ request }) => {
|
|
115
|
+
const ctx = await requireAuthContext(request);
|
|
116
|
+
const db = createCfDb(env.DB, ctx);
|
|
117
|
+
return { db, user: ctx.user, grants: ctx.grants };
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 2. Define an action
|
|
123
|
+
```typescript
|
|
124
|
+
import { compose } from "@cfast/db";
|
|
125
|
+
import { createAction } from "~/actions.server";
|
|
126
|
+
|
|
127
|
+
export const deletePost = createAction<{ postId: string }, Response>(
|
|
128
|
+
(db, input, ctx) =>
|
|
129
|
+
compose(
|
|
130
|
+
[db.delete(posts).where(eq(posts.id, input.postId))],
|
|
131
|
+
async (runDelete) => { await runDelete({}); return redirect("/"); },
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 3. Compose multiple actions in a route
|
|
137
|
+
```typescript
|
|
138
|
+
import { composeActions } from "~/actions.server";
|
|
139
|
+
import { deletePost, publishPost } from "~/actions/posts";
|
|
140
|
+
|
|
141
|
+
const composed = composeActions({ deletePost, publishPost });
|
|
142
|
+
export const action = composed.action;
|
|
143
|
+
export const loader = composed.loader(async ({ params }) => {
|
|
144
|
+
return { post: await getPost(params.slug) };
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 4. Use on the client
|
|
149
|
+
```typescript
|
|
150
|
+
import { useActions } from "@cfast/actions/client";
|
|
151
|
+
|
|
152
|
+
function PostActions({ postId }) {
|
|
153
|
+
const actions = useActions(composed.client);
|
|
154
|
+
const remove = actions.deletePost({ postId });
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<button
|
|
158
|
+
onClick={remove.submit}
|
|
159
|
+
disabled={!remove.permitted || remove.pending}
|
|
160
|
+
hidden={remove.invisible}
|
|
161
|
+
>
|
|
162
|
+
Delete
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Integration
|
|
169
|
+
- **@cfast/db**: Actions return `Operation` / `compose()` from `@cfast/db`. Permission descriptors are extracted from operations.
|
|
170
|
+
- **@cfast/permissions**: User `grants` (from `getContext`) are checked against operation permission descriptors via `checkPermissionStatus`.
|
|
171
|
+
|
|
172
|
+
## Common Mistakes
|
|
173
|
+
- Forgetting to wrap the loader with `.loader()` -- the client will have no permission data and `useActions` will default to `permitted: true`.
|
|
174
|
+
- Using `createAction` directly without first calling `createActions` to set up the factory. `createAction` is returned by `createActions`, not a standalone export.
|
|
175
|
+
- Omitting the `_action` hidden input in forms when using `composeActions`. The discriminator field is required to route to the correct handler.
|
|
176
|
+
- Calling `useActions` with the wrong descriptor. Use `composed.client` for composed actions, or `singleAction.client` for a single action.
|
|
177
|
+
|
|
178
|
+
## See Also
|
|
179
|
+
|
|
180
|
+
- `@cfast/db` -- Permission descriptors come from Operation results.
|
|
181
|
+
- `@cfast/permissions` -- Defines the grants enforced by action permissions.
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfast/actions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Multi-action routes and permission-aware action definitions for React Router",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cfast",
|
|
7
|
+
"cloudflare-workers",
|
|
8
|
+
"react-router",
|
|
9
|
+
"actions",
|
|
10
|
+
"multi-action",
|
|
11
|
+
"permissions"
|
|
12
|
+
],
|
|
5
13
|
"license": "MIT",
|
|
6
14
|
"repository": {
|
|
7
15
|
"type": "git",
|
|
@@ -22,7 +30,8 @@
|
|
|
22
30
|
}
|
|
23
31
|
},
|
|
24
32
|
"files": [
|
|
25
|
-
"dist"
|
|
33
|
+
"dist",
|
|
34
|
+
"llms.txt"
|
|
26
35
|
],
|
|
27
36
|
"sideEffects": false,
|
|
28
37
|
"publishConfig": {
|
|
@@ -33,8 +42,8 @@
|
|
|
33
42
|
"react-router": "^7.0.0"
|
|
34
43
|
},
|
|
35
44
|
"dependencies": {
|
|
36
|
-
"@cfast/db": "0.
|
|
37
|
-
"@cfast/permissions": "0.0
|
|
45
|
+
"@cfast/db": "0.1.1",
|
|
46
|
+
"@cfast/permissions": "0.1.0"
|
|
38
47
|
},
|
|
39
48
|
"devDependencies": {
|
|
40
49
|
"@cloudflare/workers-types": "^4.20260305.1",
|
package/dist/types-CNqDBI0X.d.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { Db, Operation } from '@cfast/db';
|
|
2
|
-
import { Grant } from '@cfast/permissions';
|
|
3
|
-
|
|
4
|
-
type Serializable = string | number | boolean | null | Serializable[] | {
|
|
5
|
-
[key: string]: Serializable;
|
|
6
|
-
};
|
|
7
|
-
type ActionContext<TUser> = {
|
|
8
|
-
db: Db;
|
|
9
|
-
user: TUser;
|
|
10
|
-
grants: Grant[];
|
|
11
|
-
};
|
|
12
|
-
type RequestArgs = {
|
|
13
|
-
request: Request;
|
|
14
|
-
params: Record<string, string | undefined>;
|
|
15
|
-
context?: unknown;
|
|
16
|
-
};
|
|
17
|
-
type ActionsConfig<TUser> = {
|
|
18
|
-
getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
|
|
19
|
-
};
|
|
20
|
-
type OperationsFn<TInput, TResult, TUser> = (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
|
|
21
|
-
type ActionPermissionStatus = {
|
|
22
|
-
permitted: boolean;
|
|
23
|
-
invisible: boolean;
|
|
24
|
-
reason: string | null;
|
|
25
|
-
};
|
|
26
|
-
type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
|
|
27
|
-
type ClientDescriptor = {
|
|
28
|
-
_brand: "ActionClientDescriptor";
|
|
29
|
-
actionNames: readonly string[];
|
|
30
|
-
permissionsKey: string;
|
|
31
|
-
};
|
|
32
|
-
type ActionDefinition<TInput, TResult, TUser> = {
|
|
33
|
-
action: (args: RequestArgs) => Promise<TResult>;
|
|
34
|
-
loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
|
|
35
|
-
_actionPermissions: ActionPermissionsMap;
|
|
36
|
-
}>;
|
|
37
|
-
client: ClientDescriptor;
|
|
38
|
-
buildOperation: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
|
|
39
|
-
};
|
|
40
|
-
type ComposedActions<TActions extends Record<string, ActionDefinition<any, any, any>>> = {
|
|
41
|
-
action: (args: RequestArgs) => Promise<unknown>;
|
|
42
|
-
loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
|
|
43
|
-
_actionPermissions: ActionPermissionsMap;
|
|
44
|
-
}>;
|
|
45
|
-
client: ClientDescriptor;
|
|
46
|
-
actions: TActions;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
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 };
|