Extend Scripts
Learn how to extend Zodula applications using extend scripts. Use `.on()` to extend doctype events and `bxo` to extend the HTTP server with custom routes and middleware.
Overview
Extend scripts allow you to customize and enhance your Zodula application without modifying core code. You can:
- Extend doctype events - Listen to and handle doctype lifecycle events (save, update, delete, etc.)
- Extend HTTP server - Add custom routes, middleware, and API endpoints using
bxo
Quick Start
Create an extend script in your app's scripts directory:
export default $extend((ctx) => {
// Extend doctype events
ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
console.log("User saved", doc);
});
// Extend HTTP server
ctx.bxo
.get("/new_path", async (ctx) => {
return "new_path";
});
});File Location
Extend scripts are typically placed in the scripts directory of your app:
apps/your-app-name/scripts/core/zodula__User.extend.tsThe naming convention is: {doctype_name}.extend.ts
Extending Doctype Events
The .on() function allows you to listen to and handle doctype lifecycle events.
Syntax
ctx.on(doctypeName, eventName, handler)Available Events
All event handlers receive the same parameter structure: { doc, old, input }
| Event Name | Description | Notes |
|---|---|---|
before_insert | Triggered before a new document is inserted | old is undefined for new documents |
after_insert | Triggered after a new document is inserted | old is undefined |
before_save | Triggered before a document is saved (both insert and update) | old is undefined for new documents |
after_save | Triggered after a document is saved (both insert and update) | old is undefined for new documents |
before_change | Triggered before a document is updated | old contains previous state |
after_change | Triggered after a document is updated | old contains previous state |
before_delete | Triggered before a document is deleted | old contains document being deleted |
after_delete | Triggered after a document is deleted | old contains deleted document |
before_submit | Triggered before a document is submitted | For submittable doctypes |
after_submit | Triggered after a document is submitted | For submittable doctypes |
before_cancel | Triggered before a submitted document is cancelled | For submittable doctypes |
after_cancel | Triggered after a submitted document is cancelled | For submittable doctypes |
before_save_after_submit | Triggered before saving a submitted document | For submittable doctypes |
after_save_after_submit | Triggered after saving a submitted document | For submittable doctypes |
For a complete list of all available events and their usage, see DocType Events.
Example: User Event Extension
export default $extend((ctx) => {
// Log when a user is created
ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
if (!old) {
console.log("New user created:", doc.name);
}
});
// Validate email before saving
ctx.on("zodula__User", "before_save", async ({ doc, input }) => {
if (doc.email && !doc.email.includes("@")) {
throw new Error("Invalid email address");
}
});
// Hash password from input before saving
ctx.on("zodula__User", "before_change", async ({ doc, old, input }) => {
if (input?.password) {
doc.password = await Bun.password.hash(input.password as string);
}
});
// Cleanup after user deletion
ctx.on("zodula__User", "after_delete", async ({ doc, old }) => {
console.log("User deleted:", old.name);
// Perform cleanup operations
});
});Event Handler Parameters
All event handlers receive the same context structure:
{
doc: Document, // Current state of the document (what will be saved)
old: Document, // Previous state of the document (undefined for new documents)
input: Document // Raw input data from the API request (may be undefined)
}Parameter Details:
doc: The current document state that will be saved to the database. You can modify this object to change what gets saved.old: The previous state of the document before changes. This isundefinedfor new documents (insert operations). Use this to compare changes or determine if a document is new.input: The raw input data from the API request. This contains only the fields that were sent in the request. Use this to see what the user actually submitted, which may differ fromdocif there's default value logic.
Extending HTTP Server
The bxo object provides methods to extend the HTTP server with custom routes and middleware. bxo is a lightweight HTTP framework - see the bxo npm package for complete documentation.
HTTP Methods
bxo supports all standard HTTP methods:
.get(path, handler)- Handle GET requests.post(path, handler)- Handle POST requests.put(path, handler)- Handle PUT requests.patch(path, handler)- Handle PATCH requests.delete(path, handler)- Handle DELETE requests
Example: Custom API Endpoint
export default $extend((ctx) => {
// GET endpoint
ctx.bxo.get("/api/custom/users", async (ctx) => {
const users = await $zodula
.doctype("zodula__User")
.select()
.where("active", "=", 1);
return users;
});
// POST endpoint
ctx.bxo.post("/api/custom/send-email", async (ctx) => {
const { to, subject, body } = await ctx.req.json();
// Send email logic
console.log("Sending email to:", to);
return { success: true, message: "Email sent" };
});
});Route Handlers
Route handlers receive a context object (ctx) with request information:
ctx.bxo.get("/api/data/:id", async (ctx) => {
const id = ctx.params.id; // URL parameters
const query = ctx.req.query; // Query string parameters
const body = await ctx.req.json(); // Request body (for POST/PUT)
return { id, query, body };
});Middleware Support
bxo supports middleware for request preprocessing:
export default $extend((ctx) => {
// Middleware example
ctx.bxo.use(async (ctx, next) => {
// Pre-processing
console.log("Request:", ctx.req.method, ctx.req.url);
// Call next middleware or handler
await next();
// Post-processing
console.log("Response sent");
});
ctx.bxo.get("/api/protected", async (ctx) => {
// This route goes through the middleware above
return { message: "Protected route" };
});
});Complete Example
Here's a complete extend script combining both doctype events and HTTP server extensions:
export default $extend((ctx) => {
// Doctype event: Log user activity
ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
console.log("User saved:", doc.name);
// Create an activity log entry
await $zodula
.doctype("ActivityLog")
.insert({
user: doc.name,
action: old ? "User updated" : "User created",
timestamp: new Date().toISOString()
});
});
// HTTP endpoint: Get user statistics
ctx.bxo.get("/api/stats/users", async (ctx) => {
const totalUsers = await $zodula
.doctype("zodula__User")
.count();
const activeUsers = await $zodula
.doctype("zodula__User")
.count()
.where("active", "=", 1);
return {
total: totalUsers,
active: activeUsers,
inactive: totalUsers - activeUsers
};
});
// HTTP endpoint: Custom user search
ctx.bxo.post("/api/users/search", async (ctx) => {
const { query, limit = 10 } = await ctx.req.json();
const users = await $zodula
.doctype("zodula__User")
.select()
.where("name", "like", `%${query}%`)
.limit(limit);
return users;
});
});Best Practices
- Organize Scripts: Group related extensions in the same file or create separate files for different concerns
- Error Handling: Always handle errors in event handlers and route handlers
- Async Operations: Use
async/awaitfor database operations and other async tasks - Type Safety: Use TypeScript types from Zodula for doctypes and fields
- Performance: Be mindful of performance when adding event handlers, especially on frequently saved doctypes
- Logging: Log important operations for debugging and monitoring
Common Patterns
Email Notification on Document Save
ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
if (!old) {
// Send welcome email to new users
console.log(`Sending welcome email to ${doc.email}`);
// Email sending logic here
}
});Audit Trail
ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
await $zodula.doctype("AuditLog").insert({
doctype: "zodula__User",
document_name: doc.name,
action: old ? "Update" : "Insert",
old_value: old,
new_value: doc,
timestamp: new Date().toISOString()
});
});Custom Validation
ctx.on("zodula__User", "before_save", async ({ doc, input }) => {
// Validate business rules
if (doc.role === "Admin" && !doc.has_admin_permission) {
throw new Error("User cannot be admin without permission");
}
});Password Hashing from Input
ctx.on("zodula__User", "before_change", async ({ doc, old, input }) => {
// Hash password only if provided in input
if (input?.password) {
doc.password = await Bun.password.hash(input.password as string);
}
});Detecting Changes
ctx.on("zodula__User", "after_change", async ({ doc, old }) => {
// Only send notification if email changed
if (old && old.email !== doc.email) {
console.log(`User email changed from ${old.email} to ${doc.email}`);
// Send notification logic
}
});Next Steps
- Learn more about Doctypes - the core building blocks
- Explore Actions - custom API endpoints
- Check the bxo documentation for advanced HTTP server features
Fixtures
Fixtures are JSON files that contain sample data for your application. They help you set up initial data, test data, and seed your database with predefined records.
UI Scripts
Learn how to create UI scripts to customize form and list behavior in Zodula. UI scripts allow you to add client-side logic, calculations, validations, and UI interactions similar to Frappe's client scripts.
Zodula