@fujocoded/astro-smooth-actions 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +299 -0
- package/dist/_virtual/rolldown_runtime.js +18 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +21 -0
- package/dist/controls.d.ts +5 -0
- package/dist/controls.js +6 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +70 -0
- package/dist/input.d.ts +4 -0
- package/dist/input.js +58 -0
- package/dist/middleware.d.ts +28 -0
- package/dist/middleware.js +134 -0
- package/dist/types.d.ts +6 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# `@fujocoded/astro-smooth-actions`
|
|
2
|
+
|
|
3
|
+
## What is `@fujocoded/astro-smooth-actions`?
|
|
4
|
+
|
|
5
|
+
An Astro integration that keeps your form Actions nice and smooth, even when the
|
|
6
|
+
visitor has no client-side JavaScript. You install it, your visitor submits a
|
|
7
|
+
form, and they land back on a clean page. No `Confirm Form Resubmission` prompt
|
|
8
|
+
on refresh, no leftover action query string cluttering the URL, and the action
|
|
9
|
+
result still shows up right where you expect it.
|
|
10
|
+
|
|
11
|
+
## What's included in `@fujocoded/astro-smooth-actions`?
|
|
12
|
+
|
|
13
|
+
- `astroSmoothActions()` => the integration you register in `astro.config.mjs`.
|
|
14
|
+
It routes every form action through a POST/Redirect/GET cycle, so the visitor
|
|
15
|
+
ends up on a clean page
|
|
16
|
+
- `getActionInput()` => reads back the form fields behind the last action
|
|
17
|
+
result, so you can show the visitor exactly what they submitted
|
|
18
|
+
|
|
19
|
+
## Wait…what's wrong with plain forms?
|
|
20
|
+
|
|
21
|
+
A plain HTML form sends your data straight to the server:
|
|
22
|
+
|
|
23
|
+
```astro
|
|
24
|
+
<form method="POST" action={actions.subscribe}>...</form>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
There, the server runs the action and renders the page in the same response.
|
|
28
|
+
Then it sends your visitor on their way and back to a page that came from a form
|
|
29
|
+
submission.
|
|
30
|
+
|
|
31
|
+
Now two things can go wrong:
|
|
32
|
+
|
|
33
|
+
- Refresh, and the browser asks them to `Confirm Form Resubmission` (because
|
|
34
|
+
reloading re-sends the request and re-runs the action)
|
|
35
|
+
- The address bar still shows the action's query string, so the URL is not clean
|
|
36
|
+
to bookmark or share, and a refresh can retrigger things like notifications
|
|
37
|
+
|
|
38
|
+
**The fix is an old web pattern called POST/Redirect/GET.** Instead of a page,
|
|
39
|
+
your server answers the POST with a redirect. The browser follows it with a fresh
|
|
40
|
+
GET, which means the page your visitor sees has no form body behind it. Refresh reloads that page, and the URL stays clean.
|
|
41
|
+
|
|
42
|
+
One extra problem: the redirect drops the POST body, which means the action
|
|
43
|
+
result would normally disappear right along with it. This is (also) where
|
|
44
|
+
`@fujocoded/astro-smooth-actions` comes in! It will:
|
|
45
|
+
|
|
46
|
+
1. Save the result and your submitted fields before the redirect
|
|
47
|
+
2. Read them back on the fresh GET
|
|
48
|
+
|
|
49
|
+
**tl;dr:** write your forms and actions the normal way, and the clean page comes with no
|
|
50
|
+
extra work.
|
|
51
|
+
|
|
52
|
+
## Setup
|
|
53
|
+
|
|
54
|
+
1. Install the package:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm install @fujocoded/astro-smooth-actions
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
2. Register the integration:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
// astro.config.mjs
|
|
64
|
+
import { defineConfig } from "astro/config";
|
|
65
|
+
import astroSmoothActions from "@fujocoded/astro-smooth-actions";
|
|
66
|
+
|
|
67
|
+
export default defineConfig({
|
|
68
|
+
integrations: [astroSmoothActions()],
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
3. Make sure session storage is available. The integration stashes each action
|
|
73
|
+
result in Astro's session between the POST and the redirect, so it needs a
|
|
74
|
+
[session driver or an adapter](https://docs.astro.build/en/guides/sessions/).
|
|
75
|
+
|
|
76
|
+
> [!WARNING]
|
|
77
|
+
>
|
|
78
|
+
> Without a session driver or adapter, the integration has nowhere to stash
|
|
79
|
+
> results, so it cannot smooth anything. It logs a warning when Astro starts and
|
|
80
|
+
> your forms fall back to Astro's normal behavior. No crash, but no smoothing.
|
|
81
|
+
> The same fallback covers you while running the live site: if a session write
|
|
82
|
+
> ever fails, your form actions still run instead of failing hard.
|
|
83
|
+
|
|
84
|
+
## Okay how do I _actually_ do stuff with this?
|
|
85
|
+
|
|
86
|
+
The [`__examples__/`](./__examples__/) folder has runnable Astro apps you can
|
|
87
|
+
copy from. Each one has an `index` page with success and error messaging, plus a
|
|
88
|
+
`special-cases` page covering `ACTION_INPUT_CONTROL`, comma-separated field
|
|
89
|
+
lists, and `ACTION_INPUT_NONE`:
|
|
90
|
+
|
|
91
|
+
- [`astro-7`](./__examples__/astro-7/) => the demo on the latest Astro
|
|
92
|
+
- [`astro-6`](./__examples__/astro-6/) and [`astro-5`](./__examples__/astro-5/)
|
|
93
|
+
=> the same demo pinned to those Astro versions
|
|
94
|
+
|
|
95
|
+
## What happens on each submit
|
|
96
|
+
|
|
97
|
+
> [!NOTE]
|
|
98
|
+
>
|
|
99
|
+
> This wraps the pattern Astro's own docs describe in ["Advanced: Persist action
|
|
100
|
+
> results with a
|
|
101
|
+
> session"](https://docs.astro.build/en/guides/actions/#advanced-persist-action-results-with-a-session).
|
|
102
|
+
> Astro recommends it for keeping form actions working without client-side
|
|
103
|
+
> JavaScript. This package installs that middleware for you, so you do not copy
|
|
104
|
+
> it into every project.
|
|
105
|
+
|
|
106
|
+
Once the integration is registered, every form action flows through its
|
|
107
|
+
middleware, which does its work across two sequential requests:
|
|
108
|
+
|
|
109
|
+
On the form action POST, it:
|
|
110
|
+
|
|
111
|
+
1. Catches the POST before the page renders
|
|
112
|
+
2. Runs the action on the server
|
|
113
|
+
3. Stores the serialized result in the session, plus the restorable values and
|
|
114
|
+
hidden-field markers from your form fields
|
|
115
|
+
4. Redirects the browser to a clean page, either the action destination path (on
|
|
116
|
+
success) or the page the visitor submitted from (on error)
|
|
117
|
+
|
|
118
|
+
Then, on the redirected GET, it:
|
|
119
|
+
|
|
120
|
+
1. Hands the stored result to `Astro.getActionResult()`, so it can be returned
|
|
121
|
+
to you as the action results
|
|
122
|
+
2. Exposes the submitted fields through `getActionInput()`
|
|
123
|
+
3. Clears the stored result and its cookie, so a refresh does not replay the
|
|
124
|
+
action
|
|
125
|
+
|
|
126
|
+
At the end of this, the page your visitor sees is a plain page request with no
|
|
127
|
+
submission data, which behaves the way we've all come to love and expect.
|
|
128
|
+
|
|
129
|
+
> [!NOTE]
|
|
130
|
+
>
|
|
131
|
+
> Each action payload is keyed to a per-session token in a short-lived cookie, so
|
|
132
|
+
> one route or visitor cannot read another's stored result.
|
|
133
|
+
|
|
134
|
+
## Showing the user what they submitted
|
|
135
|
+
|
|
136
|
+
In addition to making the submission experience smooth, this integration also
|
|
137
|
+
keeps the raw form fields that produced the last action result.
|
|
138
|
+
`getActionInput()` hands them back so you can repopulate a form after the
|
|
139
|
+
redirect drops the POST body. On a page with more than one form, it also tells
|
|
140
|
+
you which form's submission produced the current result, so you can show the
|
|
141
|
+
error next to the right one:
|
|
142
|
+
|
|
143
|
+
```astro
|
|
144
|
+
---
|
|
145
|
+
import { actions } from "astro:actions";
|
|
146
|
+
import { getActionInput } from "@fujocoded/astro-smooth-actions";
|
|
147
|
+
|
|
148
|
+
const result = Astro.getActionResult(actions.deleteEntry);
|
|
149
|
+
const input = await getActionInput({
|
|
150
|
+
locals: Astro.locals,
|
|
151
|
+
action: actions.deleteEntry,
|
|
152
|
+
});
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
{input?.id === entry.id && result?.error && <p>{result.error.message}</p>}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
What you get back:
|
|
159
|
+
|
|
160
|
+
- String fields keep their full submitted value
|
|
161
|
+
- A field name that shows up more than once comes back as a string array, in
|
|
162
|
+
submission order, so checkbox groups stay intact
|
|
163
|
+
- File inputs come back as `null`, because they cannot be restored from a
|
|
164
|
+
session store
|
|
165
|
+
- Common sensitive field names come back as `null` by default, including
|
|
166
|
+
password, passcode, secret, token, API key, PIN, and OTP fields
|
|
167
|
+
- The stored input clears after the redirect target reads it once
|
|
168
|
+
|
|
169
|
+
## Skipping fields
|
|
170
|
+
|
|
171
|
+
`@fujocoded/astro-smooth-actions` will round-trip your fields back to you, but
|
|
172
|
+
some of their values (like passwords) should never come back, let alone get
|
|
173
|
+
saved into a session at all.
|
|
174
|
+
|
|
175
|
+
> [!IMPORTANT]
|
|
176
|
+
>
|
|
177
|
+
> Stored form values are a UX convenience, not a security boundary. Reach for
|
|
178
|
+
> `excludeFields` or `ACTION_INPUT_CONTROL` for any field whose submitted value
|
|
179
|
+
> should never be replayed into HTML. Excluded fields still appear in stored
|
|
180
|
+
> input as `null`, so consumers can distinguish them from fields that were never
|
|
181
|
+
> submitted.
|
|
182
|
+
|
|
183
|
+
How to exclude fields depends on how you want to exclude them.
|
|
184
|
+
|
|
185
|
+
### Skipping fields by name
|
|
186
|
+
|
|
187
|
+
You may hope the middleware could just skip every `<input type="password">`, but
|
|
188
|
+
browsers do not send an input's `type` with the form data. Since there's no way
|
|
189
|
+
to tell a password field from a plain text one, we do so by name. A built-in
|
|
190
|
+
list of sensitive names has its values hidden by default, and you can change
|
|
191
|
+
that list project-wide in the config.
|
|
192
|
+
|
|
193
|
+
The default list is exported as `DEFAULT_EXCLUDED_FIELDS`. Spread it into your
|
|
194
|
+
own array to keep the built-ins and add a few more:
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
// astro.config.mjs
|
|
198
|
+
import { defineConfig } from "astro/config";
|
|
199
|
+
import astroSmoothActions, {
|
|
200
|
+
DEFAULT_EXCLUDED_FIELDS,
|
|
201
|
+
} from "@fujocoded/astro-smooth-actions";
|
|
202
|
+
|
|
203
|
+
export default defineConfig({
|
|
204
|
+
integrations: [
|
|
205
|
+
astroSmoothActions({
|
|
206
|
+
input: {
|
|
207
|
+
excludeFields: [
|
|
208
|
+
...DEFAULT_EXCLUDED_FIELDS,
|
|
209
|
+
"backupEmail",
|
|
210
|
+
"inviteCode",
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
> [!IMPORTANT]
|
|
219
|
+
>
|
|
220
|
+
> Setting `excludeFields` replaces the whole default list, it does not add to it.
|
|
221
|
+
> Keep it to the empty array (`[]`) to include all fields.
|
|
222
|
+
|
|
223
|
+
Matching is forgiving about formatting: names are compared after lowercasing and
|
|
224
|
+
stripping punctuation, so `backupEmail`, `backup-email`, and `backup_email` all
|
|
225
|
+
count as the same field.
|
|
226
|
+
|
|
227
|
+
### Skipping individual fields in forms
|
|
228
|
+
|
|
229
|
+
When a field only needs skipping on one form, add the input control right in the
|
|
230
|
+
markup:
|
|
231
|
+
|
|
232
|
+
```astro
|
|
233
|
+
---
|
|
234
|
+
import { ACTION_INPUT_CONTROL } from "@fujocoded/astro-smooth-actions";
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
<form method="POST" action={actions.updateProfile}>
|
|
238
|
+
<input type="hidden" name={ACTION_INPUT_CONTROL} value="backupEmail" />
|
|
239
|
+
<input name="displayName" />
|
|
240
|
+
<input name="backupEmail" />
|
|
241
|
+
<button>Save</button>
|
|
242
|
+
</form>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
You can repeat the control or pass a comma-separated list:
|
|
246
|
+
|
|
247
|
+
```html
|
|
248
|
+
<input
|
|
249
|
+
type="hidden"
|
|
250
|
+
name="astro-smooth-actions:input"
|
|
251
|
+
value="backupEmail, inviteCode"
|
|
252
|
+
/>
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Skipping a whole form
|
|
256
|
+
|
|
257
|
+
To skip storage for a whole form, like a login form where nothing should come
|
|
258
|
+
back:
|
|
259
|
+
|
|
260
|
+
```astro
|
|
261
|
+
---
|
|
262
|
+
import {
|
|
263
|
+
ACTION_INPUT_CONTROL,
|
|
264
|
+
ACTION_INPUT_NONE,
|
|
265
|
+
} from "@fujocoded/astro-smooth-actions";
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
<form method="POST" action={actions.login}>
|
|
269
|
+
<input type="hidden" name={ACTION_INPUT_CONTROL} value={ACTION_INPUT_NONE} />
|
|
270
|
+
<input name="email" />
|
|
271
|
+
<input name="password" type="password" />
|
|
272
|
+
<button>Log in</button>
|
|
273
|
+
</form>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The exported `ACTION_INPUT_NONE` sentinel disables input storage for the whole
|
|
277
|
+
form. Any other value on the same control is treated as one or more field names
|
|
278
|
+
to omit.
|
|
279
|
+
|
|
280
|
+
### Skipping a whole action
|
|
281
|
+
|
|
282
|
+
To disable input storage for a whole action, configure the integration with the
|
|
283
|
+
action name Astro exposes to middleware:
|
|
284
|
+
|
|
285
|
+
```js
|
|
286
|
+
// astro.config.mjs
|
|
287
|
+
import { defineConfig } from "astro/config";
|
|
288
|
+
import astroSmoothActions from "@fujocoded/astro-smooth-actions";
|
|
289
|
+
|
|
290
|
+
export default defineConfig({
|
|
291
|
+
integrations: [
|
|
292
|
+
astroSmoothActions({
|
|
293
|
+
input: {
|
|
294
|
+
excludeActions: ["actions.login"],
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
],
|
|
298
|
+
});
|
|
299
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __export = (all, symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) {
|
|
6
|
+
__defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
if (symbols) {
|
|
12
|
+
__defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
13
|
+
}
|
|
14
|
+
return target;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { __export };
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
type AstroSmoothActionsInputOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* Optional. Action names whose submitted fields are never stored, so
|
|
5
|
+
* `getActionInput()` returns `undefined` for them. When omitted, every
|
|
6
|
+
* action's input is stored, although individual values may still come back
|
|
7
|
+
* `null` via `excludeFields`.
|
|
8
|
+
*
|
|
9
|
+
* Use the action's path: "login", or "user.login" for an action nested in a
|
|
10
|
+
* group. "actions.login" works too, but the leading "actions." that Astro
|
|
11
|
+
* adds is optional.
|
|
12
|
+
*
|
|
13
|
+
* To check the correct name, comment the integration out and submit the form.
|
|
14
|
+
* The browser's address bar will show the name at the end of the url, like
|
|
15
|
+
* `?_action=actions.login`. (With the integration on, you can look at the
|
|
16
|
+
* POST request in the Network tab instead)
|
|
17
|
+
* */
|
|
18
|
+
excludeActions?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Optional. The full list of field names whose submitted values are never
|
|
21
|
+
* stored. The field still comes back from `getActionInput()` as `null`, so you
|
|
22
|
+
* can still tell it apart from a field that was never submitted. When
|
|
23
|
+
* omitted, the built-in `DEFAULT_EXCLUDED_FIELDS` (password, token, etc.)
|
|
24
|
+
* is used.
|
|
25
|
+
*
|
|
26
|
+
* Setting this replaces the defaults, it does not add to them. To keep them,
|
|
27
|
+
* spread `DEFAULT_EXCLUDED_FIELDS` into your array. Names are matched after
|
|
28
|
+
* lowercasing and stripping punctuation, so "backupEmail" and "backup-email"
|
|
29
|
+
* are the same field.
|
|
30
|
+
*/
|
|
31
|
+
excludeFields?: string[];
|
|
32
|
+
};
|
|
33
|
+
type AstroSmoothActionsConfig = {
|
|
34
|
+
input?: AstroSmoothActionsInputOptions;
|
|
35
|
+
};
|
|
36
|
+
declare const DEFAULT_EXCLUDED_FIELDS: readonly ["password", "currentPassword", "newPassword", "confirmPassword", "passcode", "secret", "token", "csrfToken", "apiKey", "pin", "otp"];
|
|
37
|
+
//#endregion
|
|
38
|
+
export { AstroSmoothActionsConfig, AstroSmoothActionsInputOptions, DEFAULT_EXCLUDED_FIELDS };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/config.ts
|
|
2
|
+
const DEFAULT_EXCLUDED_FIELDS = [
|
|
3
|
+
"password",
|
|
4
|
+
"currentPassword",
|
|
5
|
+
"newPassword",
|
|
6
|
+
"confirmPassword",
|
|
7
|
+
"passcode",
|
|
8
|
+
"secret",
|
|
9
|
+
"token",
|
|
10
|
+
"csrfToken",
|
|
11
|
+
"apiKey",
|
|
12
|
+
"pin",
|
|
13
|
+
"otp"
|
|
14
|
+
];
|
|
15
|
+
const normalizeConfig = (config = {}) => ({ input: {
|
|
16
|
+
excludeActions: config.input?.excludeActions ?? [],
|
|
17
|
+
excludeFields: config.input?.excludeFields ?? [...DEFAULT_EXCLUDED_FIELDS]
|
|
18
|
+
} });
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
export { DEFAULT_EXCLUDED_FIELDS, normalizeConfig };
|
package/dist/controls.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ActionInput } from "./input.js";
|
|
2
|
+
import { middleware_d_exports } from "./middleware.js";
|
|
3
|
+
import { AstroSmoothActionsConfig, AstroSmoothActionsInputOptions, DEFAULT_EXCLUDED_FIELDS } from "./config.js";
|
|
4
|
+
import { ACTION_INPUT_CONTROL, ACTION_INPUT_NONE } from "./controls.js";
|
|
5
|
+
import { AstroIntegration } from "astro";
|
|
6
|
+
|
|
7
|
+
//#region src/index.d.ts
|
|
8
|
+
type MiddlewareModule = typeof middleware_d_exports;
|
|
9
|
+
/**
|
|
10
|
+
* Reads back the raw form fields that produced the latest action result, so you
|
|
11
|
+
* can show the visitor exactly what they submitted.
|
|
12
|
+
*
|
|
13
|
+
* Pass the current page's `locals` and the action you rendered the result for:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const input = await getActionInput({
|
|
17
|
+
* locals: Astro.locals,
|
|
18
|
+
* action: actions.subscribe,
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Returns an object of the stored fields, where each one is either:
|
|
23
|
+
*
|
|
24
|
+
* - A string holding the submitted value, for a field submitted once
|
|
25
|
+
* - An array of strings, in submission order, for a field submitted more than
|
|
26
|
+
* once (several inputs sharing a name, or a multi-select)
|
|
27
|
+
* - `null` for a submitted field whose value was intentionally not stored, such
|
|
28
|
+
* as a file input or an excluded field
|
|
29
|
+
*
|
|
30
|
+
* Returns `undefined` when the latest result came from a different action, or
|
|
31
|
+
* when input storage was disabled for the action or form.
|
|
32
|
+
*/
|
|
33
|
+
declare const getActionInput: (...args: Parameters<MiddlewareModule["getActionInput"]>) => Promise<ActionInput | undefined>;
|
|
34
|
+
declare function astroSmoothActions(config?: AstroSmoothActionsConfig): AstroIntegration;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { ACTION_INPUT_CONTROL, ACTION_INPUT_NONE, type AstroSmoothActionsConfig, type AstroSmoothActionsInputOptions, DEFAULT_EXCLUDED_FIELDS, astroSmoothActions as default, getActionInput };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { DEFAULT_EXCLUDED_FIELDS, normalizeConfig } from "./config.js";
|
|
2
|
+
import { ACTION_INPUT_CONTROL, ACTION_INPUT_NONE } from "./controls.js";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
const CONFIG_MODULE_ID = "fujocoded:astro-smooth-actions/config";
|
|
8
|
+
const RESOLVED_CONFIG_MODULE_ID = `\0${CONFIG_MODULE_ID}`;
|
|
9
|
+
const createConfigPlugin = (config) => ({
|
|
10
|
+
name: "fujocoded:astro-smooth-actions-config",
|
|
11
|
+
resolveId(id) {
|
|
12
|
+
if (id === CONFIG_MODULE_ID) return RESOLVED_CONFIG_MODULE_ID;
|
|
13
|
+
},
|
|
14
|
+
load(id) {
|
|
15
|
+
if (id === RESOLVED_CONFIG_MODULE_ID) return `export const astroSmoothActionsConfig = ${JSON.stringify(normalizeConfig(config))};`;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
let middlewareModule = null;
|
|
19
|
+
/**
|
|
20
|
+
* Reads back the raw form fields that produced the latest action result, so you
|
|
21
|
+
* can show the visitor exactly what they submitted.
|
|
22
|
+
*
|
|
23
|
+
* Pass the current page's `locals` and the action you rendered the result for:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* const input = await getActionInput({
|
|
27
|
+
* locals: Astro.locals,
|
|
28
|
+
* action: actions.subscribe,
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Returns an object of the stored fields, where each one is either:
|
|
33
|
+
*
|
|
34
|
+
* - A string holding the submitted value, for a field submitted once
|
|
35
|
+
* - An array of strings, in submission order, for a field submitted more than
|
|
36
|
+
* once (several inputs sharing a name, or a multi-select)
|
|
37
|
+
* - `null` for a submitted field whose value was intentionally not stored, such
|
|
38
|
+
* as a file input or an excluded field
|
|
39
|
+
*
|
|
40
|
+
* Returns `undefined` when the latest result came from a different action, or
|
|
41
|
+
* when input storage was disabled for the action or form.
|
|
42
|
+
*/
|
|
43
|
+
const getActionInput = async (...args) => {
|
|
44
|
+
if (!middlewareModule) middlewareModule = await import("./middleware.js");
|
|
45
|
+
return middlewareModule.getActionInput(...args);
|
|
46
|
+
};
|
|
47
|
+
function astroSmoothActions(config = {}) {
|
|
48
|
+
return {
|
|
49
|
+
name: "astro-smooth-actions",
|
|
50
|
+
hooks: {
|
|
51
|
+
"astro:config:setup": ({ addMiddleware, updateConfig }) => {
|
|
52
|
+
updateConfig({ vite: { plugins: [createConfigPlugin(config)] } });
|
|
53
|
+
addMiddleware({
|
|
54
|
+
order: "pre",
|
|
55
|
+
entrypoint: path.join(import.meta.dirname, "./middleware.js")
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
"astro:config:done": async ({ config: config$1, injectTypes, logger }) => {
|
|
59
|
+
if (!config$1.session?.driver && !config$1.adapter) logger.warn("The astro-smooth-actions integration uses Astro's session storage, which needs a session driver or an adapter, and you have neither configured. Your form actions still run, but without the smooth redirect. To turn it on, set one up: https://docs.astro.build/en/guides/sessions/");
|
|
60
|
+
injectTypes({
|
|
61
|
+
filename: "types.d.ts",
|
|
62
|
+
content: await readFile(path.join(import.meta.dirname, "./types.d.ts"), { encoding: "utf-8" })
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
export { ACTION_INPUT_CONTROL, ACTION_INPUT_NONE, DEFAULT_EXCLUDED_FIELDS, astroSmoothActions as default, getActionInput };
|
package/dist/input.d.ts
ADDED
package/dist/input.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ACTION_INPUT_CONTROL, ACTION_INPUT_NONE } from "./controls.js";
|
|
2
|
+
import { astroSmoothActionsConfig } from "fujocoded:astro-smooth-actions/config";
|
|
3
|
+
|
|
4
|
+
//#region src/input.ts
|
|
5
|
+
const normalizeFieldName = (fieldName) => fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
6
|
+
const isExcludedFieldName = (fieldName) => {
|
|
7
|
+
const normalized = normalizeFieldName(fieldName);
|
|
8
|
+
return astroSmoothActionsConfig.input.excludeFields.some((excludedField) => normalized === normalizeFieldName(excludedField));
|
|
9
|
+
};
|
|
10
|
+
const readInputControlValues = (formData) => formData.getAll(ACTION_INPUT_CONTROL).filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean);
|
|
11
|
+
const parseOmittedFields = (controlValues) => {
|
|
12
|
+
const omittedFields = /* @__PURE__ */ new Set();
|
|
13
|
+
controlValues.forEach((value) => {
|
|
14
|
+
if (value.toLowerCase() === ACTION_INPUT_NONE) return;
|
|
15
|
+
value.split(",").map((fieldName) => fieldName.trim()).filter(Boolean).forEach((fieldName) => omittedFields.add(fieldName));
|
|
16
|
+
});
|
|
17
|
+
return omittedFields;
|
|
18
|
+
};
|
|
19
|
+
const shouldHideFieldValue = ({ fieldName, omittedFields }) => omittedFields.has(fieldName) || isExcludedFieldName(fieldName);
|
|
20
|
+
const isInputStorageEnabledForForm = (controlValues) => !controlValues.some((value) => value.toLowerCase() === ACTION_INPUT_NONE);
|
|
21
|
+
const stripActionPrefix = (name) => name.replace(/^actions\./, "");
|
|
22
|
+
const isInputStorageEnabledForAction = (actionName) => !astroSmoothActionsConfig.input.excludeActions.map(stripActionPrefix).includes(stripActionPrefix(actionName));
|
|
23
|
+
const readPersistableActionInput = async (request) => {
|
|
24
|
+
try {
|
|
25
|
+
const formData = await request.clone().formData();
|
|
26
|
+
const inputControlValues = readInputControlValues(formData);
|
|
27
|
+
if (!isInputStorageEnabledForForm(inputControlValues)) return;
|
|
28
|
+
const omittedFields = parseOmittedFields(inputControlValues);
|
|
29
|
+
const input = {};
|
|
30
|
+
formData.forEach((value, key) => {
|
|
31
|
+
if (key === ACTION_INPUT_CONTROL) return;
|
|
32
|
+
if (shouldHideFieldValue({
|
|
33
|
+
fieldName: key,
|
|
34
|
+
omittedFields
|
|
35
|
+
})) {
|
|
36
|
+
input[key] = null;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const currentValue = input[key];
|
|
40
|
+
if (typeof value !== "string") {
|
|
41
|
+
if (currentValue === void 0) input[key] = null;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (currentValue === void 0 || currentValue === null) {
|
|
45
|
+
input[key] = value;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
input[key] = Array.isArray(currentValue) ? [...currentValue, value] : [currentValue, value];
|
|
49
|
+
});
|
|
50
|
+
if (Object.keys(input).length === 0) return;
|
|
51
|
+
return input;
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
export { isInputStorageEnabledForAction, readPersistableActionInput };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ActionInput } from "./input.js";
|
|
2
|
+
import { MiddlewareHandler } from "astro";
|
|
3
|
+
|
|
4
|
+
//#region src/middleware.d.ts
|
|
5
|
+
declare function getActionInput({
|
|
6
|
+
locals,
|
|
7
|
+
action
|
|
8
|
+
}: {
|
|
9
|
+
locals: App.Locals;
|
|
10
|
+
action: {
|
|
11
|
+
queryString?: string;
|
|
12
|
+
};
|
|
13
|
+
}): ActionInput | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Runs the POST/Redirect/GET flow for form actions.
|
|
16
|
+
*
|
|
17
|
+
* Three paths through a request:
|
|
18
|
+
*
|
|
19
|
+
* - Returning GET (our cookie is set) => restore the stored result and input,
|
|
20
|
+
* clear the cookie and session entry, then continue
|
|
21
|
+
* - Form action POST => run the action, store its result and form fields, then
|
|
22
|
+
* redirect to a clean page (the page they came from on error, otherwise the
|
|
23
|
+
* current path)
|
|
24
|
+
* - Anything else => continue untouched
|
|
25
|
+
*/
|
|
26
|
+
declare const onRequest: MiddlewareHandler;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { getActionInput, middleware_d_exports, onRequest };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { isInputStorageEnabledForAction, readPersistableActionInput } from "./input.js";
|
|
2
|
+
import { ACTION_QUERY_PARAMS, getActionContext } from "astro:actions";
|
|
3
|
+
|
|
4
|
+
//#region src/middleware.ts
|
|
5
|
+
const getActionName = (action) => {
|
|
6
|
+
const queryString = "queryString" in action ? action.queryString : void 0;
|
|
7
|
+
if (typeof queryString !== "string") return void 0;
|
|
8
|
+
return new URLSearchParams(queryString.replace(/^\?/, "")).get(ACTION_QUERY_PARAMS.actionName) ?? void 0;
|
|
9
|
+
};
|
|
10
|
+
function getActionInput({ locals, action }) {
|
|
11
|
+
const lastAction = locals.lastAction;
|
|
12
|
+
if (!lastAction || lastAction.name !== getActionName(action)) return;
|
|
13
|
+
return lastAction.input;
|
|
14
|
+
}
|
|
15
|
+
const ACTION_SESSION_COOKIE = "astro-smooth-action-session";
|
|
16
|
+
const ACTION_SESSION_TTL_SECONDS = 60;
|
|
17
|
+
const getSessionKey = (sessionId) => `smooth-actions:${sessionId}`;
|
|
18
|
+
const clearActionSessionCookie = (context) => {
|
|
19
|
+
context.cookies.delete(ACTION_SESSION_COOKIE, { path: "/" });
|
|
20
|
+
};
|
|
21
|
+
const getSafeRefererPath = (context) => {
|
|
22
|
+
const referer = context.request.headers.get("Referer");
|
|
23
|
+
if (!referer) return context.originPathname;
|
|
24
|
+
try {
|
|
25
|
+
const refererUrl = new URL(referer);
|
|
26
|
+
if (refererUrl.origin !== context.url.origin) return context.originPathname;
|
|
27
|
+
return `${refererUrl.pathname}${refererUrl.search}${refererUrl.hash}`;
|
|
28
|
+
} catch {
|
|
29
|
+
return context.originPathname;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const isActionSessionEntry = (stored) => {
|
|
33
|
+
if (!stored || typeof stored !== "object") return false;
|
|
34
|
+
const candidate = stored;
|
|
35
|
+
if (typeof candidate.name !== "string") return false;
|
|
36
|
+
if (candidate.result === void 0) return false;
|
|
37
|
+
if (candidate.input === void 0) return true;
|
|
38
|
+
return typeof candidate.input === "object" && candidate.input !== null && !Array.isArray(candidate.input);
|
|
39
|
+
};
|
|
40
|
+
const readStoredAction = async ({ session, sessionId }) => {
|
|
41
|
+
try {
|
|
42
|
+
const stored = await session.get(getSessionKey(sessionId));
|
|
43
|
+
if (isActionSessionEntry(stored)) return stored;
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const deleteStoredAction = ({ session, sessionId }) => {
|
|
49
|
+
try {
|
|
50
|
+
session.delete(getSessionKey(sessionId));
|
|
51
|
+
} catch {}
|
|
52
|
+
};
|
|
53
|
+
const writeStoredAction = ({ context, actionName, result, input }) => {
|
|
54
|
+
if (!context.session) return void 0;
|
|
55
|
+
const newSessionId = crypto.randomUUID();
|
|
56
|
+
try {
|
|
57
|
+
context.session.set(getSessionKey(newSessionId), {
|
|
58
|
+
name: actionName,
|
|
59
|
+
result,
|
|
60
|
+
input
|
|
61
|
+
});
|
|
62
|
+
context.cookies.set(ACTION_SESSION_COOKIE, newSessionId, {
|
|
63
|
+
path: "/",
|
|
64
|
+
httpOnly: true,
|
|
65
|
+
sameSite: "lax",
|
|
66
|
+
maxAge: ACTION_SESSION_TTL_SECONDS
|
|
67
|
+
});
|
|
68
|
+
return newSessionId;
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const hasActionHelpers = (actionContext) => typeof actionContext.setActionResult === "function" && typeof actionContext.serializeActionResult === "function";
|
|
74
|
+
const hasActionError = (result) => typeof result === "object" && result !== null && "error" in result;
|
|
75
|
+
/**
|
|
76
|
+
* Runs the POST/Redirect/GET flow for form actions.
|
|
77
|
+
*
|
|
78
|
+
* Three paths through a request:
|
|
79
|
+
*
|
|
80
|
+
* - Returning GET (our cookie is set) => restore the stored result and input,
|
|
81
|
+
* clear the cookie and session entry, then continue
|
|
82
|
+
* - Form action POST => run the action, store its result and form fields, then
|
|
83
|
+
* redirect to a clean page (the page they came from on error, otherwise the
|
|
84
|
+
* current path)
|
|
85
|
+
* - Anything else => continue untouched
|
|
86
|
+
*/
|
|
87
|
+
const onRequest = async (context, next) => {
|
|
88
|
+
if (context.isPrerendered) return next();
|
|
89
|
+
const actionContext = getActionContext(context);
|
|
90
|
+
const { action } = actionContext;
|
|
91
|
+
const canPersist = hasActionHelpers(actionContext);
|
|
92
|
+
if (!context.session) {
|
|
93
|
+
clearActionSessionCookie(context);
|
|
94
|
+
return next();
|
|
95
|
+
}
|
|
96
|
+
const sessionId = context.cookies.get(ACTION_SESSION_COOKIE)?.value;
|
|
97
|
+
if (sessionId) {
|
|
98
|
+
const stored = await readStoredAction({
|
|
99
|
+
session: context.session,
|
|
100
|
+
sessionId
|
|
101
|
+
});
|
|
102
|
+
clearActionSessionCookie(context);
|
|
103
|
+
deleteStoredAction({
|
|
104
|
+
session: context.session,
|
|
105
|
+
sessionId
|
|
106
|
+
});
|
|
107
|
+
if (stored && canPersist) {
|
|
108
|
+
actionContext.setActionResult(stored.name, stored.result);
|
|
109
|
+
if (stored.input !== void 0) context.locals.lastAction = {
|
|
110
|
+
name: stored.name,
|
|
111
|
+
input: stored.input
|
|
112
|
+
};
|
|
113
|
+
return next();
|
|
114
|
+
}
|
|
115
|
+
return next();
|
|
116
|
+
}
|
|
117
|
+
if (action?.calledFrom === "form" && canPersist) {
|
|
118
|
+
const { serializeActionResult } = actionContext;
|
|
119
|
+
const input = isInputStorageEnabledForAction(action.name) ? await readPersistableActionInput(context.request) : void 0;
|
|
120
|
+
const result = await action.handler();
|
|
121
|
+
const serializedResult = serializeActionResult(result);
|
|
122
|
+
if (writeStoredAction({
|
|
123
|
+
context,
|
|
124
|
+
actionName: action.name,
|
|
125
|
+
result: serializedResult,
|
|
126
|
+
input
|
|
127
|
+
}) === void 0) return next();
|
|
128
|
+
return context.redirect(hasActionError(result) ? getSafeRefererPath(context) : context.originPathname);
|
|
129
|
+
}
|
|
130
|
+
return next();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
//#endregion
|
|
134
|
+
export { getActionInput, onRequest };
|
package/dist/types.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fujocoded/astro-smooth-actions",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "An Astro integration for smooth action handling",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"astro-integration",
|
|
7
|
+
"withastro"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "FujoCoded LLC",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/fujowebdev/fujocoded-plugins.git"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md",
|
|
19
|
+
"package.json"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"module": "dist/index.js",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"import": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:e2e": "playwright test",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"validate": " npx publint"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@playwright/test": "^1.61.0",
|
|
45
|
+
"@types/node": "^22.19.21",
|
|
46
|
+
"astro": "^5.0.0",
|
|
47
|
+
"glob": "^13.0.6",
|
|
48
|
+
"tsdown": "^0.14.1",
|
|
49
|
+
"vitest": "^3.2.4"
|
|
50
|
+
}
|
|
51
|
+
}
|