@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 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 };
@@ -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 };
@@ -0,0 +1,5 @@
1
+ //#region src/controls.d.ts
2
+ declare const ACTION_INPUT_CONTROL = "astro-smooth-actions:input";
3
+ declare const ACTION_INPUT_NONE = "none";
4
+ //#endregion
5
+ export { ACTION_INPUT_CONTROL, ACTION_INPUT_NONE };
@@ -0,0 +1,6 @@
1
+ //#region src/controls.ts
2
+ const ACTION_INPUT_CONTROL = "astro-smooth-actions:input";
3
+ const ACTION_INPUT_NONE = "none";
4
+
5
+ //#endregion
6
+ export { ACTION_INPUT_CONTROL, ACTION_INPUT_NONE };
@@ -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 };
@@ -0,0 +1,4 @@
1
+ //#region src/input.d.ts
2
+ type ActionInput = Record<string, string | string[] | null>;
3
+ //#endregion
4
+ export { ActionInput };
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 };
@@ -0,0 +1,6 @@
1
+ //#region src/types.d.ts
2
+ declare module "astro:actions" {
3
+ export * from "astro/actions/runtime/server.js";
4
+ }
5
+ //#endregion
6
+ export {};
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
+ }