Sequentially chain multiple SvelteKit 5 form actions with deep-merged data propagation, type-safe results, reactive progress tracking, and automatic file-safety.
Designed for real multi-stage workflows like upload β parse β SEO β save β publish.
Live Demo:
π https://chain-enhance.michaelcuneo.com.au
This demo showcases a full multi-step content workflow using@michaelcuneo/chain-enhance β including file upload, markdown parsing,
SEO generation, database save simulation, and publish confirmation β
all chained seamlessly with real-time progress tracking.
Built with SvelteKit 5, TypeScript, and SST v3.
Deployed on AWS via CloudFront + Lambda.
npm i chain-enhance
# or
pnpm add chain-enhance
<script lang="ts">
import { chainEnhance, formChain } from 'chain-enhance';
import { goto } from '$app/navigation';
let formState = $state<'idle' | 'running' | 'complete' | 'error'>('idle');
let result: Record<string, any> = $state({});
const chained = chainEnhance(['markdown', 'seo', 'save', 'publish'], {
onStep: (step, data, index, total) => {
console.log(`Step ${index}/${total}: ${step}`, data);
formState = 'running';
},
onSuccess: (final) => {
console.log('β
Chain complete', final);
result = final.final ?? {};
formState = 'complete';
goto('/success');
},
onError: (err) => {
console.error('β Chain failed', err);
formState = 'error';
}
});
</script>
<form method="POST" action="?/upload" use:chained enctype="multipart/form-data">
<input type="text" name="title" required />
<input type="file" name="featuredImage" accept="image/*" required />
<button>Start Workflow</button>
</form>
{#if formState === 'running'}
<p>βοΈ Running workflow β step {$formChain.current} of {$formChain.total}</p>
{/if}
Each step must return the standardized ChainStepResponse shape.
import type { ChainStepResponse } from 'chain-enhance';
export const actions = {
upload: async ({ request }): Promise<ChainStepResponse> => {
const data = await request.formData();
const title = data.get('title')?.toString();
const file = data.get('featuredImage') as File;
await new Promise((r) => setTimeout(r, 800)); // simulate upload
return {
step: 'upload',
ok: true,
message: 'File uploaded',
data: {
title,
featuredImageName: file.name
}
};
},
markdown: async ({ request }): Promise<ChainStepResponse> => {
const prev = JSON.parse((await request.formData()).get('__previous')?.toString() ?? '{}');
await new Promise((r) => setTimeout(r, 800));
return {
step: 'markdown',
ok: true,
message: 'Markdown processed',
data: {
wordCount: prev.description?.split(/\s+/).length ?? 0
}
};
},
seo: async ({ request }): Promise<ChainStepResponse> => {
const prev = JSON.parse((await request.formData()).get('__previous')?.toString() ?? '{}');
await new Promise((r) => setTimeout(r, 800));
return {
step: 'seo',
ok: true,
message: 'SEO metadata generated',
data: {
meta: {
title: prev.title,
description: prev.abstract,
keywords: ['svelte', 'chain', 'form']
}
}
};
},
save: async ({ request }): Promise<ChainStepResponse> => {
const prev = JSON.parse((await request.formData()).get('__previous')?.toString() ?? '{}');
await new Promise((r) => setTimeout(r, 800));
return {
step: 'save',
ok: true,
message: 'Saved to database',
data: {
projectId: crypto.randomUUID(),
timestamp: new Date().toISOString()
}
};
},
publish: async ({ request }): Promise<ChainStepResponse> => {
const prev = JSON.parse((await request.formData()).get('__previous')?.toString() ?? '{}');
await new Promise((r) => setTimeout(r, 800));
return {
step: 'publish',
ok: true,
message: `Project "${prev.title}" published successfully!`,
data: {}
};
}
};
export interface ChainStepData {
[key: string]: unknown;
}
/** Response shape required from each action step. */
export interface ChainStepResponse {
step: string; // step name
ok: boolean; // success flag
message?: string; // optional short message
data?: ChainStepData; // optional structured payload
}
/** Accumulated result returned to `onSuccess`. */
export interface ChainCombinedResult extends ChainStepResponse {
history?: ChainStepResponse[]; // all intermediate step responses
final?: ChainStepData; // deeply merged result
}
/** Lifecycle callbacks for `chainEnhance`. */
export interface ChainEnhanceCallbacks {
onStep?: (step: string, data: ChainStepData, index: number, total: number) => void;
onSuccess?: (result: ChainCombinedResult) => void;
onError?: (error: unknown) => void;
}
The first form submission runs normally via enhance().
Its resultβs data becomes the base payload.
Each next action is called with:
fd.append('__previous', JSON.stringify(combined));
Step responses are validated and deeply merged:
combined = deepMerge(combined, response.data);
After all steps complete, onSuccess fires with the merged ChainCombinedResult.
ok: false, or thrown exception stops the chain.history.Each step in your workflow receives the full, merged dataset from all previous steps β
you no longer need to manually forward every field like title, description, or meta.
chainEnhance performs a recursive deep merge between step results:
// Step 1 (upload)
data: {
title: 'My Project',
description: 'Full markdown description...',
meta: { keywords: ['svelte', 'chain'] }
}
// Step 2 (seo)
data: {
meta: { description: 'SEO summary' }
}
// β
Combined result after step 2
{
title: 'My Project',
description: 'Full markdown description...',
meta: {
keywords: ['svelte', 'chain'],
description: 'SEO summary'
}
}
---
## π Reactive Progress Store
The `formChain` store tracks real-time progress across the workflow.
```ts
import { formChain } from 'chain-enhance';
$formChain = {
step: 'seo',
current: 3,
total: 5,
percent: 60,
ok: true,
message: 'SEO metadata generated',
data: { meta: {...} }
};
export interface ChainProgress {
step: 'idle' | 'initial' | 'complete' | 'error' | string;
current: number;
total: number;
percent: number;
ok?: boolean;
message?: string;
data?: Record<string, unknown>;
error?: unknown;
}
startStep(step, data?, current?, total?)
completeStep(final)
failStep(error)
{
"step": "publish",
"ok": true,
"message": "Completed 5 chained actions in 5021.44ms.",
"final": {
"title": "Demo Project",
"featuredImageName": "cat.jpg",
"wordCount": 243,
"meta": {
"title": "Demo Project",
"description": "SEO generated description",
"keywords": ["svelte", "chain", "form"]
},
"projectId": "0fa69e9c-74e8-465d-b8b0-62fa8b6d958d",
"timestamp": "2025-10-30T14:32:10.202Z"
},
"history": [
{ "step": "upload", "ok": true, "message": "File uploaded" },
{ "step": "markdown", "ok": true, "message": "Markdown processed" },
{ "step": "seo", "ok": true, "message": "SEO metadata generated" },
{ "step": "save", "ok": true, "message": "Saved to database" },
{ "step": "publish", "ok": true, "message": "Project \"Demo Project\" published successfully!" }
]
}
chainEnhance(actions: string[], options?: ChainEnhanceCallbacks): (form: HTMLFormElement) => void
| Parameter | Type | Description |
|---|---|---|
actions |
string[] |
Ordered list of action names to chain |
options.onStep |
(step, data, i, total) => void |
Fired after each successful step |
options.onSuccess |
(result) => void |
Fired when all steps complete |
options.onError |
(err) => void |
Fired on network, parsing, or ok:false failures |
| Feature | Status |
|---|---|
| SvelteKit 2 / 5 (runes) | β |
| TypeScript | β |
| Multipart (first step only) | β |
| Deep merge data propagation | β |
| Reactive progress store | β |
| SSR + SPA | β |
MIT Β© Michael Cuneo (2025)
chainEnhance() = enhance() on steroids
Upload β Parse β SEO β Save β Publish β Done
{ step, ok, message?, data? }data objects deep-merge between stepsformChain store tracks progress