evikit
v2.2.2
Published
Preact/SQLite SSR framework for small web apps
Readme
EviKit
Preact SSR framework using a node:sqlite ORM and Vite. For small web apps hosted on a VPS or in local network.
Vite is a good foundation for building a server-rendered monolith SPA with an JSON API and client hydration. I made a few modules that help me build such an SPA on top of Node.js, Preact, Express, Valibot and popular libraries from their ecosystems. An SPA built with EviKit can:
- For each
/path, fetch/api/pathand SSR the page - Send urlencoded forms and refresh
/api/pathwithout a full page reload - Validate API input and output against your schemas
- Input is: cookies + url params + body (each overrides previous if key exists)
- Note: PHP removed cookies from this list but I think it's ok if used carefully
- Keep fetched data between link navigations
- Show the language chosen by the user in browser settings
- Have SEO meta descriptions
- Generate API documentation with a Swagger overview
- Send multipart not urlencoded when there's a file
TODO Add more simple language docs near code, don't overwhelm readers.
Documentation
Let's see how to build a basic poll application using this framework. The application will be a site that lets people view polls, suggest their options and vote.
Our language will be standard modern JavaScript as supported by Node.js directly. This means no JSX or other extensions, except node_modules resolution.
Create project
First, install dependencies:
git init mysite
cd mysite
npm init -y
# Routing and validation.
npm i accept-language classnames evikit express file-type hoofd preact preact-iso valibot
npm i -D @preact/preset-vite cross-env vite vite-bundle-analyzer
# API documentation.
npm i swagger-ui-express
# Type safety.
npm i -D @types/express @types/node @types/swagger-ui-express typescriptSecond, configure strict JSDoc type checking using TypeScript at tsconfig.json:
npx tsc --init --outDir dist --allowJs --checkJs --noEmit --esModuleInteropThird, open package.json and change "type": "commonjs" to "type": "module" because we want modern import syntax.
Fourth, add a .gitignore. I suggest starting with:
node_modules
/dist
*~
*swp
.envDatabase schema
Let's start by modelling a relational database schema. We have a question with multiple text choice options. Create src/lib/db/schema.js:
import { primary, references } from "evikit";
import { integer, number, object, optional, pipe, string } from "valibot";
export const Question = object({
createdAt: pipe(number(), integer()),
id: pipe(
optional(string(), () => crypto.randomUUID()),
primary(),
),
text: string(),
});
export const Choice = object({
id: pipe(
optional(string(), () => crypto.randomUUID()),
primary(),
),
questionId: pipe(string(), references(Question, "id")),
text: string(),
votes: pipe(number(), integer()),
});Export the database at src/lib/db/index.js:
import { sqlite } from "evikit/sqlite";
import pkg from "../../../package.json" with { type: "json" };
import * as schema from "./schema.js";
export const db = sqlite({ pkg, schema });- See ChoiceServer below for an example of
upsertreturning a created row. - See QuestionIdServer below for an example of
selectwith a join. - See VoteServer below for an example of
updateanddelete.
The database is kept under the OS-specific path for your application, such as ~/.local/share/mysite-nodejs/db.sqlite3 on GNU/Linux.
If you want a temporary, in-memory database, omit pkg, pass just { schema }.
Routing
Language negotiation
Configure languages you're going to accept at src/lib/index.js:
import acceptLanguage from "accept-language";
export function acceptLanguages() {
acceptLanguage.languages(["en"]);
}Start with en. Add languages later when translations are ready.
Error page
Code an error page at src/routes/error.js:
import { a, h1, main, p, useI18n, useStatus } from "evikit";
import { useMeta, useTitle } from "hoofd/preact";
/** @param {{ children?: import("preact").ComponentChildren, error?: unknown }} props */
export function ErrorPage({ children, error }) {
const i18n = useI18n();
const status = useStatus(error);
useMeta({ content: "noindex", name: "robots" });
useTitle(status);
return main(
p({ class: "nav" }, a({ href: "/" }, i18n.gettext("Questions"))),
h1({ class: "title" }, status),
children,
);
}API router
Add an API router at src/api.js (uncomment the routes one by one when we add them later):
import { route, router } from "evikit/server";
import pkg from "../package.json" with { type: "json" };
import { acceptLanguages } from "./lib/index.js";
// import { ChoiceServer } from "./routes/api/choice/server.js";
// import { QuestionIdServer } from "./routes/api/question/[id]/server.js";
// import { HomeServer } from "./routes/api/server.js";
// import { VoteServer } from "./routes/api/vote/server.js";
acceptLanguages();
export function api() {
return router(
{ pkg },
// route("/api", HomeServer),
// route("/api/choice", ChoiceServer),
// route("/api/question/:id", QuestionIdServer),
// route("/api/vote", VoteServer)
);
}Page router
Add a Preact router at src/app.js (uncomment the routes one-by-one when we add them later):
import { hydrate, Router, ssr } from "evikit";
import { h } from "preact";
import { Route } from "preact-iso";
import { acceptLanguages } from "./lib/index.js";
import { ErrorPage } from "./routes/error.js";
// import { HomePage } from "./routes/page.js";
// import { QuestionIdPage } from "./routes/question/[id]/page.js";
acceptLanguages();
/** @param {{ lang?: string, url: string }} data */
export async function prerender(data) {
return await ssr(App, data);
}
/** @param {{ dehydratedState?: ReturnType<import("evikit").dehydrate> | undefined, i18n: ReturnType<import("evikit").useI18n> }} props */
function App({ dehydratedState, i18n }) {
return h(
Router,
{ dehydratedState, i18n },
// h(Route, { component: HomePage, path: "/" }),
// h(Route, { component: QuestionIdPage, path: "/question/:id" }),
h(Route, { component: ErrorPage, default: true }),
);
}
hydrate(App, "#app");HTML entry point
Make an HTML template index.html, leaving placeholders for server-rendered content:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<!--app-head-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script prerender type="module" src="/src/app.js"></script>
</body>
</html>Development server
Setup Vite at vite.config.js:
import preact from "@preact/preset-vite";
import { defineConfig } from "vite";
import { analyzer } from "vite-bundle-analyzer";
import { api } from "./src/api.js";
export default defineConfig({
plugins: [
analyzer({ analyzerMode: "static" }),
{
configureServer(server) {
server.middlewares.use(api().onRequest);
},
name: "configure-server",
},
preact({ prerender: { enabled: true, renderTarget: "#app" } }),
],
});The following commands should work now:
# Run dev server with hot reload for Preact and full restart on API change.
npx vite dev
# Build server and client for production.
npx tsc
npx vite buildYou can add these commands (without npx) as dev and build scripts to your package.json.
TODO Why does npx vite build return 'Unable to detect prerender entry script' sometimes? Re-running fixed it for me.
Production server
Add production server at index.js:
import { listen, production } from "evikit/server";
import express from "express";
import { readFile } from "node:fs/promises";
import swaggerUi from "swagger-ui-express";
import { api } from "./src/api.js";
import { prerender } from "./src/app.js";
const app = express();
app.use(api().onRequest);
app.use("/openapi", swaggerUi.serve, swaggerUi.setup(api().openapi));
app.use("/", express.static("./dist", { index: false, redirect: false }));
app.use(
production({
html: await readFile("./dist/index.html", "utf-8"),
prerender,
}),
);
listen(app);Configure port in .env:
ORIGIN=http://localhost:8000
PORT=8000Run it:
npx cross-env NODE_ENV=production node --env-file=.env index.jsYou can add this command (without npx) as start script to your package.json.
To see what your biggest dependencies are, analyze bundle size stats at http://localhost:8000/stats.html.
HTTPS origin
When you host your app on a domain and add a reverse proxy with HTTPS, such as Apache with mod_md enabled, change ORIGIN to your public address but keep your local PORT the same in .env:
ORIGIN=https://example.org
PORT=8000Application logic
Data fetching
We want a home page that lists all questions. It will consist of a JSON API server endpoint and an isomorphic route.
Declare API input and output types in src/routes/api/types.js:
import { array, object, string } from "valibot";
import * as schema from "../../lib/db/schema.js";
export const HomeGet = {
input: object({
// `lang` comes from the accept-language header.
// Falls back to "en" if not found in your acceptLanguages.
lang: string(),
}),
outputs: {
200: object({ Question: array(schema.Question) }),
},
};Code the API server endpoint in src/routes/api/server.js:
import { getI18n } from "evikit";
import { endpoint, json } from "evikit/server";
import { db } from "../../lib/db/index.js";
import { HomeGet } from "./types.js";
export const HomeServer = {
get: endpoint(HomeGet, async ({ input }) => {
const i18n = await getI18n(input.lang);
let Question = db.Question.select();
if (!Question.length) {
db.Question.upsert({
createdAt: Date.now(),
text: i18n.gettext("What's up?"),
});
Question = db.Question.select();
}
return json({ Question });
}),
};Uncomment the HomeServer import and /api route in src/api.js.
Render a Preact page route: src/routes/page.js:
import { a, h1, li, ul, useApi, useI18n } from "evikit";
import { useTitle } from "hoofd/preact";
import { HomeGet } from "./api/types.js";
export function HomePage() {
const { data } = useApi(HomeGet);
const i18n = useI18n();
useTitle(i18n.gettext("Questions"));
return data
? ul(
{ class: "questions" },
data.Question.map((question) =>
li(
{ key: question.id },
a(
{ href: `/question/${encodeURIComponent(question.id)}` },
question.text,
),
),
),
)
: h1({ class: "loading" }, i18n.gettext("Loading..."));
}Uncomment the HomePage import and / route in src/app.js.
Forms
Our question page will list one question with all its choices. It will give the user a form to vote for one of the existing choices. There will be another form for suggesting your own choice.
API input/output types: src/routes/api/question/[id]/types.js:
import { array, object, string } from "valibot";
import * as schema from "../../../../lib/db/schema.js";
export const QuestionIdGet = {
input: object({ id: string(), lang: string() }),
outputs: {
200: object({
question: object({
...schema.Question.entries,
Choice: array(schema.Choice),
}),
}),
404: string(),
},
};Server endpoint: src/routes/api/question/[id]/server.js:
import { getI18n } from "evikit";
import { endpoint, json, text } from "evikit/server";
import { db } from "../../../../lib/db/index.js";
import { QuestionIdGet } from "./types.js";
export const QuestionIdServer = {
get: endpoint(QuestionIdGet, async ({ input }) => {
const i18n = await getI18n(input.lang);
const Question = db.Question.select({
with: { Choice: true },
});
// It maps join results to objects, for example you can get:
// Question[0]?.Choice[0]?.text
const question = Question.find(({ id }) => id == input.id);
if (!question) {
return text(i18n.gettext("Question does not exist"), 404);
}
return json({ question });
}),
};Uncomment the QuestionIdServer import and /question/:id route in src/api.js.
Preact page route: src/routes/question/[id]/page.js (note that we're outside the api/ directory now):
import { h1, main, useApi, useEnhance, useI18n } from "evikit";
import { useTitle } from "hoofd/preact";
import { h } from "preact";
import { ChoicePost } from "../../api/choice/types.js";
import { QuestionIdGet } from "../../api/question/[id]/types.js";
import { VotePost } from "../../api/vote/types.js";
import { ErrorPage } from "../../error.js";
export function QuestionIdPage() {
const { data, error } = useApi(QuestionIdGet);
const votePost = useEnhance(VotePost);
const suggestPost = useEnhance(ChoicePost);
const i18n = useI18n();
const title = data?.question.text || i18n.gettext("Loading...");
useTitle(title);
if (!data && error) {
return h(ErrorPage, { error });
}
return main(
h1({ class: "title" }, title),
// VOTE_FORM: replace with form view from the "Vote for choice" section.
// SUGGEST_FORM: replace with form view from the "Suggest own option" section.
);
}Uncomment the QuestionIdPage import and /question/:id route in src/app.js.
Suggest own option
Types: src/routes/api/choice/types.js:
import { object, string } from "valibot";
export const ChoicePost = {
input: object({
lang: string(),
questionId: string(),
text: string(),
}),
outputs: { 302: string(), 400: string() },
};Server endpoint: src/routes/api/choice/server.js:
import { getI18n } from "evikit";
import { endpoint, redirect, text } from "evikit/server";
import { ok } from "node:assert/strict";
import { db } from "../../../lib/db/index.js";
import { ChoicePost } from "./types.js";
export const ChoiceServer = {
post: endpoint(ChoicePost, async ({ input }) => {
const i18n = await getI18n(input.lang);
const Question = db.Question.select();
const question = Question.find(({ id }) => id == input.questionId);
if (!question) {
return text(i18n.gettext("Question does not exist"), 400);
}
const [choice] = db.Choice.upsert({
questionId: question.id,
text: input.text,
votes: 1,
});
ok(choice);
// The returned choice has a generated id.
// To replace an existing choice, don't omit the optional id.
// To create multiple choices, pass multiple arguments to upsert.
return redirect(`/question/${encodeURIComponent(question.id)}`, 302, {
cookies: {
// Note: just an example. Use cookies sparingly, e.g. for session id.
ownVoteQuestionId: { value: choice.questionId },
},
});
}),
};Uncomment the ChoiceServer import and /api/choice route in src/api.js.
Code a form view in place of SUGGEST_FORM in src/routes/question/[id]/page.js:
import classNames from "classnames";
import { button, form, h2, input, label, p } from "evikit";
// ...
form(
{ action: "/api/choice", method: "POST", onSubmit: suggestPost.onSubmit },
input({ name: "questionId", type: "hidden", value: data?.question.id }),
h2({ class: "heading" }, i18n.gettext("Own choice")),
p(
{ class: "fieldset" },
label({ for: "text" }, i18n.gettext("Text")),
input({ id: "text", name: "text" }),
),
button(
{ class: classNames("btn", suggestPost.busy && "btn-disabled") },
i18n.gettext("Suggest"),
),
),Vote for choice
Types: src/routes/api/vote/types.js:
import { object, optional, string } from "valibot";
export const VotePost = {
input: object({
choiceId: string(),
lang: string(),
ownVoteQuestionId: optional(string()), // Coming from cookie.
}),
outputs: { 302: string(), 400: string() },
};Server endpoint: src/routes/api/vote/server.js:
import { getI18n } from "evikit";
import { endpoint, redirect, text } from "evikit/server";
import { db } from "../../../lib/db/index.js";
import { VotePost } from "./types.js";
export const VoteServer = {
post: endpoint(VotePost, async ({ input }) => {
const i18n = await getI18n(input.lang);
const Choice = db.Choice.select();
const choice = Choice.find(({ id }) => id == input.choiceId);
if (!choice) {
return text(i18n.gettext("You didn't select a choice"), 400);
}
if (choice.questionId == input.ownVoteQuestionId) {
return text(i18n.gettext("Already voted"), 400);
}
db.Choice.update({
set: { votes: choice.votes + 1 },
where: { id: choice.id },
// To update multiple choices, pass an array of ids to `where.id`.
});
// To delete a choice: `db.Choice.delete({ id: choice.id })`.
// To delete multiple choices, pass an array of ids to `id`.
return redirect(`/question/${encodeURIComponent(choice.questionId)}`, 302);
}),
};Uncomment the VoteServer import and /api/vote route in src/api.js.
Code a form view in place of VOTE_FORM in src/routes/question/[id]/page.js:
import { li, ul } from "evikit";
// ...
form(
{ action: "/api/vote", method: "POST", onSubmit: votePost.onSubmit },
ul(
{ class: "choices" },
data?.question.Choice.map((choice) =>
li(
{ key: choice.id },
input({
id: choice.id,
name: "choiceId",
type: "radio",
value: choice.id,
}),
label(
{ for: choice.id },
choice.text,
": ",
i18n.ngettext("%1 vote", "%1 votes", choice.votes, choice.votes),
),
label({ for: choice.id }, choice.text),
),
),
),
button(
{ class: classNames("btn", votePost.busy && "btn-disabled") },
i18n.gettext("Vote"),
),
),File upload
Why not let each choice have an icon?
In src/lib/db/schema.js:
import { instance } from "valibot";
// Other imports and Question schema...
export const Storage = object({
content: instance(Uint8Array),
id: pipe(string(), primary()),
});
export const Choice = object({
iconStorageId: pipe(string(), references(Storage, "id")),
// The rest of the Choice schema...
});In src/routes/api/choice/types.js:
import { file } from "valibot";
// Other imports...
export const ChoicePost = {
input: object({
icon: file(),
// The rest of the schema...In src/routes/api/choice/server.js:
import { fileTypeFromBuffer } from "file-type";
import { createHash } from "node:crypto";
import { parse, picklist } from "valibot";
// ...
if (!question) {
return text(i18n.gettext("Question does not exist"), 400);
}
const buffer = Buffer.from(await input.icon.arrayBuffer());
// Fail if user sent something else than an image.
const fileType = await fileTypeFromBuffer(buffer);
ok(fileType);
const { ext } = fileType;
parse(picklist(["gif", "jpg", "png", "webp"]), ext);
// Don't save another image if hash already known.
const iconStorageId = `${createHash("sha256").update(buffer).digest("hex")}.${ext}`;
db.Storage.upsert({
content: new Uint8Array(buffer.buffer),
id: iconStorageId,
});
const [choice] = db.Choice.upsert({
iconStorageId,
// ...In src/routes/api/question/[id]/types.js:
// ...
question: object({
...schema.Question.entries,
Choice: array(
object({
...schema.Choice.entries,
iconUrl: string(),
}),
),
}),
// ...In src/routes/api/question[id]/server.js:
import { fileTypeFromBuffer } from "file-type";
import { ok } from "node:assert/strict";
// ...
const Question = db.Question.select({
with: { Choice: { with: { iconStorage: true } } },
});
// ...
return json({
question: {
...question,
Choice: await Promise.all(
question.Choice.map(async (choice) => {
ok(choice.iconStorage);
const buffer = Buffer.from(choice.iconStorage.content.buffer);
const fileType = await fileTypeFromBuffer(buffer);
ok(fileType);
return {
...choice,
iconUrl: `data:${fileType.mime};base64,${buffer.toString("base64")}`,
};
}),
),
},
});In src/routes/question/[id]/page.js:
import { img } from "evikit";
// ...
form(
{
action: "/api/choice",
enctype: "multipart/form-data",
method: "POST",
onSubmit: suggestPost.onSubmit,
},
// ...
p(
{ class: "fieldset" },
label({ for: "icon" }, i18n.gettext("Icon")),
input({ id: "icon", name: "icon", type: "file" }),
),
// The rest of the /api/choice form...
),
form(
{ action: "/api/vote", method: "POST", onSubmit: votePost.onSubmit },
// ...
input({
id: choice.id,
name: "choiceId",
type: "radio",
value: choice.id,
}),
label(
{ for: choice.id },
img({ alt: "", height: "24", src: choice.iconUrl, width: "24" }),
// ...Translation
For the Ukrainian language, which has the uk two-letter language code, create an empty file po/uk.po. It will use the gettext format, which is well-known to professional translators.
Extract translatable strings from code:
npx evikit-extractYou can add this command (without npx) as an extract script in your package.json. On every launch, it will add newly found strings to every translation file, as well as clean up the ones now unused.
Now, translate po/uk.po using Poedit or your favorite gettext-compatible translation editor. After releasing your source code, you can crowdsource translations at Codeberg Translate.
After po/uk.po is sufficiently ready (80% or more?), change src/lib/index.js like this:
import acceptLanguage from "accept-language";
export function acceptLanguages() {
acceptLanguage.languages(["en", "uk"]);
}Styling
EviKit is decoupled from styling, it's up to you how to design your app. I've had good experience with daisyUI. Let's see how to set it up.
npm i -D @tailwindcss/vite daisyuiAdd the Tailwind plugin at vite.config.js:
import tailwindcss from "@tailwindcss/vite";
// ...
export default defineConfig({
// ...
plugins: [
// ...
tailwindcss(),
],
});Import Tailwind and daisyUI at index.css:
@import "tailwindcss";
@plugin "daisyui";Change your index.html like this:
...
<html ... class="font-sans">
<head>
...
<link rel="stylesheet" href="/index.css" />
<!--app-head-->
</head>
...
</html>Style your app however you like.
Testing
A practical integration test for EviKit itself is Lanquiz, an app to host quizzes in LAN from a laptop, which is built on top of EviKit.
TODO Write testing examples using jsdom and node:test.
TODO Explain how I test EviKit locally.
Linting
To automatically rearrange my whitespace and properties so that I don't have to, and to find potential mistakes, I use ESLint with Perfectionist plugin, Prettier and typescript-eslint:
npm i -D @eslint/compat @eslint/js eslint eslint-plugin-perfectionist globals typescript-eslint
npm i -D eslint-config-prettier prettierMy eslint.config.js:
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import prettier from "eslint-config-prettier/flat";
import perfectionist from "eslint-plugin-perfectionist";
import { defineConfig } from "eslint/config";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";
export default defineConfig(
includeIgnoreFile(fileURLToPath(new URL("./.gitignore", import.meta.url))),
js.configs.recommended,
...ts.configs.strict,
...ts.configs.stylistic,
perfectionist.configs["recommended-natural"],
prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
);Formatting command:
npx prettier --write .
npx eslint --fix .Just check:
npx prettier --check .
npx eslint .You can add these commands (without npx) as format and lint scripts in your package.json.
License
SPDX-License-Identifier: MIT
