Avatar

@stevekrouse

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

Inspector to browser json data in HTTP vals

Screenshot 2024-02-23 at 9.31.42 AM.png

Example: https://val.town/v/stevekrouse/weatherDescription

Thanks @mmcgrana (https://markmcgranaghan.com/) for the idea!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { html } from "https://esm.town/v/stevekrouse/html";
import { accepts } from "https://esm.town/v/vladimyr/accepts";
export const json_viewer = (data) => (req: Request) => {
const accept = accepts(req);
if (!accept.type("html")) {
return Response.json(data);
}
return html(`<!DOCTYPE html>
<html lang="en">
<body>
<div id="json-viewer"></div>
<script src="https://cdn.jsdelivr.net/npm/@textea/json-viewer@3"></script>
<script>
new JsonViewer({
value: ${JSON.stringify(data)}
}).render('#json-viewer')
</script>
</body>
</html>`);
};

Blob Admin

This is a lightweight Blob Admin interface to view and debug your Blob data.

b7321ca2cd80899250589b9aa08bc3cae9c7cea276282561194e7fc537259b46.png

Use this button to install the val:

It uses basic authentication with your Val Town API Token as the password (leave the username field blank).

TODO

  • /new - render a page to write a new blob key and value
  • /edit/:blob - render a page to edit a blob (prefilled with the existing content)
  • /delete/:blob - delete a blob and render success
  • add upload/download buttons
  • Use modals for create/upload/edit/view/delete page (htmx ?)
  • handle non-textual blobs properly
  • use codemirror instead of a textarea for editing text blobs
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/hono@4.0.8/jsx **/
import { modifyFetchHandler } from "https://esm.town/v/andreterron/codeOnValTown?v=50";
import view_route from "https://esm.town/v/pomdtr/blob_admin_blob";
import create_route from "https://esm.town/v/pomdtr/blob_admin_create";
import delete_route from "https://esm.town/v/pomdtr/blob_admin_delete";
import edit_route from "https://esm.town/v/pomdtr/blob_admin_edit";
import upload_route from "https://esm.town/v/pomdtr/blob_admin_upload";
import { passwordAuth } from "https://esm.town/v/pomdtr/password_auth?v=74";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { Hono } from "npm:hono@4.0.8";
import { jsxRenderer } from "npm:hono@4.0.8/jsx-renderer";
const app = new Hono();
app.use(
jsxRenderer(({ children }) => {
return (
<html>
<head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
<script
type="module"
src="https://cdn.jsdelivr.net/npm/code-mirror-web-component@0.0.8/dist/code-mirror.js"
>
</script>
<title>Blob Admin</title>
</head>
<body>
<main class="container">
{children}
</main>
</body>
</html>
);
}),
);
app.get("/", async (c) => {
let blobs = await blob.list();
return c.render(
<div class="overflow-auto">
<h1>Blob Admin</h1>
<section
style={{
display: "flex",
gap: "0.5em",
}}
>
<a href="/create">New Blob</a>
<a href="/upload">Upload Blob</a>
</section>
<section>
<table>
<thead>
<tr>
<th>Name</th>
<th>Size (kb)</th>
<th>Last Modified</th>
<th
style={{
textAlign: "center",
}}
>
Edit
</th>
<th
style={{
textAlign: "center",
}}
>
Delete
</th>
<th
style={{
textAlign: "center",
}}
>
Download
</th>
</tr>
</thead>
{blobs.map(b => (
<tr>
<td>
<a href={`/view/${encodeURIComponent(b.key)}`}>
{b.key}
</a>
</td>
<td>{b.size / 1000}</td>
<td>{new Date(b.lastModified).toLocaleString()}</td>
<td
style={{
textAlign: "center",
}}
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import process from "node:process";
export const langchainEx = (async () => {
const { OpenAI } = await import("https://esm.sh/langchain/llms/openai");
const { PromptTemplate } = await import("https://esm.sh/langchain/prompts");
const { LLMChain } = await import("https://esm.sh/langchain/chains");
const model = new OpenAI({
temperature: 0.9,
openAIApiKey: process.env.openai,
maxTokens: 100,
});
const template = "What is a good name for a company that makes {product}?";
const prompt = new PromptTemplate({
template: template,
inputVariables: ["product"],
});
const chain = new LLMChain({ llm: model, prompt: prompt });
const res = await chain.call({ product: "colorful socks" });
return res;
})();
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
import { normalizeURL } from "https://esm.town/v/stevekrouse/normalizeURL";
export const fetchJSON = async (
url: string | URL,
options?: RequestInit & {
bearer?: string;
fetch?: typeof fetch;
},
) => {
let headers = new Headers(options?.headers ?? {});
headers.set("accept", "application/json");
if (options?.bearer) {
headers.set("authorization", `Bearer ${options.bearer}`);
}
let fetch = options?.fetch ?? globalThis.fetch;
let resp = await fetch(normalizeURL(url), {
redirect: "follow",
...options,
headers,
});
let text = await resp.text();
try {
return JSON.parse(text);
}
catch (e) {
throw new Error(`fetchJSON error: ${e.message} in ${url}\n\n"${text}"`);
}
};

Email with GPT-3

Send an email to stevekrouse.emailGPT3@valtown.email, it will forward it to gpt3, and email you back the response.

1
2
3
4
5
6
7
8
9
10
11
12
13
import { thisEmail } from "https://esm.town/v/stevekrouse/thisEmail";
import { mail } from "https://esm.town/v/stevekrouse/mail";
import { runVal } from "https://esm.town/v/std/runVal";
export async function emailGPT3(email) {
let response = await runVal("patrickjm.gpt3", { prompt: email.text });
return mail({
to: email.from,
from: thisEmail(),
subject: "Re: " + email.subject,
text: response,
});
}

dlock - free distributed lock as a service

https://dlock.univalent.net/

Usage

API

Acquire a lock.

The id path segment is the lock ID - choose your own.

https://dlock.univalent.net/lock/arbitrary-string/acquire?ttl=60

{"lease":1,"deadline":1655572186}

Another attempt to acquire the same lock within its TTL will fail with HTTP status code 409.

https://dlock.univalent.net/lock/01899dc0-2742-44f9-9c7b-01830851b299/acquire?ttl=60

{"error":"lock is acquired by another client","deadline":1655572186}

The previous lock can be renewed with its lease number, like a heartbeat

https://dlock.univalent.net/lock/01899dc0-2742-44f9-9c7b-01830851b299/acquire?ttl=60&lease=1

{"lease":1,"deadline":1655572824}

Release a lock

https://dlock.univalent.net/lock/01899dc0-2742-44f9-9c7b-01830851b299/release?lease=42

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
import { searchParams } from "https://esm.town/v/stevekrouse/searchParams";
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON";
import { parentReference } from "https://esm.town/v/stevekrouse/parentReference";
export async function dlock({ id, ttl, release, lease }: {
id?: string;
ttl?: number;
release?: boolean;
lease?: number;
} = {}): Promise<{
lease?: number;
deadline: number;
error?: "string";
}> {
id = id ??
parentReference().userHandle + "-" +
parentReference().valName;
ttl = ttl ?? 3; // seconds
let method = release ? "release" : "acquire";
return fetchJSON(
`https://dlock.univalent.net/lock/${id}/${method}?${
searchParams({ ttl, lease })
}`,
);
}
1
2
3
4
5
6
import { karma } from "https://esm.town/v/stevekrouse/karma";
export const whatIsValTown = karma.replaceAll(
/karma/gi,
"Val Town",
);

Passkeys Demo

Passkeys are pretty neat! I wanted to get a demo working in Val Town so I ported over https://github.com/maximousblk/passkeys-demo.

One challenge was that the original extensively uses DenoKV store with compound keys and values. I created @stevekrouse/DenoSyntheticKV as a replacement for DenoKV. It uses SuperJSON to encode the keys and values.

You can find the client-side script for the main page here: @stevekrouse/passkey_script

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 { deleteCookie, getSignedCookie, setSignedCookie } from "https://deno.land/x/hono@v3.6.3/middleware.ts";
import { Hono } from "https://deno.land/x/hono@v3.6.3/mod.ts";
import { jwtVerify, SignJWT } from "https://deno.land/x/jose@v4.14.6/index.ts";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "https://deno.land/x/simplewebauthn@v10.0.0/deno/server.ts";
import { isoBase64URL, isoUint8Array } from "https://deno.land/x/simplewebauthn@v10.0.0/deno/server/helpers.ts";
import type {
AuthenticationResponseJSON,
RegistrationResponseJSON,
} from "https://deno.land/x/simplewebauthn@v10.0.0/deno/typescript-types.ts";
import { DenoSyntheticKV } from "https://esm.town/v/stevekrouse/DenoSyntheticKV";
// CONSTANTS
const SECRET = new TextEncoder().encode(Deno.env.get("JWT_SECRET") ?? "development");
const RP_ID = "stevekrouse-passkeys_demo.web.val.run";
const RP_NAME = Deno.env.get("WEBAUTHN_RP_NAME") ?? "Deno Passkeys Demo";
const CHALLENGE_TTL = Number(Deno.env.get("WEBAUTHN_CHALLENGE_TTL")) || 60_000;
// UTILS
function generateJWT(userId: string) {
return new SignJWT({ userId }).setProtectedHeader({ alg: "HS256" }).sign(SECRET);
}
function verifyJWT(token: string) {
return jwtVerify(token, SECRET);
}
function generateRandomString() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
// DATABASE
const kv = new DenoSyntheticKV("passkeys_example");
type User = {
username: string;
data: string;
credentials: Record<string, Credential>;
};
type Credential = {
credentialID: Uint8Array;
credentialPublicKey: Uint8Array;
counter: number;
};
type Challenge = true;
// RP SERVER
const app = new Hono();
app.get("/", c =>
c.html(
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Passkeys Demo</title>
<link rel="icon" href="https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/🦕_color.svg" />
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/bamboo.css" />
</head>
<body>
<h1>🦕 Passkeys Demo</h1>
<p>
<a href="https://www.val.town/v/stevekrouse/passkeys_demo">View code</a> on Val Town. Port of{" "}
<a href="https://github.com/maximousblk/passkeys-demo
">
maximousblk/passkeys-demo
</a>.
</p>
<p id="passkeys_check">Passkeys are not supported! ❌</p>
<noscript>
<blockquote>
<p>⚠️ Passkeys require JavaScript to work.</p>
</blockquote>
</noscript>
<form>
<fieldset id="auth" disabled>
<legend>Login</legend>
<label for="name">
Name <span style="opacity: 0.5">(Optional)</span>
</label>
<input type="text" id="name" name="name" autocomplete="username webauthn" placeholder="Anon" />
<hr />
<button type="button" id="register" onclick="handleRegister()">Register</button>
<button type="button" id="login" onclick="handleLogin()">Login</button>
<button type="button" id="logout" onclick="handleLogout()">Logout</button>

SQLite Migrate Table via cloning it into a new table example

There are a lot of migrations that SQLite doesn't allow, such as adding a primary key on a table. The way to accomplish this is by creating a new table with the schema you desire and then copying the rows of the old table into it.

This example shows how to:

  1. Get the schema for the existing table
  2. Create the new table
  3. Copy all rows from old to new
  4. Rename the old table to an archive (just in case)
  5. Rename the new table to the original table name

This script shows me adding a primary key constraint to the Profile column of my DateMeDocs database. I would console and comment out various parts of it as I went. You can see everything I did in the version history. The main tricky part for me was removing the duplicate primary key entries before doing the migration step, which is a useful thing anyways, from a data cleaning perspective.

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
import { sqlite } from "https://esm.town/v/std/sqlite?v=6";
const tableName = "DateMeDocs";
const newTableName = tableName + "__new";
const archiveTableName = tableName + "__archive";
// 1. get old schema
const oldSchema = await sqlite.execute(`SELECT * FROM pragma_table_info('${tableName}')`);
const createTableStatement = `CREATE TABLE ${tableName} (
${oldSchema.rows.map((row) => `${row[1]} ${row[2]}`).join(",\n ")}
)`;
// console.log(createTableStatement);
/* CREATE TABLE DateMeDocs (
Name TEXT,
Profile TEXT, // below we will add primary key here (the point of the migration)
Gender TEXT,
InterestedIn TEXT,
Age INTEGER,
Location TEXT,
Style TEXT,
WantsKids TEXT,
LocationFlexibility TEXT,
Community TEXT,
Contact TEXT,
LastUpdated TEXT,
id TEXT
) */
// 2. create new schema
const newCreateTableStatement = `CREATE TABLE ${newTableName} (
Name TEXT,
Profile TEXT PRIMARY KEY,
Gender TEXT,
InterestedIn TEXT,
Age INTEGER,
Location TEXT,
Style TEXT,
WantsKids TEXT,
LocationFlexibility TEXT,
Community TEXT,
Contact TEXT,
LastUpdated TEXT,
id TEXT
)`;
// console.log(newCreateTableStatement);
// await sqlite.execute(newCreateTableStatement);
// 3. add the rows from the old table to the new table
// await sqlite.execute(`INSERT INTO ${newTableName} SELECT * FROM ${tableName}`);
// test out all is well
// console.log(await sqlite.execute(`SELECT * FROM ${newTableName} limit 2`));
// 4. archive the old table
// await sqlite.execute(`ALTER TABLE ${tableName} RENAME TO ${archiveTableName}`);
// 5. rename the new table to the old table
// await sqlite.execute(`ALTER TABLE ${newTableName} RENAME TO ${tableName}`);
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
import process from "node:process";
import OpenAI from "npm:openai";
const openai = new OpenAI({ apiKey: process.env.openai });
async function main() {
const response = await openai.chat.completions.create({
model: "gpt-4-vision-preview",
messages: [
{
role: "user",
content: [
{
type: "text",
text:
"I am trying to find an emoji. I took a selfie that's trying to evoke this emoji. Give me a list of potential emojis this photo evokes. Reply ONLY with emoji. No other text explaining your choices.",
},
{
type: "image_url",
image_url:
"https://media.cleanshot.cloud/media/60976/KXVUGiSb0DD4jYqnaASkHYliQLpYPUnTBIiylySQ.jpeg?Expires=1699376932&Signature=XR1~tmcXRwHgQXh1BRZh3pa0RQrq00nSGTw3w-YHbNm6kCJXpCq13J6eORE1XDdZlPWq9yy5B~h6~nR889GmtuA67E5Fno839LyxPXA4RIBMIySVNF1py55grAba
},
],
},
],
max_tokens: 10,
});
return response.choices[0].message.content;
}
export let gpt4vDemo = await main();