Jay Stack Services
Jay Stack Services
Written for AI agents. See Log Methodology Note below for details.
The Problem
Jay Stack pages need access to server-side singletons like database connections, API clients, configuration services, and other infrastructure services. Currently, the serverContexts array in the jay-stack-builder is used to pass dependencies to page rendering functions (loadParams, slowlyRender, fastRender), but:
- The naming is confusing - "context" implies hierarchical parent-child relationships (like client contexts), but server dependencies are actually global singletons
- There's no clear pattern for initializing and providing these services
- The dev-server has no mechanism to load service definitions on startup or reload them during development
The Solution: Services Pattern
We introduce a Services pattern for server-side dependency injection, distinct from the hierarchical client-side Context pattern.
Key Differences from Client Contexts
| Aspect | Client Contexts | Server Services |
|---|---|---|
| Scope | Hierarchical (parent→child) | Flat (global singletons) |
| Lifecycle | Component tree lifetime | Application/request lifetime |
| Updates | Reactive (can trigger re-renders) | Static per request |
| Runtime | Browser | Server (Node.js) |
Naming Convention
We propose renaming to clarify intent:
Current naming:
serverContexts- confusing, implies hierarchyclientContexts- redundant "client" prefix
Proposed naming:
services(orserverServicesif clarity needed) - server-side singletonscontexts- client-side hierarchical contexts
Alternative considered: serverDependencies, but "services" is more idiomatic.
Implementation
1. Service Marker Pattern
Similar to client ContextMarker<T>, we create a ServiceMarker<T> in the stack-server-runtime package:
// In @jay-framework/stack-server-runtime
export interface ServiceMarker<ServiceType> {}
export function createJayService<ServiceType = unknown>(name?: string): ServiceMarker<ServiceType> {
return Symbol(name);
}
Benefits:
- Optional
nameparameter for better error messages - Symbol description helps identify which service is missing
- Type-safe marker carries type information
Key differences from ContextMarker:
- No hierarchical lookup needed (flat structure)
- Services are registered globally, not in a stack
- Services are initialized once at startup
2. Service Registry & Lifecycle Hooks
The stack-server-runtime provides a global service registry and lifecycle management:
// Service registry
const serviceRegistry = new Map<symbol, any>();
export function registerService<ServiceType>(
marker: ServiceMarker<ServiceType>,
service: ServiceType,
): void {
serviceRegistry.set(marker as symbol, service);
}
export function getService<ServiceType>(marker: ServiceMarker<ServiceType>): ServiceType {
const service = serviceRegistry.get(marker as symbol);
if (service === undefined) {
const symbolKey = marker as symbol;
const serviceName = symbolKey.description || 'Unknown service';
throw new Error(
`Service '${serviceName}' not found. Did you register it in jay.init.ts?\n` +
`Make sure to call: registerService(${serviceName.toUpperCase()}_SERVICE, ...)`,
);
}
return service;
}
export function clearServiceRegistry(): void {
serviceRegistry.clear();
}
// Lifecycle hooks
type InitCallback = () => void | Promise<void>;
type ShutdownCallback = () => void | Promise<void>;
const initCallbacks: InitCallback[] = [];
const shutdownCallbacks: ShutdownCallback[] = [];
export function onInit(callback: InitCallback): void {
initCallbacks.push(callback);
}
export function onShutdown(callback: ShutdownCallback): void {
shutdownCallbacks.push(callback);
}
// Internal APIs for dev-server to call
export async function runInitCallbacks(): Promise<void> {
for (const callback of initCallbacks) {
await callback();
}
}
export async function runShutdownCallbacks(): Promise<void> {
// Run in reverse order (LIFO - last registered, first shut down)
for (let i = shutdownCallbacks.length - 1; i >= 0; i--) {
await shutdownCallbacks[i]();
}
}
export function clearLifecycleCallbacks(): void {
initCallbacks.length = 0;
shutdownCallbacks.length = 0;
}
3. Init File Pattern - Lifecycle Hooks
Projects can create a src/jay.init.ts (or jay.init.js) file that registers lifecycle hooks.
The API uses a hook-based pattern where you call onInit() and onShutdown() at the top level:
// src/jay.init.ts
import {
onInit,
onShutdown,
registerService,
getService,
} from '@jay-framework/stack-server-runtime';
import { DATABASE_SERVICE, INVENTORY_SERVICE } from './services';
onInit(async () => {
console.log('Initializing services...');
const db = await connectToDatabase(process.env.DATABASE_URL);
registerService(DATABASE_SERVICE, db);
const inventory = new InventoryService(db);
registerService(INVENTORY_SERVICE, inventory);
console.log('Services initialized successfully');
});
onShutdown(async () => {
console.log('Shutting down services...');
// Retrieve services for cleanup
const inventory = getService(INVENTORY_SERVICE);
const db = getService(DATABASE_SERVICE);
// Clean up in reverse order of initialization
inventory?.dispose();
await db?.close();
console.log('Services shut down successfully');
});
Benefits of hook pattern:
- Declarative - Clear intent, reads top-to-bottom
- Simple - No exports needed, just call the hooks
- No module state - Use
getService()to retrieve services instead of file-level variables - Flexible - Both hooks are optional (omit
onShutdownif no cleanup needed) - Familiar - Follows modern JavaScript patterns (similar to React hooks, Vitest setup files)
- Composable - Can call hooks multiple times, they'll run in order
How it works:
- The dev-server imports
jay.init.ts - The module executes at import time
- Calling
onInit()registers the callback - Calling
onShutdown()registers the cleanup callback - Dev-server invokes registered callbacks at appropriate times
The dev-server package will:
- Look for
jay.init.tson startup - Import the file (triggers hook registration)
- Execute all registered
onInitcallbacks - On shutdown/reload, execute all registered
onShutdowncallbacks
4. Builder API Update
Update jay-stack-builder.ts to rename serverContexts → services:
// Before
withServerContext<NewServerContexts extends Array<any>>(
...contextMarkers: ContextMarkers<NewServerContexts>
)
// After
withServices<NewServices extends Array<any>>(
...serviceMarkers: ServiceMarkers<NewServices>
)
And rename clientContexts → contexts:
// Before
withClientContext<NewClientContexts extends Array<any>>(
...contextMarkers: ContextMarkers<NewClientContexts>
)
// After
withContexts<NewContexts extends Array<any>>(
...contextMarkers: ContextMarkers<NewContexts>
)
5. Complete Usage Example
// services/database.service.ts
import { createJayService } from '@jay-framework/stack-server-runtime';
import { Pool } from 'pg';
export interface DatabaseService {
query<T>(sql: string, params?: any[]): Promise<T[]>;
close(): Promise<void>;
}
export const DATABASE_SERVICE = createJayService<DatabaseService>('DatabaseService');
export function createDatabaseService(connectionString: string): DatabaseService {
const pool = new Pool({ connectionString });
return {
async query<T>(sql: string, params?: any[]): Promise<T[]> {
const result = await pool.query(sql, params);
return result.rows;
},
async close() {
await pool.end();
},
};
}
// services/inventory.service.ts
import { createJayService } from '@jay-framework/stack-server-runtime';
import type { DatabaseService } from './database.service';
export interface InventoryService {
getStock(productId: string): Promise<number>;
dispose(): void;
}
export const INVENTORY_SERVICE = createJayService<InventoryService>('InventoryService');
export function createInventoryService(db: DatabaseService): InventoryService {
return {
async getStock(productId: string) {
const [result] = await db.query<{ stock: number }>(
'SELECT stock FROM inventory WHERE product_id = $1',
[productId],
);
return result?.stock ?? 0;
},
dispose() {
// Clean up any resources if needed
},
};
}
// src/jay.init.ts (project root)
import {
onInit,
onShutdown,
registerService,
getService,
} from '@jay-framework/stack-server-runtime';
import { DATABASE_SERVICE, createDatabaseService } from './services/database.service';
import { INVENTORY_SERVICE, createInventoryService } from './services/inventory.service';
onInit(async () => {
console.log('Initializing services...');
const db = createDatabaseService(process.env.DATABASE_URL!);
registerService(DATABASE_SERVICE, db);
const inventory = createInventoryService(db);
registerService(INVENTORY_SERVICE, inventory);
console.log('Services initialized successfully');
});
onShutdown(async () => {
console.log('Shutting down services...');
// Retrieve services for cleanup (in reverse order)
const inventory = getService(INVENTORY_SERVICE);
const db = getService(DATABASE_SERVICE);
// Clean up
inventory?.dispose();
await db?.close();
console.log('Services shut down successfully');
});
// pages/product/page.ts
import { DATABASE_SERVICE, INVENTORY_SERVICE } from '../../services';
export const ProductPage = makeJayStackComponent<typeof render>()
.withProps<{ productId: string }>()
.withServices(DATABASE_SERVICE, INVENTORY_SERVICE)
.withSlowlyRender(async (props, db, inventory) => {
const [product] = await db.query('SELECT * FROM products WHERE id = $1', [props.productId]);
const stock = await inventory.getStock(props.productId);
return partialRender({ productName: product.name, inStock: stock > 0 }, { stock });
})
.withInteractive(ProductPageComponent);
Dev-Server Integration
The dev-server package (packages/jay-stack/dev-server) manages the full service lifecycle:
Startup Flow
- Find init file - Look for
src/jay.init.ts(or.js) - Import module - Import the file (this registers hooks via
onInit/onShutdowncalls) - Run init callbacks - Execute all registered
onInitcallbacks in order - Ready state - Server is ready to handle requests
Hot Reload Flow (Development Mode)
When jay.init.ts or any service file changes:
- Detect change - File watcher triggers on save
- Graceful shutdown - Call
shutdown()if exported- Wait up to 5 seconds for graceful shutdown
- Force kill remaining connections if timeout exceeded
- Clear registry - Clear the service registry
- Clear module cache - Delete module from Node.js require cache
- Reload module - Re-import the updated init file
- Re-initialize - Call
initialize()again - Notify clients - Trigger browser refresh via WebSocket
// Pseudo-code for hot reload in dev-server
import {
runShutdownCallbacks,
clearLifecycleCallbacks,
clearServiceRegistry,
runInitCallbacks,
} from '@jay-framework/stack-server-runtime';
async function reloadServices() {
// Step 1: Shutdown existing services
try {
await Promise.race([runShutdownCallbacks(), timeout(5000)]);
} catch (error) {
console.warn('Service shutdown timed out or failed:', error);
}
// Step 2: Clear all caches
clearLifecycleCallbacks(); // Clear registered hooks
clearServiceRegistry(); // Clear registered services
delete require.cache[require.resolve('./jay.init.ts')];
// Step 3: Reload and initialize
await import('./jay.init.ts'); // Re-registers hooks
await runInitCallbacks(); // Execute new init hooks
// Step 4: Notify connected clients
broadcastReload();
}
Production Flow
In production (no hot reload):
- Startup - Initialize services once
- Serve - Handle requests
- Shutdown - On SIGTERM/SIGINT, call
shutdown()before exiting
// Graceful shutdown in production
import { runShutdownCallbacks } from '@jay-framework/stack-server-runtime';
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
await runShutdownCallbacks();
process.exit(0);
});
Benefits
- Clear naming - "services" clearly indicates server singletons, "contexts" for client hierarchy
- Type safety - ServiceMarker provides compile-time type checking
- Proper lifecycle - Initialize and shutdown hooks ensure clean resource management
- Hot reload - Services can be gracefully shut down and reloaded during development
- Testability - Services can be mocked by registering test implementations
- Separation of concerns - Infrastructure setup separate from page logic
- Graceful shutdown - Production servers can clean up resources properly on termination
Migration Path
This is a breaking change but straightforward to migrate:
- Rename
withServerContext→withServices - Rename
withClientContext→withContexts - Create
jay.init.tsusingonInit()andonShutdown()hooks - Update imports from
createJayContext→createJayServicefor server dependencies
Example migration:
Before:
// Old pattern - no lifecycle management
import { DATABASE } from './database';
// Global initialization happens at module load
After:
// New pattern - explicit lifecycle
import {
onInit,
onShutdown,
registerService,
getService,
} from '@jay-framework/stack-server-runtime';
import { DATABASE_SERVICE, createDatabase } from './database';
onInit(async () => {
const db = await createDatabase();
registerService(DATABASE_SERVICE, db);
});
onShutdown(async () => {
const db = getService(DATABASE_SERVICE);
await db?.close();
});
API Design Considerations
Why Hook-Based Pattern?
We chose onInit(callback) and onShutdown(callback) over export-based patterns:
Advantages:
- Declarative - Clear intent without ceremony
- No exports needed - Just call functions at top level
- Composable - Can split initialization across multiple calls/files
- Familiar pattern - Similar to Vitest's
beforeAll/afterAll, React hooks - Order control - Callbacks run in registration order; shutdown runs in reverse
Example of composability:
// src/jay.init.ts
import './services/database.init'; // Registers its own hooks
import './services/cache.init'; // Registers its own hooks
import './services/queue.init'; // Registers its own hooks
// Each service file can manage its own lifecycle
// services/database.init.ts
import {
onInit,
onShutdown,
registerService,
getService,
} from '@jay-framework/stack-server-runtime';
import { DATABASE_SERVICE, createDatabaseService } from './database.service';
onInit(async () => {
const db = await createDatabaseService(process.env.DATABASE_URL);
registerService(DATABASE_SERVICE, db);
});
onShutdown(async () => {
const db = getService(DATABASE_SERVICE);
await db?.close();
});
Alternative naming considered:
setupInit()/setupShutdown()- Too verboseuseInit()/useShutdown()- "use" implies return value (React convention)init()/shutdown()- Shorter, but less clear these are hooksonInit()/onShutdown()- Selected - Clear event handler pattern
Service Registry Design
The registry is intentionally simple - a Map keyed by symbols:
Why not a DI container?
- Keeps it simple - no complex resolution algorithms
- Explicit dependencies - services are passed directly to render functions
- Type-safe - TypeScript validates service types at compile time
- No magic - developers see exactly what's injected
Why symbols as keys?
- Type safety -
ServiceMarker<T>carries type information - No string collisions - symbols are guaranteed unique
- Same pattern as client contexts - consistent API
Future Enhancements
- Lifecycle hooks - Additional hooks like
beforeReload,afterReload,healthCheck - Scoped services - Services could have request scope (new instance per request)
- Service dependencies - Services could declare dependencies on other services
- Lazy initialization - Services could be initialized on first use
- Service composition - Higher-order services that wrap other services
- Metrics and monitoring - Built-in observability for service health and performance
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.