Lifecycle hooks (transforms)
Custom JS that runs at well-defined points in a content node's lifecycle. Use these for: validation, computed fields, slug generation, content rewrites, audit logging, fan-out to external services.
The three hooks
| Hook | When | Use case |
|---|---|---|
onSave |
Before a content write commits | Validation, slug generation, computed fields, content normalization |
beforeRender |
Before a content node is rendered to HTML/PNG/etc. | Resolve relationships, expand references, inject computed data |
onPublish |
After a Release is deployed | Fan-out to external services (Buffer, Mailchimp), log events, send notifications |
Each hook runs in a sandboxed VM with a constrained API surface. Hooks are versioned (per-content-type and per-template), can be enabled/disabled per-environment, and execute under a 5-second timeout (default; configurable).
Hook code shape
// onSave example: generate a slug from the title
async function onSave({ type, id, fields, parentId }, ctx) {
if (!fields.slug && fields.title) {
fields.slug = fields.title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
}
return { fields };
}
The hook receives:
- First arg: input describing what's happening — content type, id, current fields, parentId, etc.
- Second arg (
ctx): a context with read-only utilities (graph queries, fetch, console)
Returning { fields } merges those fields into the write. Returning nothing leaves the input untouched.
onSave shape
{
type: string, // content type name, e.g. "article"
id: string, // full id, e.g. "article:my-post"
fields: Record<string, unknown>,
parentId: string | null, // for tree-structured content
}
Return: { fields } to apply changes, or nothing.
Errors thrown abort the write with a 400 response carrying the error message.
beforeRender shape
{
type: string,
id: string,
fields: Record<string, unknown>,
// Pre-resolved 1-hop relationships:
// fields[relName] = [...related content nodes]
template: string, // template name being used
}
Return: { fields } to inject additional context for the render.
onPublish shape
{
releaseId: string,
deploymentId: string,
envId: string,
siteId: string,
artifacts: Array<{ path, hash, size, sourceContentId }>,
validFrom: string,
}
Return value ignored (this hook is for side effects).
The sandbox
Hooks run in isolated-vm — a fresh V8 isolate per execution with no shared heap with the host. The implementation is in packages/core/src/crud/transform-runner.ts.
Available globals (curated)
input— the value the caller suppliedconsole.log/console.warn/console.error— captured toRunResult.logs; non-serializable args (functions, symbols) are silently droppedutils.slugify(s)/utils.iso(d?)/utils.now()utils.http.get|post|request(...)— outbound fetch, 10s timeout, 1MB response capgraph.query(cypher, params?)— read-only Cypher (when a graph is supplied)
NOT available — verified by isolation tests
process/process.env— undefinedrequire/import— undefinedmodule/exports/__dirname/__filename— undefinedBuffer— undefinedfs/child_process/os/ native modules — unreachableglobalThis.process(prototype-chain probe) — undefinedFunction("return process")()— returns the isolate's own (empty)process, not the host's
Resource limits
- Wall-clock timeout — default 5s, configurable via
RunOptions.timeoutMs - Memory cap — default 32 MB; isolate hard-aborts past this
- Output size —
RunResult.resultiscloneSafed (depth ≤ 8, ≤ 200 keys per object, ≤ 500 array items)
Security posture
Hostile JS in a transform CANNOT reach the host. The isolate has no shared heap, no Node intrinsics, and no path back to
process/require/fs. Verified by 23 unit tests including the classic prototype-chain escape probe.
What this still relies on:
isolated-vm's isolation guarantee (battle-tested; used by Cloudflare Workers, Deno's older runtime, etc.)- Memory cap actually being enforced by V8 (it is — a runaway allocator hits OOM and the isolate dies)
- Host-side capabilities (graph query, http) doing their own input validation — they run with full host privilege when the isolate calls them
What this doesn't protect against:
- A malicious admin who can rewrite
transform-runner.tsitself (they have full repo access by definition) - A malicious admin who exfils data through a graph query they're allowed to run anyway (the sandbox isn't the right defense — capability scoping is)
- Network-level attacks (the host's outbound
utils.httpis unconstrained beyond the per-request timeout)
Bottom line: the sandbox does its one job. Threats it doesn't address need different defenses (RBAC on transform creation, agent-scoped tokens, network egress filtering).
Authoring + management
Hooks are first-class graph entities (Transform nodes). Manage via:
- UI: the Transforms tab in the admin
- API:
/api/transforms— list, get, create, update, delete (see api.md) - Runtime: the
TransformServiceinpackages/core/src/crud/transform-service.ts
Per-type binding
Each transform binds to a content type and a hook lifecycle stage:
{
id: 'transform:slug-generator',
name: 'slug-generator',
description: 'Auto-generate slugs from titles',
boundType: 'article', // or '*' for all types
stage: 'onSave', // | 'beforeRender' | 'onPublish'
enabled: true,
code: '...', // the JS source
templateOverride: null, // for beforeRender — bind to specific template
timeoutMs: 5000,
envFilter: ['dev', 'staging'], // empty = all envs
}
Versioning
Like any other Versionable, transforms are bundled into Releases. A transform change rolls forward via the same Release/Deployment flow as content. Replay shows you exactly what hook code ran when.
Common patterns
Slug + canonical URL generation (onSave)
async function onSave({ type, fields }, ctx) {
if (!fields.slug && fields.title) {
fields.slug = fields.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 80);
}
if (!fields.canonicalUrl && fields.slug) {
const site = ctx.config.get('publicUrl');
fields.canonicalUrl = `${site}/${type}/${fields.slug}/`;
}
return { fields };
}
Resolve author bio for render (beforeRender)
async function beforeRender({ type, fields }, ctx) {
if (fields.author && fields.author.length > 0) {
const author = fields.author[0];
// Fetch additional data not in the relationship resolver
const r = await ctx.graph.query(
`MATCH (a:Author {id: $id}) RETURN a.bio AS bio, a.avatar AS avatar`,
{ id: author.id },
);
fields.authorBio = r.data[0]?.bio ?? '';
fields.authorAvatar = r.data[0]?.avatar ?? null;
}
return { fields };
}
Fan-out to Buffer + Slack on publish (onPublish)
async function onPublish({ releaseId, artifacts, envId }, ctx) {
if (envId !== 'prod') return; // only on production deploys
const newPosts = artifacts.filter(a => a.path.startsWith('/blog/'));
if (newPosts.length === 0) return;
// Notify Slack
await ctx.fetch('https://hooks.slack.com/...', {
method: 'POST',
body: JSON.stringify({
text: `📝 ${newPosts.length} new post${newPosts.length === 1 ? '' : 's'} live: ` +
newPosts.map(a => a.path).join(', '),
}),
});
// Schedule a Buffer post
for (const post of newPosts) {
await ctx.fetch('https://api.bufferapp.com/1/updates/create.json', {
method: 'POST',
body: JSON.stringify({
access_token: ctx.config.get('bufferToken'),
text: `New post: https://example.com${post.path}`,
profile_ids: ['...'],
}),
});
}
}
Validate content + abort on bad data (onSave)
async function onSave({ type, fields }) {
if (type === 'product' && fields.price != null && fields.price < 0) {
throw new Error('product.price cannot be negative');
}
if (fields.publishedAt && new Date(fields.publishedAt) < new Date()) {
throw new Error('publishedAt must be in the future when set');
}
}
Errors + observability
Errors
- Sync throws become 400 responses on the originating write
- Timeouts (default 5000ms) become a structured
transformErrorsentry on the build/save result; the operation still completes (best-effort) - Network failures in
ctx.fetchare caught and logged; up to the hook author to retry
Logs
ctx.log.info / .warn writes go to the same structured log stream as the rest of the server. Hooks emit component: 'transform' so you can filter:
# Tail transform errors
journalctl -u graphiquity-cms -o json | jq 'select(.component == "transform")'
Observability via Release facts
Every onSave and beforeRender execution stamps the Release with a transformExecutions count. onPublish failures attach as Finding entries on the Deployment.
See also
- Architecture: build pipeline — where
beforeRenderfires - API: transforms — CRUD for
Transformnodes - Features: Releases — how transforms get bundled