Avatar

@stevekrouse

176 likes814 public vals
Joined July 11, 2022
mayor of val town

SSR React Mini & SQLite Todo App

This Todo App is server rendered and client-hydrated React. This architecture is a lightweight alternative to NextJS, RemixJS, or other React metaframeworks with no compile or build step. The data is saved server-side in Val Town SQLite.

demo

SSR React Mini Framework

This "framework" is currently 44 lines of code, so it's obviously not a true replacement for NextJS or Remix.

The trick is client-side importing the React component that you're server rendering. Val Town is uniquely suited for this trick because it both runs your code server-side and exposes vals as modules importable by the browser.

The tricky part is making sure that server-only code doesn't run on the client and vice-versa. For example, because this val colocates the server-side loader and action with the React component we have to be careful to do all server-only imports (ie sqlite) dynamically inside the loader and action, so they only run server-side.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource https://esm.sh/react */
import { zip } from "https://esm.sh/lodash-es";
import { useEffect, useState } from "https://esm.sh/react@18.2.0";
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=46";
import { Form, hydrate } from "https://esm.town/v/stevekrouse/ssr_react_mini?v=75";
export async function loader(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const [, { columns, rows }] = await sqlite.batch([
`CREATE TABLE IF NOT EXISTS todos2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
text TEXT NOT NULL
)`,
`select * from todos2`,
]);
const initialTodos = rows.map(row => Object.fromEntries(zip(columns, row)));
return { initialTodos, initialLogs: [`Server rendered`] };
}
export async function action(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const formData = await req.formData();
if (req.method === "POST") {
const text = formData.get("text") as string;
const [, { rows }] = await sqlite.batch([{
sql: `insert into todos2(text) values (?)`,
args: [text],
}, "select last_insert_rowid()"]);
return Response.json({ id: rows[0][0], text });
}
else if (req.method === "PUT") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
const { rows } = await sqlite.execute({ sql: `select completed_at from todos2 where id = ?`, args: [id] });
const completed_at = rows[0][0];
const new_completed_at = completed_at ? null : new Date() as any;
await sqlite.execute({ sql: `update todos2 set completed_at = ? where id = ?`, args: [new_completed_at, id] });
}
else if (req.method === "DELETE") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
await sqlite.execute({ sql: `delete from todos2 where id = ?`, args: [id] });
}
return Response.json("OK");
}
export function Component({ initialTodos, initialLogs }) {
const [todos, setTodos] = useState(initialTodos);
const [logs, setLogs] = useState(initialLogs);
const [newTodo, setNewTodo] = useState("");
const addLog = log => setLogs([...logs, log]);
useEffect(() => addLog(`Client rendered`), []);
function addTodo() {
setTodos([...todos, { text: newTodo }]);
setNewTodo("");
return async (resp: Response) => {
const { text, id } = await resp.json();
setTodos([...todos, { text, id }]); // ok to add again because todos is stale
addLog(`Got ${resp.ok ? "OK" : "Error"} from server for adding todo`);
};
}
function toggleTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.map(t => t.id === id ? { ...t, completed_at: t.completed_at ? null : Date.now() } : t));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for toggling todo`);
}
function deleteTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.filter(t => t.id !== id));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for deleting todo`);
}
return (
<html>
<head>
<title>Todo List</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com" />
</head>
<body className="max-w-md mx-auto p-10">
<h2 className="text-xl font-bold pb-2">Todo list</h2>
<ol className="pl-2 max-w-64">
{todos.map((todo) => (
<li key={todo.id} className={`flex justify-between ${todo.completed_at ? "line-through" : ""}`}>
<div className="flex">
<Form action="put" className="mr-2" onSubmit={toggleTodo}>
<input name="id" value={todo.id} type="hidden" />
<button>{todo.completed_at ? "✅" : "o"}</button>
</Form>
{todo.text}
</div>
<Form action="delete" onSubmit={deleteTodo}>
<input name="id" value={todo.id} type="hidden" />

☔️ Umbrella reminder if there's rain today

Screenshot 2023-09-14 at 12.31.32.png

Setup

  1. Fork this val 👉 https://val.town/v/stevekrouse.umbrellaReminder/fork
  2. Customize the location (line 8). You can supply any free-form description of a location.

⚠️ Only works for US-based locations (where weather.gov covers).

How it works

  1. Geocodes an free-form description of a location to latitude and longitude – @stevekrouse.nominatimSearch
  2. Converts a latitude and longitude to weather.gov grid – @stevekrouse.weatherGovGrid
  3. Gets the hourly forecast for that grid
  4. Filters the forecast for periods that are today and >30% chance of rain
  5. If there are any, it formats them appropriately, and sends me an email
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { email } from "https://esm.town/v/std/email?v=9";
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON";
import { nominatimSearch } from "https://esm.town/v/stevekrouse/nominatimSearch";
import { weatherGovGrid } from "https://esm.town/v/stevekrouse/weatherGovGrid";
export const umbrellaReminder = async (arg) => {
if (arg.method) return Response.json("");
let location = "prospect heights, brooklyn"; // <---- customize this line
let [{ lat, lon }] = await nominatimSearch({
q: location,
});
let { properties: grid } = await weatherGovGrid({
lat,
lon,
});
let { properties: { periods } } = await fetchJSON(
grid.forecastHourly,
);
let { DateTime } = await import("npm:luxon");
let parse = (iso) => DateTime.fromISO(iso).setZone(grid.timeZone);
let today = periods.filter((x) =>
parse(x.startTime).toLocaleString()
=== DateTime.now().setZone(grid.timeZone).toLocaleString()
);
if (today.every((x) => x.probabilityOfPrecipitation.value < 30))
return today;
let format = (iso) => parse(iso).toFormat("ha").toLowerCase();
let html = `The probabilities of rain in <b>${location}</b> today:<br><br>`
+ today.map((
{ startTime, endTime, probabilityOfPrecipitation: { value: p } },
) => `${format(startTime)}-${format(endTime)}: ${p}%`).join("<br>");
return email({ html, subject: "☔️ Carry an umbrella today!" });
};

Planes Above Me

Inspired by https://louison.substack.com/p/i-built-a-plane-spotter-for-my-son

A little script that grabs that planes above you, just change line 4 to whatever location you want and it'll pull the lat/log for it and query.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON";
import { round } from "https://esm.town/v/stevekrouse/round";
import { nominatimSearch } from "https://esm.town/v/stevekrouse/nominatimSearch";
export let planesAboveMe = (async () => {
let [{ lat, lon, display_name }] = await nominatimSearch({
q: "atlantic terminal", // <---- change me
});
let epsilon = .1;
let query = new URLSearchParams({
lamax: String(round(lat, 2) + epsilon),
lamin: String(round(lat, 2) - epsilon),
lomax: String(round(lon, 2) + epsilon),
lomin: String(round(lon, 2) - epsilon),
});
console.log({ lat, lon, display_name });
let url = `https://opensky-network.org/api/states/all?${query}`;
let data = await fetchJSON(url);
return data?.states?.map((f) => ({
icao24: f[0],
callsign: f[1],
origin_country: f[2],
time_position: f[3],
last_contact: f[4],
longitude: f[5],
latitude: f[6],
baro_altitude: f[7],
on_ground: f[8],
velocity: f[9],
true_track: f[10],
vertical_rate: f[11],
sensors: f[12],
geo_altitude: f[13],
squawk: f[14],
spi: f[15],
position_source: f[16],
category: f[17],
}));
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { eval_ } from "https://esm.town/v/stevekrouse/eval_";
import { fetchTweet } from "https://esm.town/v/dpetrouk/fetchTweet?v=35";
export let xeval = async (req: Request) => {
let tweet, code, result;
try {
tweet = await fetchTweet(req.url);
code = tweet.text.split("```")[1]
.trim()
.replaceAll(/&lt;/g, "<")
.replaceAll(/&gt;/g, ">")
.replaceAll(/&amp;/g, "&");
result = await eval_(code, [req]);
return result;
}
catch (e) {
return Response.json({ code, tweet, result }, { status: 500 });
}
};

Message yourself on Telegram

This val lets you send yourself Telegram messages via ValTownBot. This ValTownBot saves you from creating your own Telegram Bot.

However if I'm being honest, it's really simple and fun to make your own Telegram bot. (You just message BotFather.) I'd recommend most folks going that route so you have an unmediated connection to Telegram. However if you want to have the simplest possible setup to just send yourself messages, read on...

Installation

It takes less than a minute to set up!

  1. Start a conversation with ValTownBot

  2. Copy the secret it gives you

  3. Save it in your Val Town Environment Variables under telegram

  4. Send a message!

Usage

telegramText

Create valimport { telegramText } from "https://esm.town/v/stevekrouse/telegram?v=14"; const statusResponse = await telegramText("Hello from Val.Town!!"); console.log(statusResponse);

telegramPhoto

Create valimport { telegramPhoto } from "https://esm.town/v/stevekrouse/telegram?v=14"; const statusResponse = await telegramPhoto({ photo: "https://placekitten.com/200/300", }); console.log(statusResponse);

ValTownBot Commands

  • /roll - Roll your secret in case you accidentally leak it.
  • /webhook - Set a webhook to receive messages you send to @ValTownBot

Receiving Messages

If you send /webhook to @ValTownBot, it will let you specify a webhook URL. It will then forward on any messages (that aren't recognized @ValTownBot commands) to that webhook. It's particularly useful for creating personal chatbots, like my telegram <-> DallE bot.

How it works

Telegram has a lovely API.

  1. I created a @ValTownBot via Bot Father.
  2. I created a webhook and registered it with telegram
  3. Whenever someone new messages @ValTownBot, I generate a secret and save it along with their Chat Id in @stevekrouse/telegramValTownBotSecrets (a private val), and message it back to them
  4. Now whenever you call this val, it calls telegramValTownAPI, which looks up your Chat Id via your secret and sends you a message

Telegram Resources

Credits

This val was originally made by pomdtr.

Todo

  • Store user data in Val Town SQLite
  • Parse user data on the API side using Zod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { runVal } from "https://esm.town/v/std/runVal";
import type { TelegramSendMessageOptions } from "https://esm.town/v/stevekrouse/telegramSendMessage";
import type { TelegramSendPhotoOptions } from "https://esm.town/v/stevekrouse/telegramSendPhoto";
/**
* Send a text message to yourself from the ValTownBot Telegram bot
* Message https://t.me/ValTownBot to get started
* @param text
* @param options
* @param authorization
*/
export async function telegramText(text: string, options?: TextOptions, authorization?: string) {
return telegramRequest("text", { text, options }, authorization);
}
/**
* Send a photo to yourself from the ValTownBot Telegram bot
* Message https://t.me/ValTownBot to get started
* @param options
* @param authorization
*/
export async function telegramPhoto(options: PhotoOptions, authorization?: string) {
return telegramRequest("photo", { options }, authorization);
}
async function telegramRequest(path, body, authorization?: string) {
const response = await fetch("https://stevekrouse-telegramValTownAPI.web.val.run/" + path, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${authorization ?? Deno.env.get("telegram")}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
} else {
return "SUCCESS";
}
}
type TextOptions = Omit<TelegramSendMessageOptions, "chat_id">;
type PhotoOptions = Omit<TelegramSendPhotoOptions, "chat_id">;
/**
* @deprecated since 4/20/2024
*/
export async function telegram(secret: string, text: string, options?: MergedOptions) {
return runVal("stevekrouse.telegramValTownBot", secret, text, options);
}
export type MergedOptions = TextOptions & PhotoOptions;

Get all the pages in a notion database

Usage

  1. Find your databaseId: https://developers.notion.com/reference/retrieve-a-database
  2. Get auth by setting up an internal integration: https://developers.notion.com/docs/authorization#internal-integration-auth-flow-set-up

Example usage: @stevekrouse.dateMeNotionDatabase

deno-notion-sdk docs: https://github.com/cloudydeno/deno-notion_sdk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const notionGetDatabase = async ({ databaseId, auth, filter }: {
databaseId: string;
auth: string;
filter?: any;
}) => {
const { Client, collectPaginatedAPI } = await import(
"https://deno.land/x/notion_sdk/src/mod.ts"
);
const notion = new Client({ auth });
return collectPaginatedAPI(notion.databases.query, {
database_id: databaseId,
filter,
});
};

Poll RSS feeds

This val periodically polls specified RSS feeds and send the author an email with new items. It checks each feed defined in rssFeeds for new content since the last run and sends an email with the details of the new items.

Usage

  1. Fork @stevekrouse/rssFeeds and update it with your desired RSS feeds;
  2. Fork this val and replace the https://esm.town/v/stevekrouse/rssFeeds import with your newly forked val;
  3. Enjoy RSS updates on your email!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { email } from "https://esm.town/v/std/email?v=9";
import { newRSSItems } from "https://esm.town/v/stevekrouse/newRSSItems";
import { rssFeeds } from "https://esm.town/v/stevekrouse/rssFeeds";
export async function pollRSSFeeds({ lastRunAt }: Interval) {
return Promise.all(
Object.entries(rssFeeds).map(async ([name, url]) => {
let items = await newRSSItems({
url,
lastRunAt,
});
if (items.length)
await email({
text: JSON.stringify(items, null, 2),
subject: `New from ${name} RSS`,
});
return { name, items };
}),
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource npm:hono@3/jsx */
import { chat } from "https://esm.town/v/stevekrouse/openai";
import cronstrue from "npm:cronstrue";
import { Hono } from "npm:hono@3";
const app = new Hono();
app.get("/", (c) =>
c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com" />
<script
src="https://unpkg.com/htmx.org@1.9.11"
integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0"
crossorigin="anonymous"
>
</script>
<style
dangerouslySetInnerHTML={{
__html: `
.htmx-indicator{
display:none;
}
.htmx-request .htmx-indicator{
display:inline;
}
.htmx-request.htmx-indicator{
display:inline;
}
`,
}}
>
</style>
<title>Cron Compiler</title>
</head>
<body class="flex p-6 flex-col space-y-4 max-w-2xl mx-auto">
<div>
<h1 class="text-3xl">Cron Compiler</h1>
<p>Compile natural language to cron via ChatGPT</p>
</div>
<form hx-post="/compile" hx-target="#cron" class="flex space-x-2" hx-disabled-elt="button">
<input
name="description"
value="On weekdays at noon"
required
class="w-full border-2 rounded-lg p-2"
>
</input>
<input type="hidden" name="timezone" id="timezone"></input>
<script
dangerouslySetInnerHTML={{
__html: `document.querySelector('#timezone').value = Intl.DateTimeFormat().resolvedOptions().timeZone`,
}}
/>
<button class="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded disabled:hidden">
Compile
</button>
<button class="htmx-indicator text-white font-bold py-2 px-4 rounded bg-gray-400" disabled>
Loading...
</button>
</form>
<div hx-swap-oob="true" id="cron">
{renderCron("0 16 * * 1-5", "America/New_York")}
</div>
<div>
Inspired by <a href="https://cronprompt.com/" class="text-blue-500 hover:text-blue-700">Cron Prompt</a>.{" "}
<a href="https://www.val.town/v/stevekrouse/cron" class="text-blue-500 hover:text-blue-700">View source</a>.
</div>
</body>
</html>,
));
function renderCron(cron: string, timezone: string) {
return (
<div>
<pre class="font-mono text-xl text-center w-full border-2 p-2 bg-gray-200">{cron}</pre>
<p class="text-center">
{cronstrue.toString(cron, { tzOffset: getOffset(timezone), use24HourTimeFormat: false })}
, {shortenTimezoneString(timezone)}
</p>
</div>
);
}
// convert a timezone string like America/New_York to "EST"
function shortenTimezoneString(timeZone: string) {
const string = new Date().toLocaleString("en-US", { timeZone, timeZoneName: "short" });
return string.split(" ").at(-1);
}
// https://stackoverflow.com/a/68593283
const getOffset = (timeZone: string) => {
const now = new Date();
const utcDate = new Date(now.toLocaleString("en-US", { timeZone: "UTC" }));
const tzDate = new Date(now.toLocaleString("en-US", { timeZone }));
return (tzDate.getTime() - utcDate.getTime()) / (60 * 60 * 1000);
};

This is an example call of @stevekrouse/insecureFetch

1
2
3
4
5
6
import { insecureFetch } from "https://esm.town/v/stevekrouse/insecureFetch";
const url = "https://assignment-api.uspto.gov/patent/basicSearch?query=1234567&fields=main&rows=20";
const data = await insecureFetch(url);
const text = await data.text();
console.log(text);

Starter App for ssr_react_mini

You need to export four things:

  1. loader - runs on any GET request, on the server. it accepts the Request and returns the props of your React compnent.
  2. action- runs on the server on any non-GET, ie POST, PUT, DELETE, or <form>s submit
  3. Component - your React component. it's initially server-rendered and then client-hydrated
  4. default - you should mostly leave this line alone

This is framework is bleeding-edge. You'll need to read the code of the framework itself (it's very short) to understand what it's doing.

If you have questions or comments, please comment below on this val! (or any of these vals)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/** @jsxImportSource https://esm.sh/react */
import { useEffect, useState } from "https://esm.sh/react@18.2.0";
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=46";
import { Button, Form, hydrate } from "https://esm.town/v/stevekrouse/ssr_react_mini";
// runs on page load, on the server
export async function loader(req: Request) {
// ensure any server-side imports only run server side
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const [, { columns, rows }] = await sqlite.batch([
// you can optionally create your table here IF NOT EXISTS
`CREATE TABLE IF NOT EXISTS ssr_react_mini_startesr_clicks (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
// get the data your page needs on load
`select count(*) from ssr_react_mini_starter_clicks`,
]);
// return the props for your Component
return { initialClicks: rows[0][0] };
}
// handle <Form> submissions and other server-requests
export async function action(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
if (req.method === "POST") {
await sqlite.execute(`INSERT INTO ssr_react_mini_starter_clicks DEFAULT VALUES`);
}
return Response.json("OK");
}
export function Component({ initialClicks }: { initialClicks: number }) {
const [clicks, setClicks] = useState(0);
const addClick = () => setClicks(clicks + 1); // optimistic, client-side
return (
<html>
<head>
<title>SSR React Starter</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com" />
</head>
<body className="max-w-md mx-auto p-10">
<h1 className="text-3xl font-bold">Hello Val Town</h1>
<div className="flex justify-between">
<div>
<div>{clicks + initialClicks} clicks ever</div>
<div>{clicks} clicks since the page loaded</div>
</div>
<Form onSubmit={addClick}>
<Button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Click!</Button>
</Form>
</div>
</body>
</html>
);
}
export default codeOnValTown(hydrate(import.meta.url));