next-action-forge
Version:
A simple, type-safe toolkit for Next.js server actions with Zod validation
458 lines (449 loc) • 16.4 kB
JavaScript
"use client";
;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/hooks/index.ts
var hooks_exports = {};
__export(hooks_exports, {
ToastRestorer: () => ToastRestorer,
useFormAction: () => useFormAction,
useOptimisticAction: () => useOptimisticAction,
useServerAction: () => useServerAction
});
module.exports = __toCommonJS(hooks_exports);
// src/hooks/use-server-action.ts
var import_react = require("react");
var import_navigation = require("next/navigation");
var import_sonner = require("sonner");
// src/next/errors/redirect.ts
var RedirectStatusCode = /* @__PURE__ */ ((RedirectStatusCode2) => {
RedirectStatusCode2[RedirectStatusCode2["SeeOther"] = 303] = "SeeOther";
RedirectStatusCode2[RedirectStatusCode2["TemporaryRedirect"] = 307] = "TemporaryRedirect";
RedirectStatusCode2[RedirectStatusCode2["PermanentRedirect"] = 308] = "PermanentRedirect";
return RedirectStatusCode2;
})(RedirectStatusCode || {});
var REDIRECT_ERROR_CODE = "NEXT_REDIRECT";
function isRedirectError(error) {
if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") {
return false;
}
const digest = error.digest.split(";");
const [errorCode, type] = digest;
const destination = digest.slice(2, -2).join(";");
const status = digest.at(-2);
const statusCode = Number(status);
return errorCode === REDIRECT_ERROR_CODE && (type === "replace" || type === "push") && typeof destination === "string" && !isNaN(statusCode) && statusCode in RedirectStatusCode;
}
// src/next/errors/http-access-fallback.ts
var HTTPAccessErrorStatus = {
NOT_FOUND: 404,
FORBIDDEN: 403,
UNAUTHORIZED: 401
};
var ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus));
var HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK";
function isHTTPAccessFallbackError(error) {
if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") {
return false;
}
const [prefix, httpStatus] = error.digest.split(";");
return prefix === HTTP_ERROR_FALLBACK_ERROR_CODE && ALLOWED_CODES.has(Number(httpStatus));
}
// src/next/errors/index.ts
function isNextNavigationError(error) {
return isRedirectError(error) || isHTTPAccessFallbackError(error);
}
// src/hooks/use-server-action.ts
function useServerAction(action, options = {}) {
const router = (0, import_navigation.useRouter)();
const [result, setResult] = (0, import_react.useState)();
const [isExecuting, setIsExecuting] = (0, import_react.useState)(false);
const [isRedirecting, setIsRedirecting] = (0, import_react.useState)(false);
const [, startTransition] = (0, import_react.useTransition)();
const {
onSuccess,
onError,
successMessage,
errorMessage,
showSuccessToast = false,
showErrorToast = true,
redirectOnAuthError = true,
preventRedirect = false,
redirectDelay = 0
} = options;
const hasSucceeded = !!result?.data;
const hasErrored = !!result?.serverError || !!result?.validationErrors || !!result?.fetchError;
const reset = (0, import_react.useCallback)(() => {
setResult(void 0);
setIsRedirecting(false);
}, []);
const executeInternal = (0, import_react.useCallback)(
async (input) => {
try {
const response = await (input === void 0 ? action() : action(input));
let actionResult;
if (response.success) {
actionResult = { data: response.data };
if (process.env.NODE_ENV === "development") {
console.log("[useServerAction] Success response:", response);
console.log("[useServerAction] Has redirect?", !!response.redirect);
}
if (showSuccessToast && successMessage) {
const message = typeof successMessage === "function" ? successMessage(response.data) : successMessage;
import_sonner.toast.success(message);
}
await onSuccess?.(response.data);
if (response.redirect && !preventRedirect) {
setIsRedirecting(true);
const redirectConfig = typeof response.redirect === "string" ? { url: response.redirect } : response.redirect;
const delay = redirectConfig.delay ?? redirectDelay;
setTimeout(() => {
if (redirectConfig.replace) {
router.replace(redirectConfig.url);
} else {
router.push(redirectConfig.url);
}
}, delay);
}
} else if (!response.success && response.error) {
actionResult = {
serverError: response.error,
validationErrors: response.error.fields
};
if (process.env.NODE_ENV === "development") {
console.log("[useServerAction] Error response:", response.error);
console.log("[useServerAction] Should redirect?", response.error.shouldRedirect);
console.log("[useServerAction] Redirect to:", response.error.redirectTo);
}
if (redirectOnAuthError && response.error.shouldRedirect) {
if (showErrorToast) {
const message = errorMessage ? typeof errorMessage === "function" ? errorMessage(actionResult) : errorMessage : response.error.message || "An error occurred";
if (typeof window !== "undefined") {
try {
const storageKey = "sonner-toasts";
const existingToasts = JSON.parse(
localStorage.getItem(storageKey) || "[]"
);
const persistentToast = {
id: Date.now(),
type: "error",
message,
persistent: true,
createdAt: Date.now()
};
existingToasts.push(persistentToast);
localStorage.setItem(storageKey, JSON.stringify(existingToasts));
} catch (err) {
console.error("Failed to persist toast:", err);
}
}
import_sonner.toast.error(message, { persistent: true });
}
setIsRedirecting(true);
router.push(response.error.redirectTo || "/login");
return actionResult;
}
if (showErrorToast) {
const message = errorMessage ? typeof errorMessage === "function" ? errorMessage(actionResult) : errorMessage : response.error.message || "An error occurred";
import_sonner.toast.error(message);
}
await onError?.(actionResult);
} else {
actionResult = { fetchError: "Unexpected response format" };
}
setResult(actionResult);
return actionResult;
} catch (error) {
if (isNextNavigationError(error)) {
throw error;
}
const fetchError = error instanceof Error ? error.message : "Network error";
const actionResult = { fetchError };
setResult(actionResult);
if (showErrorToast) {
import_sonner.toast.error(fetchError);
}
await onError?.(actionResult);
return actionResult;
}
},
[action, router, showSuccessToast, successMessage, showErrorToast, errorMessage, redirectOnAuthError, onSuccess, onError]
);
const execute = (0, import_react.useCallback)(
(...args) => {
setIsExecuting(true);
return new Promise((resolve) => {
startTransition(async () => {
try {
const result2 = await executeInternal(args[0]);
resolve(result2);
} catch (error) {
const fetchError = error instanceof Error ? error.message : "Unexpected error";
resolve({ fetchError });
} finally {
setIsExecuting(false);
}
});
});
},
[executeInternal]
);
return {
execute,
result,
isExecuting,
isRedirecting,
hasSucceeded,
hasErrored,
reset
};
}
// src/hooks/use-optimistic-action.ts
var import_react2 = require("react");
function useOptimisticAction(initialState, action, options) {
const [optimisticState, addOptimisticUpdate] = (0, import_react2.useOptimistic)(
initialState,
options.updateFn
);
const [, startTransition] = (0, import_react2.useTransition)();
const { updateFn: _, ...serverActionOptions } = options;
const {
execute: executeAction,
isExecuting,
hasSucceeded,
hasErrored,
result,
reset
} = useServerAction(action, serverActionOptions);
const execute = async (input) => {
startTransition(() => {
if (input !== void 0) {
addOptimisticUpdate(input);
} else {
addOptimisticUpdate(void 0);
}
});
return executeAction(input);
};
return {
optimisticState,
execute,
isExecuting,
hasSucceeded,
hasErrored,
result,
reset
};
}
// src/hooks/use-form-action.ts
var import_react3 = require("react");
var import_navigation2 = require("next/navigation");
var import_react_hook_form = require("react-hook-form");
var import_zod = require("@hookform/resolvers/zod");
var import_sonner2 = require("sonner");
function isSuccessResponse(response) {
return response.success === true;
}
function objectToFormData(obj) {
const formData = new FormData();
Object.entries(obj).forEach(([key, value]) => {
if (value !== void 0 && value !== null) {
if (value instanceof File || value instanceof Blob) {
formData.append(key, value);
} else if (Array.isArray(value)) {
value.forEach((item) => formData.append(`${key}[]`, String(item)));
} else if (typeof value === "object") {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, String(value));
}
}
});
return formData;
}
function useFormAction({
action,
schema,
defaultValues,
mode = "onChange",
transformData,
onSuccess,
onError,
resetOnSuccess = false,
showSuccessToast = false,
showErrorToast = true,
preventRedirect = false,
redirectDelay = 0
}) {
const router = (0, import_navigation2.useRouter)();
const [isRedirecting, setIsRedirecting] = (0, import_react3.useState)(false);
const onSuccessRef = (0, import_react3.useRef)(onSuccess);
const onErrorRef = (0, import_react3.useRef)(onError);
const showSuccessToastRef = (0, import_react3.useRef)(showSuccessToast);
const showErrorToastRef = (0, import_react3.useRef)(showErrorToast);
(0, import_react3.useEffect)(() => {
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
showSuccessToastRef.current = showSuccessToast;
showErrorToastRef.current = showErrorToast;
});
const form = (0, import_react_hook_form.useForm)({
resolver: schema ? (0, import_zod.zodResolver)(schema) : void 0,
defaultValues,
mode
});
const initialState = { success: true, data: void 0 };
const [actionState, formAction, isPending] = (0, import_react3.useActionState)(action, initialState);
const [isTransitioning, startTransition] = (0, import_react3.useTransition)();
(0, import_react3.useEffect)(() => {
if (actionState.success === true && actionState.data === void 0) {
return;
}
setIsRedirecting(false);
let timeoutId;
if (!isSuccessResponse(actionState) && actionState.error) {
const { error } = actionState;
if (error.fields) {
Object.entries(error.fields).forEach(([field, messages]) => {
if (Array.isArray(messages) && messages.length > 0) {
form.setError(field, {
type: "server",
message: messages[0]
});
}
});
}
if (error.field && error.message && !error.fields) {
form.setError(error.field, {
type: "server",
message: error.message
});
}
if (error.message && !error.field && !error.fields) {
const showToast = showErrorToastRef.current;
if (showToast) {
const message = typeof showToast === "function" ? showToast(error) : typeof showToast === "string" ? showToast : error.message;
import_sonner2.toast.error(message);
}
form.setError("root", {
type: "server",
message: error.message
});
}
onErrorRef.current?.(error);
}
if (isSuccessResponse(actionState) && actionState.data !== void 0) {
const showToast = showSuccessToastRef.current;
if (showToast) {
const message = typeof showToast === "function" ? showToast(actionState.data) : typeof showToast === "string" ? showToast : "Success!";
import_sonner2.toast.success(message);
}
if (resetOnSuccess) {
form.reset();
}
onSuccessRef.current?.(actionState.data);
if (actionState.redirect && !preventRedirect) {
setIsRedirecting(true);
const redirectConfig = typeof actionState.redirect === "string" ? { url: actionState.redirect } : actionState.redirect;
const delay = redirectConfig.delay ?? redirectDelay;
timeoutId = setTimeout(() => {
if (redirectConfig.replace) {
router.replace(redirectConfig.url);
} else {
router.push(redirectConfig.url);
}
}, delay || 100);
}
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [actionState]);
const handleSubmit = async (data) => {
form.clearErrors();
const formData = transformData ? transformData(data) : objectToFormData(data);
startTransition(() => {
formAction(formData);
});
};
const isSubmitting = isPending || isTransitioning;
const onSubmit = (e) => {
e?.preventDefault();
void form.handleSubmit(handleSubmit)(e);
};
return {
form,
onSubmit,
isSubmitting,
isRedirecting,
actionState,
reset: form.reset,
handleSubmit
};
}
// src/hooks/toast-restorer.tsx
var import_react4 = require("react");
var import_sonner3 = require("sonner");
function ToastRestorer() {
(0, import_react4.useEffect)(() => {
if (typeof window === "undefined") return;
const storageKey = "sonner-toasts";
try {
const storedToasts = localStorage.getItem(storageKey);
if (storedToasts) {
const persistentToasts = JSON.parse(storedToasts);
if (Array.isArray(persistentToasts) && persistentToasts.length > 0) {
localStorage.removeItem(storageKey);
const recentToasts = persistentToasts.filter(
(t) => Date.now() - (t.createdAt || 0) < 3e4
);
recentToasts.forEach((persistedToast, index) => {
if (persistedToast.type === "loading") return;
setTimeout(() => {
const toastFunction = import_sonner3.toast[persistedToast.type];
if (typeof toastFunction === "function") {
if (toastFunction.length >= 2) {
toastFunction(persistedToast.message, {
persistent: true,
id: persistedToast.id
});
} else {
toastFunction(persistedToast.message);
}
}
}, index * 150);
});
}
}
} catch (error) {
console.error("Failed to restore toasts:", error);
localStorage.removeItem(storageKey);
}
}, []);
return null;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ToastRestorer,
useFormAction,
useOptimisticAction,
useServerAction
});
//# sourceMappingURL=index.js.map