camelon

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>
  );
}
live

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}` : '' });
};
live

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',
    });
  }
};
live

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>));
  }
}
live

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' },
  });
};