UNPKG

@kellanjs/actioncraft

Version:

Fluent, type-safe builder for Next.js server actions.

613 lines 24.1 kB
import { safeExecuteCallback } from "./core/callbacks.js"; import { createUnhandledErrorResult, createImplicitReturnErrorResult, } from "./core/errors.js"; import { log } from "./core/logging.js"; import { serializeRawInput, convertToClientError, } from "./core/transformation.js"; import { validateInput, validateBindArgs, validateOutput, } from "./core/validation.js"; import { EXTERNAL_ERROR_TYPES, } from "./types/errors.js"; import { err, isOk, isResultOk, isResultErr, isErr } from "./types/result.js"; import { unstable_rethrow } from "next/navigation.js"; // ============================================================================ // CRAFTER CLASS - Configure and define your action // ============================================================================ const INTERNAL = Symbol("INTERNAL"); class Crafter { _config; _schemas; _errors; _callbacks; _handler; constructor(config, schemas, errors, callbacks, handler) { this._config = config; this._schemas = schemas; this._errors = errors; this._callbacks = callbacks; this._handler = handler; } // -------------------------------------------------------------------------- // FLUENT API METHODS // -------------------------------------------------------------------------- /** * Defines configuration options for the action. * Resets previously defined handler and callbacks. */ config(config) { return new Crafter(config, this._schemas, this._errors, {}, undefined); } /** * Defines validation schemas for input, output, and bind arguments. * Resets previously defined handler and callbacks. */ schemas(schemas) { return new Crafter(this._config, schemas, this._errors, {}, undefined); } /** * Defines error functions for returning typed errors from the handler. * Resets previously defined handler and callbacks. */ errors(errors) { return new Crafter(this._config, this._schemas, errors, {}, undefined); } /** * Defines the handler function containing the server action's business logic. * Resets previously defined callbacks. */ handler(fn) { return new Crafter(this._config, this._schemas, this._errors, {}, fn); } /** * Defines lifecycle callbacks to be triggered during the exection of an action. * Must be called after handler() for correct type inference. */ callbacks(callbacks) { return new Crafter(this._config, this._schemas, this._errors, callbacks, this._handler); } /** * @returns Internal properties of the Crafter instance */ [INTERNAL]() { return { config: this._config, schemas: this._schemas, errors: this._errors, callbacks: this._callbacks, handler: this._handler, }; } } // ============================================================================ // EXECUTOR CLASS - Build and execute your action // ============================================================================ class Executor { _config; _schemas; _errors; _callbacks; _handler; _actionId; constructor(crafter) { this._config = crafter[INTERNAL]().config; this._schemas = crafter[INTERNAL]().schemas; this._errors = crafter[INTERNAL]().errors; this._callbacks = crafter[INTERNAL]().callbacks; this._handler = crafter[INTERNAL]().handler; } /** * Builds and returns the final executable server action. */ craft() { if (!this._handler) { throw new Error("A handler implementation is required"); } // Generate a unique ID for this action instance this._actionId = this._generateActionId(); const craftedAction = (...args) => { return this._runAction(args); }; // Attach the action's config and ID for runtime inspection // eslint-disable-next-line @typescript-eslint/no-explicit-any craftedAction.__ac_config = this._config; // eslint-disable-next-line @typescript-eslint/no-explicit-any craftedAction.__ac_id = this._actionId; return craftedAction; } /** * Generates a unique identifier for this action instance. */ _generateActionId() { return crypto.randomUUID(); } // -------------------------------------------------------------------------- // ACTION EXECUTION // -------------------------------------------------------------------------- /** * Orchestrates action execution (validation, business logic, callbacks, and result formatting.) */ async _runAction(args) { // We know these exist because craft() creates/verifies them const handler = this._handler; const actionId = this._actionId; // Extract bindArgs, prevState, and input from the raw args const { bindArgs: rawBindArgs, prevState, input: rawInput, } = this._extractActionArgs(args); // Check for custom error handler const handleThrownErrorFn = this._config.handleThrownError ? (error) => err(this._config.handleThrownError(error), actionId) : null; // Track validation state for error handling let validatedInput = undefined; let validatedBindArgs = undefined; try { // Execute onStart callback before any processing await this._executeOnStartCallback({ rawInput, rawBindArgs, prevState, validatedInput: undefined, validatedBindArgs: undefined, actionId, }); // Validate input and return on failure const inputValidation = await this._validateInput(rawInput); if (!isOk(inputValidation)) { await this._executeResultCallbacks(inputValidation, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); return this._toActionResult(inputValidation, rawInput); } // Update validation state validatedInput = inputValidation.value; // Validate bound arguments and return on failure const bindArgsValidation = await this._validateBindArgs(rawBindArgs); if (!isOk(bindArgsValidation)) { await this._executeResultCallbacks(bindArgsValidation, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); return this._toActionResult(bindArgsValidation, rawInput); } // Update validation state validatedBindArgs = bindArgsValidation.value; // Execute the user's action handler const handlerResult = await handler({ input: inputValidation.value, bindArgs: bindArgsValidation.value, errors: this._buildErrorFunctions(), metadata: { rawInput, rawBindArgs, prevState, actionId, }, }); // Return on `undefined` (implicit return error) if (handlerResult === undefined) { const implicitReturnError = createImplicitReturnErrorResult(actionId); await this._executeResultCallbacks(implicitReturnError, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); return this._toActionResult(implicitReturnError, rawInput); } let finalResult; // Process different return types from the action if (isResultErr(handlerResult)) { // Ensure error result has correct action ID finalResult = this._ensureResultActionId(handlerResult); } else { const outputData = isResultOk(handlerResult) ? handlerResult.value : handlerResult; finalResult = await this._validateOutput(outputData); } // Execute callbacks and return final result await this._executeResultCallbacks(finalResult, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); // Use validated input for the values field on a successful run const inputForValues = isOk(finalResult) ? this._schemas.inputSchema ? inputValidation.value : rawInput : rawInput; return this._toActionResult(finalResult, inputForValues); } catch (error) { // Re-throw Next.js framework errors unstable_rethrow(error); // Handle unexpected thrown errors try { const errorResult = this._handleThrownError(error, handleThrownErrorFn); await this._executeResultCallbacks(errorResult, { rawInput, rawBindArgs, prevState, validatedInput, validatedBindArgs, actionId, }); return this._toActionResult(errorResult, rawInput); } catch (handlerError) { // If we catch another error here, then we're done log(this._config.logger, "warn", "Error handling failure - both primary error and error handler threw", { primaryError: error, handlerError }); return this._toActionResult(createUnhandledErrorResult(actionId), rawInput); } } } /** * Extracts bind arguments, previous state, and input from raw action arguments. */ _extractActionArgs(args) { const numBindSchemas = this._schemas.bindSchemas?.length ?? 0; if (this._config.useActionState) { return { bindArgs: args.slice(0, numBindSchemas), prevState: args[numBindSchemas], input: args[numBindSchemas + 1], }; } // For regular actions (non-useActionState), the input is the first argument after bind args // If there are no bind schemas, the input is the first argument (args[0]) return { bindArgs: args.slice(0, numBindSchemas), // When useActionState is disabled the prevState parameter is never // present, so we cast to never (or undefined) to satisfy the type. prevState: undefined, input: args[numBindSchemas], }; } // -------------------------------------------------------------------------- // RESULT TRANSFORMATION // -------------------------------------------------------------------------- /** * Transforms internal Result objects to client-facing action result format. */ _toActionResult(result, inputForValues) { // Convert internal errors to client-facing errors const clientResult = isOk(result) ? result : err(convertToClientError(result.error), this._actionId); // Handle useActionState format (always returns StatefulApiResult) if (this._config.useActionState) { if (isOk(clientResult)) { const successValues = this._schemas.inputSchema ? inputForValues : serializeRawInput(inputForValues); return { success: true, data: clientResult.value, values: successValues, __ac_id: this._actionId, }; } return { success: false, error: clientResult.error, values: serializeRawInput(inputForValues), __ac_id: this._actionId, }; } const format = this._config.resultFormat ?? "api"; // Return functional format if configured if (format === "functional") { return clientResult; } // Default API format if (isOk(clientResult)) { return { success: true, data: clientResult.value, __ac_id: this._actionId, }; } return { success: false, error: clientResult.error, __ac_id: this._actionId, }; } // -------------------------------------------------------------------------- // ERROR HANDLING // -------------------------------------------------------------------------- /** * Handles uncaught exceptions during action execution. */ _handleThrownError(error, customHandler) { const caughtErrorResult = customHandler ? customHandler(error) : createUnhandledErrorResult(this._actionId); return caughtErrorResult; } // -------------------------------------------------------------------------- // VALIDATION // -------------------------------------------------------------------------- /** * Validates input using the shared helper. */ _validateInput(rawInput) { return validateInput(this._schemas, this._config, rawInput, this._actionId); } /** * Validates bound arguments using the configured bind schemas. */ _validateBindArgs(bindArgs) { return validateBindArgs(this._schemas, this._config, bindArgs, this._actionId); } /** * Validates output data using the configured output schema. */ _validateOutput(data) { return validateOutput(this._schemas, this._config, data, this._actionId); } // -------------------------------------------------------------------------- // CALLBACKS // -------------------------------------------------------------------------- /** * Executes the onStart callback if defined. */ async _executeOnStartCallback(metadata) { const callbacks = this._callbacks; if (callbacks.onStart) { await safeExecuteCallback(() => callbacks.onStart({ metadata }), "onStart", (level, msg, details) => log(this._config.logger, level, msg, details)); } } /** * Executes result-based lifecycle callbacks (onSuccess, onError, onSettled). */ async _executeResultCallbacks(result, metadata) { const callbacks = this._callbacks; // Success path if (isOk(result)) { await safeExecuteCallback(callbacks.onSuccess ? () => callbacks.onSuccess({ data: result.value, metadata }) : undefined, "onSuccess", (level, msg, details) => log(this._config.logger, level, msg, details)); } // Error path if (isErr(result)) { await safeExecuteCallback(callbacks.onError ? () => callbacks.onError({ error: result.error, metadata }) : undefined, "onError", (level, msg, details) => log(this._config.logger, level, msg, details)); } // onSettled always runs, regardless of result const finalResult = this._toActionResult(result); await safeExecuteCallback(callbacks.onSettled ? () => callbacks.onSettled({ result: finalResult, metadata }) : undefined, "onSettled", (level, msg, details) => log(this._config.logger, level, msg, details)); } // -------------------------------------------------------------------------- // UTILITY METHODS // -------------------------------------------------------------------------- /** * Ensures a Result object has the correct action ID. */ _ensureResultActionId(result) { if (!result.__ac_id || result.__ac_id === "unknown") { return { ...result, __ac_id: this._actionId, }; } return result; } /** * Creates error functions that return a Result object when called by the action handler. */ _buildErrorFunctions() { const errorFns = {}; for (const [key, errorDefFn] of Object.entries(this._errors)) { errorFns[key] = ((...args) => err(errorDefFn(...args), this._actionId)); } return errorFns; } } // ============================================================================ // ACTION BUILDER CLASS - Alternative fluent API syntax ending with craft() // ============================================================================ class ActionBuilder { _config; _schemas; _errors; _callbacks; _handler; constructor(config, schemas, errors, callbacks, handler) { this._config = config; this._schemas = schemas; this._errors = errors; this._callbacks = callbacks; this._handler = handler; } // -------------------------------------------------------------------------- // FLUENT API METHODS (same as Crafter) // -------------------------------------------------------------------------- /** * Defines configuration options for the action. * Resets previously defined handler and callbacks. */ config(config) { return new ActionBuilder(config, this._schemas, this._errors, {}, undefined); } /** * Defines validation schemas for input, output, and bind arguments. * Resets previously defined handler and callbacks. */ schemas(schemas) { return new ActionBuilder(this._config, schemas, this._errors, {}, undefined); } /** * Defines error functions for returning typed errors from the handler. * Resets previously defined handler and callbacks. */ errors(errors) { return new ActionBuilder(this._config, this._schemas, errors, {}, undefined); } /** * Defines the handler function containing the server action's business logic. * Resets previously defined callbacks. */ handler(fn) { return new ActionBuilder(this._config, this._schemas, this._errors, {}, fn); } /** * Defines lifecycle callbacks to be triggered during the exection of an action. * Must be called after handler() for correct type inference. */ callbacks(callbacks) { return new ActionBuilder(this._config, this._schemas, this._errors, callbacks, this._handler); } // -------------------------------------------------------------------------- // CRAFT METHOD - Final step to create the action // -------------------------------------------------------------------------- /** * Builds and returns the final executable server action. * This is the terminal method for the ActionBuilder fluent API. */ craft() { // Convert ActionBuilder to Crafter and use existing Executor logic const crafter = new Crafter(this._config, this._schemas, this._errors, this._callbacks, this._handler); const executor = new Executor(crafter); return executor.craft(); } } /** * One of two entry points to the Actioncraft system. * It provides you with an empty Crafter instance on which you can call any of the fluent * Crafter methods to configure and define your action. * * Example Usage: * ```ts * const myAction = craft(async (action) => { * return action * .config(...) * .schemas(...) * .errors(...) * .handler(...) * .callbacks(...) * }); * ``` * * @param craftFn - The function that the user passes to `craft()` in order to build an action. * @returns The fully-typed server action function that can be used in your app. */ export function craft(craftFn) { const crafter = craftFn(new Crafter({}, {}, {}, {}, undefined)); // Handle async crafter functions if (crafter instanceof Promise) { return _craftAsync(crafter); } // Handle sync crafter functions const executor = new Executor(crafter); const craftedAction = executor.craft(); return craftedAction; } /** * Internal helper function to handle async craft functions. * Encapsulates the logic for creating async actions and preserving metadata. */ function _craftAsync(crafterPromise) { // Resolve the crafter once and cache the resulting action to ensure consistent IDs const actionPromise = crafterPromise.then((resolvedCrafter) => { const executor = new Executor(resolvedCrafter); return executor.craft(); }); // For async craft functions, we need to return an async action // eslint-disable-next-line @typescript-eslint/no-explicit-any const asyncAction = (async (...args) => { // Wait for the cached action to be ready const craftedAction = await actionPromise; // Call the action with the user's arguments return craftedAction(...args); }); // We need to preserve the config and ID for the initial() function to work // We'll use the same cached action to ensure consistent metadata actionPromise.then((craftedAction) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any asyncAction.__ac_config = craftedAction.__ac_config; // eslint-disable-next-line @typescript-eslint/no-explicit-any asyncAction.__ac_id = craftedAction.__ac_id; }); return asyncAction; } /** * One of two entry points to the Actioncraft system. * Creates a new ActionBuilder instance for the fluent API that ends with craft(). * This provides an alternative syntax for building your server actions. * * Example Usage: * ```ts * const myAction = action() * .config(...) * .schemas(...) * .errors(...) * .handler(...) * .callbacks(...) * .craft(); * ``` * * @returns A new ActionBuilder instance to start building your action. */ export function action() { return new ActionBuilder({}, {}, {}, {}, undefined); } /** * Creates an appropriate initial state for any action based on its configuration. * The initial state uses the action's real ID for consistency with actual results. * * For useActionState actions: returns StatefulApiResult with error and values * For functional format actions: returns Result.err() with error * For regular actions: returns ApiResult with error * * Usage: * - useActionState: const [state, action] = useActionState(myAction, initial(myAction)) * - useState: const [state, setState] = useState(initial(myAction)) */ export function initial(action) { const error = { type: EXTERNAL_ERROR_TYPES.INITIAL_STATE, message: "Action has not been executed yet", }; // Attempt to read the action ID created during craft() const actionId = // eslint-disable-next-line @typescript-eslint/no-explicit-any action?.__ac_id ?? "unknown"; // Attempt to read the Actioncraft config attached during craft() // eslint-disable-next-line @typescript-eslint/no-explicit-any const cfg = action?.__ac_config; // Functional format -> Result<_, _> if (cfg?.resultFormat === "functional") { return err(error, actionId); } // useActionState enabled -> StatefulApiResult if (cfg?.useActionState) { return { success: false, error, values: undefined, __ac_id: actionId, }; } // Default ApiResult shape return { success: false, error, __ac_id: actionId, }; } //# sourceMappingURL=actioncraft.js.map