Forms & I/O
A native form POSTs to a post handler. Read the submitted fields off body.
// routes/contact.tsx
export const post: PostFunction = async ({ body }) => {
await sendMessage(body); // { name, email, message }
throw redirect('/thanks');
};
export default function Contact() {
return (
<form method="post">
<input name="name" />
<input name="email" type="email" />
<textarea name="message"></textarea>
<button>Send</button>
</form>
);
}
Type body with PostInput to validate it. See typed contracts.
import type { PostFunction } from 'camelon';
export type PostInput = { body: { name: string } };
export type PostOutput = { greeting?: string };
export const post: PostFunction<PostInput> = ({ body }) => ({
greeting: `Hello, ${body.name || 'stranger'}!`,
});
export default function Form({ greeting }: PostOutput) {
return (
<div class="d-card">
{greeting ? <strong>{greeting}</strong> : <p>a native form — no client JS:</p>}
<form method="post" class="d-row">
<input class="d-input" name="name" placeholder="your name" />
<button class="d-btn d-wide" type="submit">Send</button>
</form>
</div>
);
}A reactive submit posts the current signals to a resource route, which patches a reply back:
export default function Echo() {
return (
<div class="d-card" signals='{"msg": "", "reply": ""}'>
<div class="d-row">
<input class="d-input" bind:msg="" placeholder="say something" />
<button class="d-btn d-wide" on:click="@post('/echo/submit')">Send</button>
</div>
<p text="$reply"></p>
</div>
);
}import type { PostFunction } from 'camelon';
export type PostInput = { signals: { msg: string } };
export const post: PostFunction<PostInput> = ({ stream, signals }) => {
const msg = signals.msg ?? '';
stream.patchSignals({ reply: msg ? `You said: ${msg}` : '' });
};File uploads
A multipart/form-data submit lands on files, keyed by field name. Each upload's .data is a Uint8Array.
export const post: PostFunction = async ({ files }) => {
for (const file of files.avatar ?? []) {
await save(file.filename, file.data); // also .mimeType, .truncated
}
};
<form method="post" enctype="multipart/form-data">
<input name="avatar" type="file" />
<button>Upload</button>
</form>
Upload limits
Each uploaded file is capped at 10 MB by default. A file over the cap is truncated and its truncated flag is set. Change the cap with multipart.fileSize:
await createHandler({
routes: manifest,
multipart: { fileSize: 5 * 1024 * 1024 },
});
Streaming responses
Use the stream arg for Server-Sent Events. Keep patching while your generator runs; the stream stays open until the handler returns. patchElements(html, { selector, mode }) with mode: 'append' adds to an element instead of replacing it.
export default function Stream() {
return (
<div class="d-card">
<button class="d-btn d-wide" on:click="@get('/stream/words')">Stream</button>
<p id="out"></p>
</div>
);
}import type { GetFunction } from 'camelon';
async function* words(): AsyncGenerator<string> {
for (const word of 'words stream in one at a time'.split(' ')) {
await new Promise((r) => setTimeout(r, 130));
yield word;
}
}
export const get: GetFunction = async ({ stream }) => {
stream.patchElements('<p id="out"></p>'); // reset
for await (const word of words()) {
stream.patchElements(`<span> ${word}</span>`, {
selector: '#out',
mode: 'append',
});
}
};A chat is the same shape — a form sends the prompt with @get (a long-lived stream has to be GET), and the response route streams the reply back one word at a time:
export default function Chat() {
return (
<div class="d-card d-chat">
<div id="msgs" class="d-msgs"></div>
<form class="d-row" signals='{"prompt": ""}' on:submit="@get('/chat/response')">
<input class="d-input" placeholder="Say something…" bind:prompt="" aria-label="message" />
<button class="d-btn d-wide" type="submit">Send</button>
</form>
</div>
);
}import type { GetFunction } from 'camelon';
export type GetInput = { signals: { prompt: string } };
async function* reply(prompt: string): AsyncGenerator<string> {
const text = `you said “${prompt}”. this reply streams back one word at a time.`;
for (const word of text.split(' ')) {
await new Promise((r) => setTimeout(r, 70));
yield word;
}
}
export const get: GetFunction<GetInput> = async ({ signals, stream }) => {
const prompt = String(signals.prompt ?? '').trim();
if (!prompt) return;
stream.patchElements(await (<p class="d-msg d-me">{prompt}</p>), { selector: '#msgs', mode: 'append' });
stream.patchSignals({ prompt: '' });
const id = `r${Date.now()}`;
stream.patchElements(await (<p class="d-msg d-bot" id={id} />), { selector: '#msgs', mode: 'append' });
let acc = '';
for await (const word of reply(prompt)) {
acc = acc ? `${acc} ${word}` : word;
stream.patchElements(await (<p class="d-msg d-bot" id={id}>{acc}</p>));
}
}For full control over status, headers, and body, a resource route can return a raw Response.
export const get: GetFunction = () => {
return new Response('hello', {
status: 200,
headers: { 'content-type': 'text/plain' },
});
};