@athenox/talos
Version:
Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.
310 lines (309 loc) • 17.5 kB
JavaScript
import { createError, defineEventHandler, getHeader, getQuery, getRequestHost, getRequestIP, getRequestProtocol, getRouterParams, readBody, readMultipartFormData, sendStream, setHeader, } from 'h3';
import { planText } from './runtime.js';
// The HTTP method already says which kind of operation a route is.
const OPS = {
GET: ['SELECT'], POST: ['INSERT', 'CALL'], PUT: ['UPDATE'], PATCH: ['UPDATE'], DELETE: ['DELETE'],
};
/**
* An h3 route that is a sentence.
*
* export default sentenceRoute(app, 'supprime la categorie {id}', { guard: requireAdmin })
*
* Parameters come from the route params, the body and the query, in that order
* of precedence. Three facts about the request are handed to the runtime
* rather than re-read by the model from the sentence: the HTTP method fixes the
* operation, an `id` in the URL fixes the row, and a body's fields are written
* to the columns they name. See `route` for the template syntax and for how a
* sentence is compiled once and reused.
*/
export function sentenceRoute(app, template, opts = {}) {
const answer = answerer(app, template, opts);
return defineEventHandler(event => answer(event, getRouterParams(event)));
}
/**
* A whole API as a table of sentences, served by one catch-all handler.
*
* // server/api/[...].ts
* export default defineRoutes(app, {
* 'GET /api/events': 'montre les evenements publies',
* 'DELETE /api/admin/events/:id': { say: 'supprime l evenement {id}', guard: 'events' },
* }, { guards: { events: e => requirePerm(e, 'events') } })
*
* An entry is a sentence, or the options of `sentenceRoute` plus `say`, with
* `guard` naming one of `guards` (or given as a function). `:name` segments are
* parameters. Route files the app still has win over the table, so the table
* holds what is a sentence and files hold what is code.
*/
export function defineRoutes(app, table, opts = {}) {
const routes = Object.entries(table).map(([key, entry]) => {
const [method, path] = key.trim().split(/\s+/);
const names = [];
const re = new RegExp(`^${path.replace(/\/:(\w+)/g, (_, n) => { names.push(n); return '/([^/]+)'; })}/?$`);
// A handler (an `accounts()` endpoint, say) sits in the table as it is.
// It sees the URL's parameters as a file route would (`getRouterParams`).
if (typeof entry === 'function')
return { method: method.toUpperCase(), re, names,
answer: (event, params) => { event.context.params = { ...event.context.params, ...params }; return entry(event); } };
const e = typeof entry === 'string' ? { say: entry } : entry;
const guard = typeof e.guard === 'string' ? opts.guards?.[e.guard] : e.guard;
if (typeof e.guard === 'string' && !guard)
throw new Error(`${key}: no guard named ${e.guard}`);
return { method: method.toUpperCase(), re, names, answer: answerer(app, e.say, { ...opts.defaults, ...e, guard }) };
});
return defineEventHandler(async (event) => {
const path = event.path.split('?')[0];
for (const r of routes) {
if (r.method !== event.method.toUpperCase())
continue;
const m = r.re.exec(path);
if (m) {
const params = Object.fromEntries(r.names.map((n, i) => [n, decodeURIComponent(m[i + 1])]));
event.context.params = { ...event.context.params, ...params };
return r.answer(event, params);
}
}
if (opts.passthrough)
return;
throw createError({ statusCode: 404, message: 'no such route' });
});
}
function answerer(app, template, opts) {
const { guard, ctx, where, shape, stamp, fill, row, reply, only, form, firstError, fields, download, pin, notFound = 'not found', ...base } = opts;
// Keep only the listed fields; `author.name` keeps one field of a relation.
const keep = (r) => {
if (!fields)
return r;
const out = {};
for (const f of fields) {
const [a, b] = f.split('.');
if (b) {
if (r[a] && typeof r[a] === 'object')
(out[a] ??= {})[b] = r[a][b];
else if (a in r)
out[a] ??= r[a];
}
else if (a in r)
out[a] = r[a];
}
return out;
};
const compiled = {};
// A string, or the whole `data` of the 404 when the front reads it there.
const missing = () => createError({ statusCode: 404,
message: typeof notFound === 'string' ? notFound : String(notFound.message ?? 'not found'),
data: typeof notFound === 'string' ? undefined : notFound });
// An HTTP error the route means (a 404, a failed rule) is an answer; anything
// else thrown while answering is a runtime error, for telemetry.
return async (event, params) => {
try {
return await answer(event, params);
}
catch (e) {
if (!e?.statusCode || e.statusCode >= 500)
app.telemetry?.error(e, 'route', event.context.talosCall);
throw e;
}
};
async function answer(event, params) {
const user = (guard ? await guard(event, params) : undefined);
const method = event.method.toUpperCase();
// A multipart form's text fields are values like any other; its files are
// handed to the route's functions as `p.files`, for the code that stores them.
let body = {};
let files = [];
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (String(getHeader(event, 'content-type') ?? '').startsWith('multipart/')) {
const parts = (await readMultipartFormData(event)) ?? [];
files = parts.filter(f => f.filename);
body = Object.fromEntries(parts.filter(f => !f.filename && f.name).map(f => [f.name, f.data.toString('utf8')]));
}
else
body = (await readBody(event).catch(() => ({}))) ?? {};
}
if (form && ['POST', 'PUT', 'PATCH'].includes(method)) {
const on = (v) => v === true || v === 'true' || v === 'on' || v === '1' || v === 1;
for (const k of form.checkboxes ?? [])
body[k] = on(body[k]);
// Absent or blank alike: a form that leaves a field out has emptied it.
for (const k of form.nullable ?? [])
if (body[k] === undefined || (typeof body[k] === 'string' && !body[k].trim()))
body[k] = null;
}
const p = { ...getQuery(event), ...body, ...params, ...(files.length ? { files } : {}) };
for (const [k, f] of Object.entries(fill ?? {}))
if (p[k] === undefined || p[k] === '')
p[k] = await f(p, user);
const run = compiled[method] ??= app.route(template, { ops: OPS[method], ...base });
const more = (await shape?.(p, user, event)) ?? {};
// Every URL segment names the row (`/:id`, `/:slug`); segments that are not
// columns are ignored by the runtime.
const pinned = {
...(typeof where === 'function' ? where(p, user) : where), ...more.where,
...Object.fromEntries(Object.entries(params).filter(([k]) => pin === undefined || (pin !== false && pin.includes(k)))
.map(([k, v]) => [k, k === 'id' ? Number(v) : v])),
};
const sent = only ? Object.fromEntries(Object.entries(body).filter(([k]) => only.includes(k))) : body;
const out = await run(p, ctx ? ctx(user) : {}, {
...more,
where: Object.keys(pinned).length ? pinned : undefined,
values: { ...sent, ...more.values, ...Object.fromEntries(Object.keys(fill ?? {}).map(k => [k, p[k]])) },
stamp: { ...more.stamp, ...await stamp?.(p, user) },
uploads: files.length ? files.map(f => ({ name: f.name, filename: f.filename, type: f.type, data: f.data })) : undefined,
});
// In the response headers, so a wrong plan is never a mystery — and the
// call that made it, for feedback or for whoever helps with it.
setHeader(event, 'x-talos-plan', encodeURIComponent(`${out.sentence} => ${planText(out.plan)}${out.cached ? ' (compiled)' : ''}`));
if (out.callId) {
setHeader(event, 'x-talos-call', out.callId);
event.context.talosCall = out.callId;
}
const r = out.result;
// A failed rule comes back as `data.errors: [{ msg }]`, the shape forms expect.
if (out.status === 404)
throw missing();
if (out.status !== 200) {
const errors = r?.errors && (firstError ? r.errors.slice(0, 1) : r.errors);
throw createError({ statusCode: out.status, message: errors?.[0]?.msg ?? r?.error, data: r?.data ?? (errors ? { errors } : undefined) });
}
if (r.row && download) {
const f = download(r.row, p);
if (!f)
throw missing();
const { createReadStream } = await import('node:fs');
const { stat } = await import('node:fs/promises');
if (!(await stat(f.path).catch(() => null))?.isFile())
throw missing();
if (f.type)
setHeader(event, 'content-type', f.type);
setHeader(event, 'x-content-type-options', 'nosniff');
if (f.name)
setHeader(event, 'content-disposition', `${f.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`);
for (const [k, v] of Object.entries(f.headers ?? {}))
setHeader(event, k, v);
return sendStream(event, createReadStream(f.path));
}
if (r.rows && row)
r.rows = r.rows.map(row);
if (r.row && row)
r.row = row(r.row);
if (r.rows && fields)
r.rows = r.rows.map(keep);
if (r.row && fields)
r.row = keep(r.row);
// A write that matched nothing is a 404 before any custom reply is made.
// (A bulk write reports created/updated/skipped: none of them is "not found".)
if (('updated' in r || 'deleted' in r) && !(r.updated ?? r.deleted) && !r.dedupe && !('created' in r))
throw missing();
if (reply)
return reply(r, p, user, event);
if (r.row)
return r.row;
if (r.rows && r.total === undefined && !('deleted' in r))
return r.rows;
if ('created' in r && ('updated' in r || 'skipped' in r))
return { created: r.created, updated: r.updated, skipped: r.skipped };
if ('updated' in r || 'deleted' in r) {
if (!(r.updated ?? r.deleted) && !r.dedupe)
throw missing();
return { success: true };
}
if ('created' in r)
return { id: r.created };
return r;
}
}
/**
* Files stored by a table's `files` columns, served back: the path is checked to
* stay inside `dir`, the type follows the extension, and `guard` may refuse one
* (a private photo) by throwing.
*
* 'GET /uploads/:name': serveFiles('uploads', { guard: (name, event) => ... })
*/
export function serveFiles(dir, opts = {}) {
const TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp',
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4' };
return defineEventHandler(async (event) => {
const { resolve, extname, sep } = await import('node:path');
const name = decodeURIComponent(event.path.split('?')[0].split('/').pop() ?? '');
const root = resolve(dir), path = resolve(root, name);
if (!name || !path.startsWith(root + sep))
throw createError({ statusCode: 404, message: 'not found' });
await opts.guard?.(name, event);
const { stat } = await import('node:fs/promises');
if (!(await stat(path).catch(() => null))?.isFile())
throw createError({ statusCode: 404, message: 'not found' });
setHeader(event, 'content-type', TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream');
setHeader(event, 'x-content-type-options', 'nosniff');
// The guard may have set its own policy (a private photo is not cached publicly).
if (!event.node.res.getHeader('cache-control'))
setHeader(event, 'cache-control', `public, max-age=${opts.maxAge ?? 86400}`);
const { createReadStream } = await import('node:fs');
return sendStream(event, createReadStream(path));
});
}
/**
* A per-client limit on requests, as middleware: `rateLimit({ max: 20, windowMs: 60_000 })`.
* In memory, so per process.
*/
export function rateLimit(opts) {
// ponytail: in-process counters; share them (redis) when the app runs on several nodes.
const hits = new Map();
const methods = opts.methods ?? ['POST', 'PUT', 'PATCH', 'DELETE'];
return defineEventHandler((event) => {
if (!methods.includes(event.method.toUpperCase()))
return;
const now = Date.now();
const key = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown';
const h = hits.get(key);
if (!h || h.until < now) {
hits.set(key, { n: 1, until: now + opts.windowMs });
return;
}
if (++h.n > opts.max)
throw createError({ statusCode: 429, message: opts.message ?? 'too many requests' });
});
}
/**
* An RSS feed or a sitemap from rows — the rows a sentence route reads, mapped
* to items. `sitemap-news` is Google News' flavour (last two days only).
*
* 'GET /rss.xml': feed('rss', { title: 'Le Journal', rows: () => latest(), item: a => ({ url: `/articles/${a.slug}`, title: a.title, date: a.published_at }) })
*/
export function feed(kind, o) {
const x = (s) => String(s ?? '').replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
const when = (d) => { const t = d ? new Date(d) : null; return t && !Number.isNaN(t.getTime()) ? t : null; };
return defineEventHandler(async (event) => {
const site = (typeof o.site === 'function' ? o.site(event) : o.site) ?? `${getRequestProtocol(event)}://${getRequestHost(event)}`;
const abs = (u) => /^https?:/.test(u) ? u : `${site.replace(/\/$/, '')}${u.startsWith('/') ? '' : '/'}${u}`;
let items = [...(o.extra ?? []), ...(await o.rows(event)).map(o.item).filter((i) => !!i)];
// Google News reads the last two days; `days: 0` keeps every item.
const days = o.days ?? 2;
if (kind === 'sitemap-news' && days > 0) {
const since = Date.now() - days * 86_400_000;
items = items.filter(i => (when(i.date)?.getTime() ?? 0) >= since);
}
setHeader(event, 'content-type', kind === 'rss' ? 'application/rss+xml; charset=utf-8' : 'application/xml; charset=utf-8');
setHeader(event, 'cache-control', `public, max-age=${o.maxAge ?? 900}`);
if (kind === 'rss')
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel>
<title>${x(o.title)}</title><link>${x(site)}</link><description>${x(o.description ?? o.title)}</description><language>${x(o.language ?? 'fr')}</language>
<atom:link href="${x(abs(event.path.split('?')[0]))}" rel="self" type="application/rss+xml"/>
${items.map(i => `<item><title>${x(i.title)}</title><link>${x(abs(i.url))}</link><guid isPermaLink="true">${x(abs(i.url))}</guid>`
+ (when(i.date) ? `<pubDate>${when(i.date).toUTCString()}</pubDate>` : '') + (i.description ? `<description>${x(i.description)}</description>` : '')
+ (i.author ? `<dc:creator>${x(i.author)}</dc:creator>` : '') + (i.category ? `<category>${x(i.category)}</category>` : '')
+ (i.image ? `<enclosure url="${x(abs(i.image))}" type="image/jpeg"/>` : '') + '</item>').join('\n')}
</channel></rss>`;
const news = kind === 'sitemap-news';
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${news ? ' xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"' : ''} xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${items.map(i => `<url><loc>${x(abs(i.url))}</loc>` + (when(i.date) && !news ? `<lastmod>${when(i.date).toISOString()}</lastmod>` : '')
+ (i.changefreq && !news ? `<changefreq>${i.changefreq}</changefreq>` : '') + (i.priority !== undefined && !news ? `<priority>${i.priority}</priority>` : '')
+ (news ? `<news:news><news:publication><news:name>${x(o.title)}</news:name><news:language>${x(o.language ?? 'fr')}</news:language></news:publication>`
+ `<news:publication_date>${when(i.date)?.toISOString() ?? ''}</news:publication_date><news:title>${x(i.title)}</news:title></news:news>` : '')
+ (i.image ? `<image:image><image:loc>${x(abs(i.image))}</image:loc></image:image>` : '') + '</url>').join('\n')}
</urlset>`;
});
}
/** `show_in_nav` → `showInNav`, for fronts written against an ORM that camelCases. */
export const camelRow = (row) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k.replace(/_(\w)/g, (_, c) => c.toUpperCase()), v]));