Jay-Stack Server Actions
Jay Stack Server Actions
Written for AI agents. See Log Methodology Note below for details.
Background
Jay Stack pages currently support a three-phase rendering model:
- Slow Render (build/data-change time) - Loads static data, pre-renders HTML
- Fast Render (request time) - Loads dynamic data, completes SSR
- Interactive (client time) - Handles user interactions with reactive state
However, once the page is loaded, client-side code has no way to call server-side functions. This is a critical gap for building real applications.
Problem
In real applications, client components need to call server-side code for:
- Search: User types in search box → call server search API
- Mutations: Add to cart, place order, update profile
- Data fetching: Load more items, refresh data
- Form submissions: Submit contact form, checkout
Currently, developers would need to:
- Set up a separate API (Express routes, etc.)
- Manually create fetch calls
- Handle serialization/deserialization
- Lose type safety between client and server
Example: Current Gap
// pages/products/[slug]/page.ts
function ProductsPageConstructor(
props: Props<PageProps>,
refs: PageRefs,
fastViewState: Signals<PageFastViewState>,
fastCarryForward: ProductAndInventoryCarryForward,
) {
refs.addToCart.onclick(() => {
// ❌ How do we call the server to add to cart?
// There's no mechanism for this!
addToCart(fastCarryForward.productId, 1);
});
return { render: () => ({}) };
}
Questions and Answers
Q: Should actions be defined per-page or globally?
- A: Globally as standalone modules. This enables reuse across pages and components. Actions are developer-to-developer (frontend ↔ backend), not designer-to-developer like contracts.
Q: How should actions relate to services?
- A: Actions use the same service injection pattern as render functions via
.withServices().
- A: Actions use the same service injection pattern as render functions via
Q: Should actions be type-safe end-to-end?
- A: Yes, using a builder pattern with explicit input/output types.
Q: How does this integrate with the contract system?
- A: It doesn't. Contracts are for designer ↔ developer communication. Actions are backend API definitions, purely developer concern.
Q: What about authentication/authorization?
- A: Actions can optionally receive request context for auth checks.
Q: Where should action modules live?
- A: In
src/actions/directory, organized by domain (e.g.,cart.actions.ts,search.actions.ts).
- A: In
Design Options Considered
| Option | Description | Verdict |
|---|---|---|
| Page-scoped actions | Actions defined in .withActions() on the component builder |
❌ Not reusable across pages |
| Contract-based actions | Actions defined in .jay-contract files |
❌ Contracts are designer↔developer; actions are developer↔developer |
| Hybrid (page + standalone) | Both page-scoped and standalone modules | ❌ Two ways to do same thing creates confusion |
'use server' directive |
Next.js-style magic directive | ❌ Requires complex compiler magic, less explicit |
| RPC-style standalone modules | tRPC-inspired builder pattern | ✅ Chosen |
Why RPC-Style Standalone Modules
- Reusability - Actions can be imported from any component, page, or context
- Explicit - Clear what runs on server vs client (no magic directives)
- Type-safe - Full inference from handler function signature
- Familiar pattern - Builder API consistent with
makeJayStackComponent - Service injection - Same
.withServices()pattern as render phases - Separation of concerns - Actions are backend API definitions, not UI contracts
Design: RPC-Style Action Builder
Actions are defined as standalone modules using a builder pattern similar to makeJayStackComponent. They can be imported and called from any client component or context.
Action Builder API
Types are inferred from the handler function - no need to specify separate Input/Output types:
// @jay-framework/fullstack-component
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
// For mutations (default: POST)
export function makeJayAction(name: string): JayActionBuilder<[], unknown, unknown, 'POST'>;
// For queries/reads (default: GET)
export function makeJayQuery(name: string): JayActionBuilder<[], unknown, unknown, 'GET'>;
interface JayActionBuilder<
Services extends any[],
Input,
Output,
DefaultMethod extends HttpMethod,
> {
withServices<NewServices extends any[]>(
...services: ServiceMarkers<NewServices>
): JayActionBuilder<NewServices, Input, Output, DefaultMethod>;
// Override HTTP method if needed
withMethod(method: HttpMethod): JayActionBuilder<Services, Input, Output, DefaultMethod>;
// Enable caching (typically for GET, but allowed for other methods if needed)
withCaching(options?: CacheOptions): JayActionBuilder<Services, Input, Output, DefaultMethod>;
// Handler defines both input and output types via inference
withHandler<I, O>(handler: (input: I, ...services: Services) => Promise<O>): JayAction<I, O>;
}
interface CacheOptions {
maxAge?: number; // seconds
staleWhileRevalidate?: number;
}
// The callable action/query type (same runtime type)
interface JayAction<Input, Output> {
// Called from client - makes HTTP request to server
(input: Input): Promise<Output>;
// Metadata for registration
readonly actionName: string;
readonly method: HttpMethod;
readonly _marker: unique symbol;
}
// Error class for action/query failures
export class ActionError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message);
this.name = 'ActionError';
}
}
When to Use Each
| Builder | Default Method | Use Case |
|---|---|---|
makeJayAction |
POST | Mutations: add to cart, submit form, update profile |
makeJayQuery |
GET | Reads: search, get details, list items |
Both can override the method with .withMethod() if needed, but the defaults express intent clearly.
Error Handling and HTTP Status Codes
Actions distinguish between business logic errors (4xx) and system errors (5xx):
| Error Type | HTTP Status | When |
|---|---|---|
ActionError thrown |
422 Unprocessable Entity | Business logic failure (out of stock, invalid input, etc.) |
Other Error thrown |
500 Internal Server Error | Unexpected system failure |
| Action not found | 404 Not Found | Invalid action name |
| Wrong HTTP method | 405 Method Not Allowed | GET vs POST mismatch |
| Invalid JSON input | 400 Bad Request | Malformed request body |
Example:
export const addToCart = makeJayAction('cart.addToCart')
.withServices(INVENTORY_SERVICE)
.withHandler(async (input, inventory) => {
const available = await inventory.getAvailableUnits(input.productId);
// ActionError → 422 (client can handle gracefully)
if (available < input.quantity) {
throw new ActionError('NOT_AVAILABLE', 'Product is out of stock');
}
// Unexpected error → 500 (system failure)
if (!input.productId) {
throw new Error('Missing productId'); // Should not happen
}
return { success: true };
});
Client-side handling:
try {
const result = await addToCart({ productId: '123', quantity: 1 });
} catch (error) {
if (error instanceof ActionError) {
// Business logic error (4xx) - show to user
showNotification(error.message);
} else {
// System error (5xx) - generic message
showNotification('Something went wrong. Please try again.');
}
}
Timeout Configuration
Actions have a configurable timeout to prevent hung requests:
interface JayActionBuilder<...> {
// ... existing methods ...
// Set action timeout (default: 30 seconds)
withTimeout(ms: number): JayActionBuilder<...>;
}
Server-side timeout:
// Action with custom timeout
export const generateReport = makeJayAction('reports.generate')
.withTimeout(60000) // 60 seconds for long-running operation
.withHandler(async (input) => {
return await generateLargeReport(input);
});
Default timeout: 30 seconds (configurable globally)
// In jay.init.ts - set global default
import { setActionDefaults } from '@jay-framework/stack-server-runtime';
setActionDefaults({
timeout: 15000, // 15 seconds default for all actions
});
Timeout behavior:
- Server cancels handler execution after timeout
- Returns 504 Gateway Timeout status
- Client receives
ActionErrorwith code'TIMEOUT'
Defining Actions
Input and output types are inferred from the handler function signature:
// src/actions/cart.actions.ts
import { makeJayAction } from '@jay-framework/fullstack-component';
import { CART_SERVICE, INVENTORY_SERVICE } from '../services';
// ✅ Types inferred from handler - input type from parameter, output from return
// ✅ Uses ActionError for failures (thrown, not returned)
export const addToCart = makeJayAction('cart.addToCart')
.withServices(CART_SERVICE, INVENTORY_SERVICE)
.withHandler(async (input: { productId: string; quantity: number }, cartService, inventory) => {
const available = await inventory.getAvailableUnits(input.productId);
if (available < input.quantity) {
throw new ActionError('NOT_AVAILABLE', `Only ${available} units available`);
}
const cart = await cartService.addItem(input.productId, input.quantity);
return { cartItemCount: cart.items.length };
});
// ✅ No input needed - use empty object or void
export const getCart = makeJayAction('cart.getCart')
.withServices(CART_SERVICE)
.withHandler(async (_input: void, cartService) => {
return cartService.getCart();
});
// ✅ Simple inline types
export const removeFromCart = makeJayAction('cart.removeFromCart')
.withServices(CART_SERVICE)
.withHandler(async (input: { itemId: string }, cartService) => {
await cartService.removeItem(input.itemId);
return { success: true };
});
// src/actions/search.actions.ts
import { makeJayQuery } from '@jay-framework/fullstack-component';
import { PRODUCTS_DATABASE_SERVICE } from '../services';
// ✅ makeJayQuery defaults to GET - enables browser/CDN caching
export const searchProducts = makeJayQuery('products.search')
.withServices(PRODUCTS_DATABASE_SERVICE)
.withCaching({ maxAge: 60, staleWhileRevalidate: 120 })
.withHandler(async (input: { query: string; page?: number; limit?: number }, productsDb) => {
const results = await productsDb.search(input.query, {
page: input.page ?? 1,
limit: input.limit ?? 20,
});
return {
products: results.items,
totalCount: results.total,
hasMore: results.hasMore,
};
});
Extracting Types (When Needed)
If you need to reference the input/output types elsewhere, use TypeScript utilities:
// Extract types from an action
type AddToCartInput = Parameters<typeof addToCart>[0];
type AddToCartOutput = Awaited<ReturnType<typeof addToCart>>;
// Or define a helper type
type ActionInput<T> = T extends JayAction<infer I, any> ? I : never;
type ActionOutput<T> = T extends JayAction<any, infer O> ? O : never;
type Input = ActionInput<typeof addToCart>; // { productId: string; quantity: number }
type Output = ActionOutput<typeof addToCart>; // { success: boolean; cartItemCount: number; error?: string }
Client Usage
Actions are imported directly and called like regular async functions:
// pages/products/[slug]/page.ts
import { addToCart } from '../../../actions/cart.actions';
function ProductsPageConstructor(
props: Props<PageProps>,
refs: PageRefs,
fastViewState: Signals<PageFastViewState>,
fastCarryForward: ProductAndInventoryCarryForward,
) {
const [isAdding, setIsAdding] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
refs.addToCart.onclick(async () => {
setIsAdding(true);
setError(null);
try {
// ✅ Type-safe action call - TypeScript knows input/output types
const result = await addToCart({
productId: fastCarryForward.productId,
quantity: 1,
});
// Success! result.cartItemCount is available
} catch (e) {
// ✅ ActionError provides code and message
if (e instanceof ActionError) {
setError(e.message); // "Only 2 units available"
// e.code === 'NOT_AVAILABLE'
} else {
setError('Network error. Please try again.');
}
} finally {
setIsAdding(false);
}
});
return {
render: () => ({
isAdding,
error,
}),
};
}
Using Actions in Client Context
Actions can be wrapped in a reactive context for shared state management:
// src/contexts/cart.context.ts
import { createSignal, provideReactiveContext } from '@jay-framework/component';
import { createJayContext } from '@jay-framework/runtime';
import { addToCart, getCart, removeFromCart } from '../actions/cart.actions';
export interface CartContextValue {
items: () => CartItem[];
itemCount: () => number;
isLoading: () => boolean;
add: (productId: string, quantity: number) => Promise<boolean>;
remove: (itemId: string) => Promise<void>;
refresh: () => Promise<void>;
}
export const CART_CONTEXT = createJayContext<CartContextValue>();
// Call this in a parent component to provide the cart context
export const provideCartContext = () =>
provideReactiveContext(CART_CONTEXT, () => {
const [items, setItems] = createSignal<CartItem[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const refresh = async () => {
setIsLoading(true);
try {
const cart = await getCart();
setItems(cart.items);
} finally {
setIsLoading(false);
}
};
const add = async (productId: string, quantity: number) => {
setIsLoading(true);
try {
const result = await addToCart({ productId, quantity });
await refresh();
return true;
} catch (e) {
return false;
} finally {
setIsLoading(false);
}
};
const remove = async (itemId: string) => {
setIsLoading(true);
try {
await removeFromCart({ itemId });
await refresh();
} finally {
setIsLoading(false);
}
};
return {
items,
itemCount: () => items().length,
isLoading,
add,
remove,
refresh,
};
});
Project Structure
src/
├── actions/ # Action definitions
│ ├── cart.actions.ts # Cart-related actions
│ ├── search.actions.ts # Search actions
│ ├── user.actions.ts # User/auth actions
│ └── index.ts # Re-exports
├── services/ # Service definitions
│ ├── cart.service.ts
│ ├── products.service.ts
│ └── index.ts
├── contexts/ # Client contexts (can use actions)
│ ├── cart.context.ts
│ └── user.context.ts
├── pages/
│ ├── products/
│ │ └── [slug]/
│ │ ├── page.jay-html
│ │ ├── page.jay-contract
│ │ └── page.ts
│ └── search/
│ ├── page.jay-html
│ └── page.ts
└── jay.init.ts # Service registration
How It Works: Under the Hood
Build/Dev Time
- Action Discovery: The dev-server/build scans
src/actions/for action definitions - Action Registration: Each action is registered with its name and handler
- Client Transform: When client code imports an action, it's transformed to a fetch call
Runtime Flow
┌─────────────────────────────────────────────────────────────────┐
│ Client │
│ │
│ const result = await addToCart({ productId: '123', qty: 1 }) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Generated client stub: │ │
│ │ POST /_jay/actions/cart.addToCart │ │
│ │ Body: { productId: '123', qty: 1 } │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Server │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Action Router: │ │
│ │ 1. Parse action name from URL │ │
│ │ 2. Look up handler in registry │ │
│ │ 3. Resolve services from service registry │ │
│ │ 4. Call handler(input, ...services) │ │
│ │ 5. Serialize and return response │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ const handler = actionRegistry.get('cart.addToCart'); │
│ const cartService = getService(CART_SERVICE); │
│ const inventory = getService(INVENTORY_SERVICE); │
│ const result = await handler(input, cartService, inventory); │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client │
│ │
│ result = { success: true, cartItemCount: 3 } │
│ │
└─────────────────────────────────────────────────────────────────┘
Client Stub Generation
When building for client, action imports are transformed:
// Original (what developer writes)
import { addToCart } from '../actions/cart.actions';
// Transformed (what runs in browser)
import { createActionCaller } from '@jay-framework/stack-client-runtime';
const addToCart = createActionCaller<AddToCartInput, AddToCartOutput>('cart.addToCart');
The createActionCaller returns a function that:
- Serializes input to JSON
- POSTs to
/_jay/actions/{actionName} - Deserializes response
- Returns typed result
Implementation Plan
Phase 1: Core Infrastructure
Action Builder (
packages/jay-stack/full-stack-component)makeJayAction()functionJayActionBuilderclass with fluent APIJayActiontype with input/output inference
Action Registry (
packages/jay-stack/stack-server-runtime)registerAction(action)functiongetActionHandler(name)lookup- Integration with service registry
Action Router (
packages/jay-stack/dev-server)/_jay/actions/:actionNamePOST endpoint- Request parsing and validation
- Service injection and handler execution
Phase 2: Client Runtime
Action Caller (
packages/jay-stack/stack-client-runtime)createActionCaller<Input, Output>(name)function- Fetch wrapper with error handling
- Response type inference
Build Transform (
packages/compiler)- Detect action imports in client code
- Replace with
createActionCallercalls - Preserve type information
Phase 3: Developer Experience
Error Handling
ActionErrorclass with code and message- Network error handling
- Timeout configuration
Request Context (optional)
.withRequestContext()builder method- Access to headers, cookies
- Auth token extraction
Validation (optional)
.withValidation(zodSchema)method- Runtime input validation on server
Examples
Search Page
// src/actions/search.actions.ts
import { makeJayQuery } from '@jay-framework/fullstack-component';
import { PRODUCTS_DATABASE_SERVICE } from '../services';
// makeJayQuery = GET by default, with caching
export const searchProducts = makeJayQuery('products.search')
.withServices(PRODUCTS_DATABASE_SERVICE)
.withCaching({ maxAge: 60 })
.withHandler(async (input: { query: string; page?: number }, productsDb) => {
const results = await productsDb.search(input.query, {
page: input.page ?? 1,
limit: 20,
});
return {
products: results.items,
totalCount: results.total,
hasMore: results.hasMore,
};
});
// pages/search/page.ts
import { searchProducts } from '../../actions/search.actions';
function SearchPageConstructor(props, refs, viewState, carryForward) {
const [query, setQuery] = createSignal('');
const [results, setResults] = createSignal<Product[]>([]);
const [isSearching, setIsSearching] = createSignal(false);
refs.searchInput.oninput((e) => {
setQuery(e.target.value);
});
refs.searchButton.onclick(async () => {
if (!query()) return;
setIsSearching(true);
try {
const response = await searchProducts({ query: query() });
setResults(response.products);
} finally {
setIsSearching(false);
}
});
return {
render: () => ({
query,
results,
isSearching,
resultCount: () => results().length,
}),
};
}
Product Page with Add to Cart
// src/actions/cart.actions.ts
import { makeJayAction, ActionError } from '@jay-framework/fullstack-component';
import { CART_SERVICE, INVENTORY_SERVICE } from '../services';
// POST (default) for mutations
export const addToCart = makeJayAction('cart.addToCart')
.withServices(CART_SERVICE, INVENTORY_SERVICE)
.withHandler(async (input: { productId: string; quantity: number }, cartService, inventory) => {
const available = await inventory.getAvailableUnits(input.productId);
if (available < input.quantity) {
throw new ActionError('NOT_AVAILABLE', `Only ${available} units available`);
}
const cart = await cartService.addItem(input.productId, input.quantity);
return { cartItemCount: cart.items.length };
});
// pages/products/[slug]/page.ts
import { addToCart } from '../../../actions/cart.actions';
import { ActionError } from '@jay-framework/fullstack-component';
function ProductsPageConstructor(
props: Props<PageProps>,
refs: PageRefs,
viewState: Signals<PageFastViewState>,
carryForward: ProductCarryForward,
) {
const [quantity, setQuantity] = createSignal(1);
const [isAdding, setIsAdding] = createSignal(false);
const [addError, setAddError] = createSignal<string | null>(null);
refs.quantityInput.oninput((e) => {
setQuantity(parseInt(e.target.value, 10) || 1);
});
refs.addToCart.onclick(async () => {
setIsAdding(true);
setAddError(null);
try {
const result = await addToCart({
productId: carryForward.productId,
quantity: quantity(),
});
// Success! result.cartItemCount available
} catch (e) {
if (e instanceof ActionError) {
setAddError(e.message);
} else {
setAddError('Network error. Please try again.');
}
} finally {
setIsAdding(false);
}
});
return {
render: () => ({
quantity,
isAdding,
addError,
}),
};
}
Build Transform Design
Actions need different handling on server vs client:
- Server: Execute handler directly with service injection
- Client: Make HTTP request to
/_jay/actions/:actionName
The jay-stack compiler plugin handles this transformation.
Action Sources
Actions can come from two places:
| Source | Location | Example |
|---|---|---|
| Project actions | src/actions/*.ts |
src/actions/cart.actions.ts |
| Plugin actions | node_modules/@jay-plugin-*/actions.ts |
@jay-plugin-store/actions |
Both follow the same pattern - export JayAction instances created with makeJayAction/makeJayQuery.
Plugin Action Pattern
Plugins export actions alongside their components:
// @jay-plugin-store/lib/actions.ts
import { makeJayAction, makeJayQuery } from '@jay-framework/fullstack-component';
export const addToCart = makeJayAction('store.addToCart')
.withServices(STORE_SERVICE)
.withHandler(async (input, storeService) => {
return storeService.addToCart(input.productId, input.quantity);
});
export const searchProducts = makeJayQuery('store.search')
.withServices(STORE_SERVICE)
.withCaching({ maxAge: 60 })
.withHandler(async (input, storeService) => {
return storeService.search(input.query);
});
// @jay-plugin-store/lib/index.ts
export * from './actions';
export * from './components';
Transformation Location
The transformation happens in @jay-framework/compiler-jay-stack (the jay-stack Vite/Rollup plugin).
Why this plugin?
- Already handles jay-stack specific transforms (client/server code splitting)
- Has access to build context (client vs SSR)
- Runs for both project code and plugin dependencies
Detection: What to Transform
The transform identifies action imports by:
Import source detection - Imports from:
src/actions/*(project actions)@jay-plugin-*/actions(plugin actions)- Any module exporting
JayActiontypes
Runtime marker detection - The
JayActionhas_brand: 'JayAction'markerExport analysis - Scan exports for
JayActiontype instances
Transform Rules
| Build Target | Import Statement | Transformation |
|---|---|---|
| SSR/Server | import { addToCart } from './actions/cart.actions' |
No change - use original handler |
| Client | import { addToCart } from './actions/cart.actions' |
Replace with createActionCaller |
Client Transform
// BEFORE (source code)
import { addToCart, searchProducts } from '../actions/cart.actions';
const result = await addToCart({ productId: '123', quantity: 1 });
// AFTER (client build)
import { createActionCaller } from '@jay-framework/stack-client-runtime';
const addToCart = createActionCaller('cart.addToCart', 'POST');
const searchProducts = createActionCaller('products.search', 'GET');
const result = await addToCart({ productId: '123', quantity: 1 });
Implementation Approach
Option A: Static Analysis (Recommended)
Parse imports, identify action exports, replace with createActionCaller:
// In compiler-jay-stack plugin
function transformActionImports(code: string, id: string, isSSR: boolean) {
if (isSSR) return code; // No transform for server
// Parse and find action imports
const actionImports = findActionImports(code);
for (const imp of actionImports) {
// Load the action module to get metadata
const actionModule = await loadActionModule(imp.source);
// Replace import with createActionCaller calls
code = replaceImportWithCallers(code, imp, actionModule);
}
return code;
}
Pros:
- Clean separation of concerns
- No runtime overhead
- Works with tree-shaking
Cons:
- Requires parsing action modules at build time
- Need to track action metadata (name, method)
Option B: Virtual Module Pattern
Create virtual modules that re-export actions appropriately:
// For client builds, resolve 'src/actions/cart.actions' to:
// 'virtual:jay-actions/cart.actions'
// Virtual module content:
import { createActionCaller } from '@jay-framework/stack-client-runtime';
export const addToCart = createActionCaller('cart.addToCart', 'POST');
export const searchProducts = createActionCaller('products.search', 'GET');
Pros:
- Cleaner transform (no code rewriting)
- Easy to debug (can inspect virtual module)
Cons:
- More complex Vite plugin setup
- Need to generate virtual modules dynamically
Recommended: Option A with Metadata Extraction
- At build start: Scan action files, extract metadata (name, method) from each action
- During transform: Replace client imports using cached metadata
- For plugins: Scan plugin
plugin.yamlforactionsarray
Action Discovery
Project Actions
Scan src/actions/ for files matching *.actions.ts:
async function discoverProjectActions(projectRoot: string) {
const actionsDir = path.join(projectRoot, 'src/actions');
const files = await glob('**/*.actions.ts', { cwd: actionsDir });
const actions = new Map<string, ActionMetadata>();
for (const file of files) {
const module = await parseActionModule(path.join(actionsDir, file));
for (const action of module.exports) {
actions.set(action.name, {
name: action.name,
method: action.method,
importPath: `./src/actions/${file}`,
exportName: action.exportName,
});
}
}
return actions;
}
Plugin Actions
Plugins declare action exports in plugin.yaml:
# @jay-plugin-store/plugin.yaml
name: '@jay-plugin-store'
version: '1.0.0'
actions:
- addToCart
- removeFromCart
- getCart
- searchProducts
These are named exports from the plugin's backend bundle. The compiler:
- Server build: Imports these exports from the plugin backend bundle, auto-registers them
- Client build: Transforms imports of these exports to
createActionCaller()calls
// Plugin backend bundle exports the actions
// @jay-plugin-store/dist/backend.js
export { addToCart, removeFromCart, getCart } from './actions/cart.actions';
export { searchProducts } from './actions/search.actions';
Registration Flow
Actions are auto-registered - no manual registration needed:
┌─────────────────────────────────────────────────────────────────┐
│ Build Time │
│ │
│ 1. Discover actions: │
│ - Project: scan src/actions/*.actions.ts │
│ - Plugins: read plugin.yaml → actions array │
│ 2. Extract metadata (name, method, services) │
│ 3. Generate registration code (injected into server entry) │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Server Startup (auto-generated) │
│ │
│ 1. Import discovered action modules │
│ 2. Auto-register all actions │
│ 3. Actions available at /_jay/actions/* │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client Build │
│ │
│ 1. Action imports transformed → createActionCaller() │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client Runtime │
│ │
│ 1. Call action → HTTP request to /_jay/actions/:name │
│ 2. Server executes handler, returns result │
│ │
└─────────────────────────────────────────────────────────────────┘
Auto-Registration (Default Behavior)
Actions are auto-registered via generated code injected into the server entry:
Discovery sources:
- Project actions: Scan
src/actions/*.actions.tsforJayActionexports - Plugin actions: Named exports listed in
plugin.yaml→actionsarray
Generated registration code (injected into server entry):
// Auto-generated by compiler
import { registerAction } from '@jay-framework/stack-server-runtime';
// Project actions
import * as cartActions from './src/actions/cart.actions';
import * as searchActions from './src/actions/search.actions';
// Plugin actions (named exports from plugin.yaml)
import { addToCart, getCart } from '@jay-plugin-store/backend';
// Auto-register all
[cartActions, searchActions].forEach((mod) =>
Object.values(mod).filter(isJayAction).forEach(registerAction),
);
registerAction(addToCart);
registerAction(getCart);
Manual Registration API (for dynamic or conditional actions):
// jay.init.ts - Optional manual registration
import { registerAction } from '@jay-framework/stack-server-runtime';
import { customAction } from './custom/my-action';
export async function onInit() {
// Auto-registration already happened
// Manually register additional actions if needed
registerAction(customAction);
}
Open Questions (Compiler)
Plugin action convention?
- Option A:
package.jsonjay.actionsfield - Option B: Conventional
/actionsexport - Option C:
plugin.yamlactionsarray - Answer: Use
plugin.yamlwith named exports. Theactionsarray lists export names from the plugin's backend bundle. Server imports and registers these exports; client build transforms them tocreateActionCaller()calls.
- Option A:
Auto-registration?
- Manual: Explicit in
jay.init.ts(current) - Auto: Compiler generates registration code
- Answer: Auto-registration is the default. Compiler generates registration code injected into server entry. Scans project
src/actions/and pluginplugin.yamlaction exports. ProvideregisterAction()API for optional manual registration of additional actions.
- Manual: Explicit in
Development mode?
- Should dev server re-scan actions on file change?
- Answer: Yes, watch
src/actions/and plugin changes
Type generation?
- Generate
.d.tsfor client callers? - Answer: Not needed if transform preserves types
- Generate
Trade-offs Accepted
- Build-time transform required - Client imports need to be transformed to HTTP callers
- Separate files - Actions live in
src/actions/not alongside pages (intentional for reuse) - Explicit action names - Must provide unique action name string (enables stable endpoints)
Open Questions
How to handle authentication?
- Option A: Always inject request context
- Option B: Opt-in via
.withRequestContext() - Answer: Defer for now. Will need a default strategy + custom strategy injection. Requires handshake between page loading and action calls. Address in future iteration.
Action naming convention?
- Current:
'domain.action'(e.g.,'cart.addToCart') - Alternative: Auto-generate from file path + export name
- Answer: Use explicit names. Less compiler transformation, more predictable endpoints.
- Current:
Error handling patterns?
- Return error in output type (current examples)
- Throw
ActionError - Or support both?
- Answer: Use
throw new ActionError(code, message). Clear error messages, consistent pattern. Client receives structured error response.
Batching/deduplication?
- Should identical concurrent calls be deduplicated?
- Useful for search debouncing
- Answer: Not needed for now. Keep it simple.
HTTP method and caching?
- Should actions support GET method for cacheable queries?
- Answer: Yes. Add
.withMethod('GET' | 'POST')(default: POST) and.withCaching()options. GET enables browser/CDN caching for read-only operations.
Is it still an "action" if we allow GET?
- GET requests are typically queries, not actions/mutations
- Answer: Provide both builders:
makeJayAction(name)- defaults to POST, for mutationsmakeJayQuery(name)- defaults to GET, for reads
- Both can override method with
.withMethod()if needed. Same runtime type (JayAction<I, O>), just different defaults. Clear intent in code.
Implementation Results
Phase 1: Core Infrastructure ✅
Action Builder API (@jay-framework/fullstack-component)
makeJayAction(name)- POST default, for mutationsmakeJayQuery(name)- GET default, for reads- Builder chain:
.withServices(),.withMethod(),.withCaching(),.withHandler() ActionErrorclass for business logic errors- 14 tests passing
Action Registry (@jay-framework/stack-server-runtime)
ActionRegistryclass (not global singleton, for testability)- Methods:
register(),execute(),get(),has(),getNames(),clear(),getCacheHeaders() - Default instance exported as
actionRegistry - Legacy function exports for backwards compatibility
- 16 tests passing
Action Router (@jay-framework/dev-server)
- Endpoint:
/_jay/actions/:actionName - Validates HTTP method against action definition
- GET: parses input from query string (simple params or
_inputJSON) - POST/PUT/PATCH/DELETE: parses input from request body
- Returns 422 for ActionError, 500 for other errors
- Sets Cache-Control headers for GET with caching
- 10 tests passing
Phase 2: Client Runtime ✅
Action Caller (@jay-framework/stack-client-runtime)
createActionCaller(actionName, method)- creates client-side action callersetActionCallerOptions({ baseUrl, headers, timeout })- global configActionErrorclass (client-side)- Handles timeouts, network errors, business logic errors
- 19 tests passing
Phase 3: Compiler Transform ✅
Action Import Transform (@jay-framework/compiler-jay-stack)
transformActionImports()- transforms client action imports tocreateActionCaller()extractActionsFromSource()- parses action modules to extract metadataisActionImport(),isActionModule()- detection helpers- Integrated into
jayStackCompiler()plugin array - 18 tests passing
Transform behavior:
- Server builds: No transform (actions execute directly)
- Client builds: Replace
import { addToCart } from './actions/cart.actions'withconst addToCart = createActionCaller('cart.addToCart', 'POST')
Phase 4: Auto-Registration ✅
Action Discovery (@jay-framework/stack-server-runtime)
discoverAndRegisterActions()- scanssrc/actions/*.actions.tsand registers actionsdiscoverPluginActions()- readsplugin.yamlfor plugin action declarations- Recursive directory scanning for nested action files
- Integrated into
ServiceLifecycleManager.initialize() - 6 tests passing
Dev-Server Integration
- Actions auto-discovered after
jay.init.tsruns - Actions cleared and re-discovered on hot reload
- No manual
registerAction()calls needed in user code
Phase 5: Plugin Actions ✅
Plugin Action Discovery (@jay-framework/stack-server-runtime)
discoverAllPluginActions()- scanssrc/plugins/for plugins with actionsdiscoverPluginActions()- readsplugin.yaml, imports and registers declared actions- Integrated into
ServiceLifecycleManager.initialize() - 5 new tests for plugin discovery
Plugin Manifest Extension (@jay-framework/compiler-shared)
- Added
actions?: string[]field toPluginManifestinterface - Actions are named exports from the plugin module
Example: product-rating plugin
- Added
submitRatingandgetRatingsactions - Demonstrates plugin actions pattern
Phase 6: Plugin Client Build ✅
Plugin Build Configuration
- Plugins use
jayStackCompilerin their vite.config.ts (already handles action transform) - Client build (
isSsrBuild = false) transforms action imports tocreateActionCaller() - Plugin must add
@jay-framework/stack-client-runtimeto externals
Example vite.config.ts for plugin with actions:
export default defineConfig(({ isSsrBuild }) => ({
plugins: [...jayStackCompiler(jayOptions)],
build: {
ssr: isSsrBuild,
rollupOptions: {
external: [
'@jay-framework/fullstack-component',
'@jay-framework/stack-client-runtime', // Required for action callers
// ... other externals
],
},
},
}));
Test Coverage
- Added test for plugin component importing actions from same plugin
- 19 tests passing in compiler-jay-stack
Learnings and Deviations
Design Decisions Made During Implementation
ActionRegistry as Class (not singleton)
- Original design implied global registry
- Changed to class with default instance for better testability
- Each test can create isolated registry instances
Plugin YAML Parsing Reuse
- Initially created custom
parseSimpleYaml()in action-discovery - Refactored to use
loadPluginManifest()from@jay-framework/compiler-shared - Eliminates duplication, uses proper YAML library
- Initially created custom
Handler Simplification
- Original design had
withHandlerreturn a placeholder function - Simplified to directly call the handler when action is invoked server-side
- Client builds transform to HTTP calls anyway
- Original design had
Test Pattern for Discriminated Unions
- TypeScript struggled with type narrowing for
ActionExecutionResult - Used
expect(result).toMatchObject()pattern instead of property access - Provides better error messages and type inference
- TypeScript struggled with type narrowing for
Files Structure
Key implementation files:
full-stack-component/lib/jay-action-builder.ts- Builder APIstack-server-runtime/lib/action-registry.ts- Server registrystack-server-runtime/lib/action-discovery.ts- Auto-registrationdev-server/lib/action-router.ts- HTTP endpointstack-client-runtime/lib/action-caller.ts- Client callercompiler-jay-stack/lib/transform-action-imports.ts- Build transformcompiler-jay-stack/lib/index.ts- Vite plugin with resolveId + load hooks
Plugin Build Transform Fix
Problem: When building a plugin package, the transform hook runs AFTER Rollup has resolved and inlined internal imports. This means action imports like import { submitMood } from './mood-actions' get bundled before the transform can replace them with createActionCaller.
Solution: Use resolveId + load hooks instead of transform:
resolveId- Intercepts action module imports BEFORE bundling- Checks
isActionImport(source)to identify action modules - Returns a virtual module ID:
\0jay-action:${actualPath}
- Checks
load- Generates virtual module content for the virtual ID- Reads actual action file and extracts metadata via
extractActionsFromSource() - Returns code with
createActionCallerexports instead of handlers
- Reads actual action file and extracts metadata via
Additional fixes required:
TypeScript bridge - Direct
import * as ts from 'typescript'fails in ESM bundles. Changed to use@jay-framework/typescript-bridgewhich loads TypeScript viacreateRequire.Hyphen pattern support - Updated
isActionImport()to match both:cart.actions.ts(dot pattern)mood-actions.ts(hyphen pattern)
SSR detection - Used IIFE with closure variable to track
isSSRBuildacross plugin lifecycle hooks, sinceconfigResolvedandresolveIddon't sharethiscontext.
Result:
| Bundle | File Size | Actions |
|---|---|---|
| Client | 2.23 kB | createActionCaller() HTTP calls |
| Server | 2.65 kB | makeJayAction().withHandler() |
Dev Server Action Discovery Fix
Problem: Action discovery used native import() which cannot load TypeScript files:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for cart.actions.ts
Solution: Pass Vite server to discovery functions and use ssrLoadModule:
// action-discovery.ts
export interface ViteSSRLoader {
ssrLoadModule: (url: string) => Promise<Record<string, any>>;
}
export interface ActionDiscoveryOptions {
// ...
viteServer?: ViteSSRLoader;
}
// In discovery function:
if (viteServer) {
module = await viteServer.ssrLoadModule(filePath);
} else {
module = await import(filePath); // Production: pre-compiled .js
}
Additional fix for module paths without extensions:
// Handle paths like "./product-rating" without .ts or .js
if (!fs.existsSync(modulePath)) {
const tsPath = modulePath + '.ts';
const jsPath = modulePath + '.js';
if (fs.existsSync(tsPath)) modulePath = tsPath;
else if (fs.existsSync(jsPath)) modulePath = jsPath;
}
NPM Package Plugin Action Discovery
Problem: discoverAllPluginActions only scanned local plugins in src/plugins/, not npm package dependencies like example-jay-mood-tracker-plugin.
Solution: Added discoverNpmPluginActions() to scan project dependencies:
- Read
package.jsonfor dependencies - For each dependency, try to resolve
{package}/plugin.yaml - If plugin.yaml exists and has
actionsarray, import and register them
function tryResolvePluginYaml(packageName: string, projectRoot: string): string | null {
try {
return require.resolve(`${packageName}/plugin.yaml`, {
paths: [projectRoot],
});
} catch {
return null; // Package doesn't export plugin.yaml
}
}
Result: All action types now discovered:
[Actions] Found 2 action file(s) # Project actions
[Actions] Registered: cart.addToCart
[Actions] Plugin "product-rating" declares actions # Local plugins
[Actions] Registered plugin action: productRating.submit
[Actions] NPM plugin "example-jay-mood-tracker-plugin" declares actions # NPM packages
[Actions] Registered NPM plugin action: moodTracker.submitMood
[Actions] Auto-registered 13 action(s) total
Implementation Complete
All phases of server actions are now implemented:
- ✅ Action Builder API
- ✅ Action Registry
- ✅ Action Router
- ✅ Client Action Caller
- ✅ Compiler Transform
- ✅ Auto-Registration (project actions)
- ✅ Plugin Actions (local src/plugins/)
- ✅ NPM Plugin Actions (package dependencies)
- ✅ Plugin Client Build (with resolveId + load pattern)
- ✅ Vite SSR Loading (for TypeScript in dev mode)
- ✅ Backend Action Execution (runAction helper)
Backend Action Execution Fix
Problem: When calling an action directly from backend code (e.g., render phases), services are not injected. The action callable passes an empty array as services:
// jay-action-builder.ts line 186
(input: I): Promise<O> => handler(input, ...([] as unknown as Services)),
This is by design for client-side code (transformed to HTTP calls), but breaks when backend code calls actions directly:
// In renderFastChanging
const result = await searchProducts({ query: '' }); // ❌ Services not injected
Solution: Added runAction(action, input) helper to @jay-framework/stack-server-runtime:
import { runAction } from '@jay-framework/stack-server-runtime';
import { searchProducts } from '../actions/stores-actions';
// In renderFastChanging
const result = await runAction(searchProducts, { query: '' }); // ✅ Services injected
Implementation:
// action-registry.ts
export async function runAction<I, O>(
action: JayAction<I, O> & JayActionDefinition<I, O, any[]>,
input: I,
): Promise<O> {
const services = resolveServices(action.services);
return action.handler(input, ...services);
}
Why not modify the action callable?
fullstack-componentcan't import fromstack-server-runtime(circular dependency)- Explicit
runActionmakes it clear services are being resolved - Matches the pattern mentioned in the original comment on line 183-184
Tests: 4 new tests added (66 total passing)
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.