@kellanjs/actioncraft
Version:
Fluent, type-safe builder for Next.js server actions.
613 lines • 24.1 kB
JavaScript
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