@kellanjs/actioncraft
Version:
Fluent, type-safe builder for Next.js server actions.
387 lines • 15.9 kB
JavaScript
import { unstable_rethrow } from "next/navigation.js";
import { safeExecuteCallback } from "./core/callbacks.js";
import { createImplicitReturnErrorResult, createUnhandledErrorResult, } from "./core/errors.js";
import { log } from "./core/logging.js";
import { convertToClientError, serializeRawInput, } from "./core/transformation.js";
import { validateBindArgs, validateInput, validateOutput, } from "./core/validation.js";
import { EXTERNAL_ERROR_TYPES, } from "./types/errors.js";
import { err, isErr, isOk, isResultErr, isResultOk } from "./types/result.js";
// ============================================================================
// CRAFTER CLASS - TYPE-SAFE ACTION BUILDER
// ============================================================================
/**
* Builder class for creating type-safe server actions with validation, error handling, and callbacks.
*/
class Crafter {
_config;
_schemas;
_errors;
_callbacks;
_actionImpl;
constructor(config, schemas, errors, callbacks, actionImpl) {
this._config = config;
this._schemas = schemas;
this._errors = errors;
this._callbacks = callbacks;
this._actionImpl = actionImpl;
}
// --------------------------------------------------------------------------
// FLUENT API METHODS
// --------------------------------------------------------------------------
/**
* Defines validation schemas for input, output, and bind arguments.
* Resets previously defined actions and callbacks.
*/
schemas(schemas) {
return new Crafter(this._config, schemas, this._errors, {}, undefined);
}
/**
* Defines error functions for returning typed errors from actions.
* Resets previously defined actions and callbacks.
*/
errors(errors) {
return new Crafter(this._config, this._schemas, errors, {}, undefined);
}
/**
* Defines the action implementation function containing business logic.
* Resets previously defined callbacks.
*/
action(fn) {
return new Crafter(this._config, this._schemas, this._errors, {}, fn);
}
/**
* Defines lifecycle callbacks for action execution.
* Must be called after action() for correct type inference.
*/
callbacks(callbacks) {
return new Crafter(this._config, this._schemas, this._errors, callbacks, this._actionImpl);
}
/**
* Builds and returns the final executable action function.
*/
craft() {
if (!this._actionImpl) {
throw new Error("Action implementation is not defined. Call .action() before calling .craft().");
}
const craftedAction = (...args) => {
return this._runAction(args);
};
// Attach the action's config for runtime inspection (used by `initial()`)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
craftedAction.__ac_config = this._config;
return craftedAction;
}
// --------------------------------------------------------------------------
// ACTION EXECUTION
// --------------------------------------------------------------------------
/** Orchestrates action execution including validation, business logic, callbacks, and result formatting. */
async _runAction(args) {
// We know this exists because craft() verifies it
const actionImpl = this._actionImpl;
// 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))
: null;
// Execute onStart callback before any processing
await this._executeOnStartCallback({
rawInput,
rawBindArgs,
prevState,
validatedInput: undefined,
validatedBindArgs: undefined,
});
// Track validation state for error handling
let validatedInput = undefined;
let validatedBindArgs = undefined;
try {
// Validate input and return on failure
const inputValidation = await this._validateInput(rawInput);
if (!isOk(inputValidation)) {
await this._executeResultCallbacks(inputValidation, {
rawInput,
rawBindArgs,
prevState,
validatedInput,
validatedBindArgs,
});
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,
});
return this._toActionResult(bindArgsValidation, rawInput);
}
// Update validation state
validatedBindArgs = bindArgsValidation.value;
// Execute the user's action implementation
const actionImplResult = await actionImpl({
input: inputValidation.value,
bindArgs: bindArgsValidation.value,
errors: this._buildErrorFunctions(),
metadata: { rawInput, rawBindArgs, prevState },
});
// Return on `undefined` (implicit return error)
if (actionImplResult === undefined) {
const implicitReturnError = createImplicitReturnErrorResult();
await this._executeResultCallbacks(implicitReturnError, {
rawInput,
rawBindArgs,
prevState,
validatedInput,
validatedBindArgs,
});
return this._toActionResult(implicitReturnError, rawInput);
}
let finalResult;
// Process different return types from the action
if (isResultErr(actionImplResult)) {
finalResult = actionImplResult;
}
else {
const outputData = isResultOk(actionImplResult)
? actionImplResult.value
: actionImplResult;
finalResult = await this._validateOutput(outputData);
}
// Execute callbacks and return final result
await this._executeResultCallbacks(finalResult, {
rawInput,
rawBindArgs,
prevState,
validatedInput,
validatedBindArgs,
});
// 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,
});
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(), 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],
};
}
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));
// 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,
};
}
return {
success: false,
error: clientResult.error,
values: serializeRawInput(inputForValues),
};
}
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,
};
}
return {
success: false,
error: clientResult.error,
};
}
// --------------------------------------------------------------------------
// ERROR HANDLING
// --------------------------------------------------------------------------
/**
* Handles uncaught exceptions during action execution.
*/
_handleThrownError(error, customHandler) {
const caughtErrorResult = customHandler
? customHandler(error)
: createUnhandledErrorResult();
return caughtErrorResult;
}
// --------------------------------------------------------------------------
// VALIDATION
// --------------------------------------------------------------------------
/**
* Validates input using the shared helper.
*/
_validateInput(rawInput) {
return validateInput(this._schemas, this._config, rawInput);
}
/**
* Validates bound arguments using the configured bind schemas.
*/
_validateBindArgs(bindArgs) {
return validateBindArgs(this._schemas, this._config, bindArgs);
}
/**
* Validates output data using the configured output schema.
*/
_validateOutput(data) {
return validateOutput(this._schemas, this._config, data);
}
// --------------------------------------------------------------------------
// 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
// --------------------------------------------------------------------------
/**
* Creates error functions that return Result objects when called by action implementations.
*/
_buildErrorFunctions() {
const errorFns = {};
for (const [key, errorDefFn] of Object.entries(this._errors)) {
errorFns[key] = ((...args) => err(errorDefFn(...args)));
}
return errorFns;
}
}
// ============================================================================
// PUBLIC API EXPORTS
// ============================================================================
/**
* Creates a new Crafter instance for building type-safe server actions.
*/
export function create(config) {
return new Crafter(config ?? {}, {}, {}, {}, undefined);
}
/**
* Creates an appropriate initial state for any action based on its configuration.
*
* 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 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);
}
// useActionState enabled -> StatefulApiResult
if (cfg?.useActionState) {
return {
success: false,
error,
values: undefined,
};
}
// Default ApiResult shape
return {
success: false,
error,
};
}
//# sourceMappingURL=actioncraft-prev.js.map