eraspace 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +55 -2
- package/bin.js +40620 -0
- package/package.json +32 -4
- package/sdk/src/client/client.ts +171 -0
- package/sdk/src/client/config.ts +39 -0
- package/sdk/src/client/connection.ts +450 -0
- package/sdk/src/client/errors.ts +37 -0
- package/sdk/src/client/files.ts +364 -0
- package/sdk/src/client/index.ts +36 -0
- package/sdk/src/client/mount.ts +45 -0
- package/sdk/src/client/pagination.ts +104 -0
- package/sdk/src/client/request-key.ts +13 -0
- package/sdk/src/client/result-diff.ts +41 -0
- package/sdk/src/client/retry.ts +5 -0
- package/sdk/src/client/room.ts +110 -0
- package/sdk/src/client/router.ts +177 -0
- package/sdk/src/client/storage.ts +132 -0
- package/sdk/src/client/sub-key.ts +12 -0
- package/sdk/src/client/subscription.ts +55 -0
- package/sdk/src/client/transport.ts +356 -0
- package/sdk/src/client/types.ts +132 -0
- package/sdk/src/server/index.ts +635 -0
- package/templates/chat/client/index.tsx +41 -0
- package/templates/chat/client/styles.css +43 -0
- package/templates/chat/server/index.ts +27 -0
- package/templates/poll/client/index.tsx +24 -0
- package/templates/poll/client/styles.css +28 -0
- package/templates/poll/server/index.ts +17 -0
- package/templates/todo/client/index.tsx +26 -0
- package/templates/todo/client/styles.css +28 -0
- package/templates/todo/server/index.ts +18 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
@layer base {
|
|
2
|
+
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #000; color: #fff; }
|
|
3
|
+
body { margin: 0; }
|
|
4
|
+
main { max-width: 760px; margin: 0 auto; padding: 28px 20px; }
|
|
5
|
+
header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid #333; padding-bottom: 18px; }
|
|
6
|
+
h1 { font-family: ui-monospace, monospace; font-size: 24px; letter-spacing: -1px; margin: 0; }
|
|
7
|
+
h2 { font-size: 20px; margin: 32px 0 16px; }
|
|
8
|
+
p { line-height: 1.5; }
|
|
9
|
+
ul { list-style: none; padding: 0; }
|
|
10
|
+
li { padding: 14px 0; border-bottom: 1px solid #333; display: flex; gap: 12px; align-items: baseline; }
|
|
11
|
+
li > span { flex: 1; overflow-wrap: anywhere; }
|
|
12
|
+
form { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; }
|
|
13
|
+
input { background: #000; border: 1px solid #666; padding: 10px; color: #fff; min-width: 0; flex: 1; }
|
|
14
|
+
input[type=checkbox] { flex: none; accent-color: #fff; }
|
|
15
|
+
button { background: #fff; color: #000; padding: 10px 16px; border: 1px solid #fff; cursor: pointer; }
|
|
16
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
17
|
+
button.subtle { background: #000; color: #fff; border-color: #666; }
|
|
18
|
+
:focus-visible { outline: 2px solid #83b5ff; outline-offset: 3px; }
|
|
19
|
+
small, time { font-family: ui-monospace, monospace; font-size: 12px; }
|
|
20
|
+
[role=alert] { color: #ff9a9a; }
|
|
21
|
+
meter { flex: 1; width: 100%; }
|
|
22
|
+
meter::-webkit-meter-bar { background: #222; border: 0; border-radius: 0; }
|
|
23
|
+
meter::-webkit-meter-optimum-value { background: #fff; }
|
|
24
|
+
meter::-moz-meter-bar { background: #fff; }
|
|
25
|
+
.done { text-decoration: line-through; }
|
|
26
|
+
.presence { padding: 12px 0; font-size: 13px; }
|
|
27
|
+
.attach { flex-basis: 100%; padding: 10px; border: 1px dashed #666; color: #aaa; font-size: 13px; text-align: center; cursor: pointer; position: relative; }
|
|
28
|
+
.attach[data-dragging], .attach:hover, .attach:focus-within { border-color: #fff; color: #fff; }
|
|
29
|
+
.attach progress { display: block; width: 100%; margin-top: 6px; accent-color: #fff; }
|
|
30
|
+
.pending { flex-basis: 100%; display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
|
31
|
+
.pending > span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
32
|
+
li:has(.attachment) { align-items: flex-start; }
|
|
33
|
+
.attachment { margin: 8px 0 0; }
|
|
34
|
+
li > span > .attachment:first-child { margin-top: 0; }
|
|
35
|
+
.attachment img, .attachment video { display: block; max-width: 100%; max-height: 280px; border: 1px solid #333; }
|
|
36
|
+
.pending .attachment { margin: 0; }
|
|
37
|
+
.pending .attachment img { max-height: 48px; }
|
|
38
|
+
.attachment audio { display: block; width: 100%; }
|
|
39
|
+
.attachment iframe { display: block; width: 100%; height: 160px; border: 1px solid #333; background: #fff; }
|
|
40
|
+
.attachment a { color: #ccc; display: inline-flex; gap: 8px; }
|
|
41
|
+
.attachment figcaption { display: flex; gap: 8px; margin-top: 4px; font-size: 12px; color: #888; }
|
|
42
|
+
@media (max-width: 480px) { main { padding: 20px 12px; } li { flex-wrap: wrap; } }
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { capsule, table, query, mutation, endpoint, string, number, object, text } from "eraspace/server";
|
|
2
|
+
type Attachment = { key: string; name: string; contentType: string; size: number };
|
|
3
|
+
const KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
4
|
+
export default capsule({
|
|
5
|
+
schema: {
|
|
6
|
+
messages: table({
|
|
7
|
+
room: string(), author: string(), body: string(),
|
|
8
|
+
// A file in app storage, saved from the client's FileUpload.
|
|
9
|
+
file: object({ key: string(), name: string(), contentType: string(), size: number() }).optional(),
|
|
10
|
+
}).index("by_room", ["room"]),
|
|
11
|
+
},
|
|
12
|
+
queries: {
|
|
13
|
+
messages: query(async (ctx, room: string) => ctx.db.messages.withIndex("by_room", q => q.eq("room", room)).order("desc").take(100)),
|
|
14
|
+
},
|
|
15
|
+
mutations: {
|
|
16
|
+
send: mutation(async (ctx, room: string, author: string, body: string, file?: Attachment | null) => {
|
|
17
|
+
const trimmed = body.trim();
|
|
18
|
+
if (trimmed.length > 2000 || (!trimmed && !file)) throw new Error("Write a message of 1 to 2000 characters.");
|
|
19
|
+
if (file && (!KEY.test(String(file.key)) || typeof file.name !== "string" || typeof file.contentType !== "string" || !Number.isInteger(file.size) || file.size < 0)) throw new Error("That attachment is not a stored file.");
|
|
20
|
+
const name = author.trim().slice(0, 40) || "Guest";
|
|
21
|
+
ctx.log.info("message sent", { room });
|
|
22
|
+
const attachment = file ? { key: file.key, name: file.name.slice(0, 255) || "file", contentType: file.contentType.slice(0, 256), size: file.size } : undefined;
|
|
23
|
+
return ctx.db.messages.insert({ room, author: name, body: trimmed, file: attachment });
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
endpoints: { hi: endpoint({ method: "GET", path: "/hi", readOnly: true }, () => text("hello from eraspace")) },
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createClient } from "eraspace/client";
|
|
2
|
+
import { useState } from "preact/hooks";
|
|
3
|
+
import type app from "../server/index";
|
|
4
|
+
const client = createClient<typeof app>({ reloadOnDeploy: true });
|
|
5
|
+
export default function App() {
|
|
6
|
+
const options = client.useQuery("results");
|
|
7
|
+
const add = client.useMutation("addOption");
|
|
8
|
+
const vote = client.useMutation("vote", { optimistic(store, id) { store.update("results", [], rows => rows.map(row => row.id === id ? { ...row, votes: row.votes + 1 } : row)); } });
|
|
9
|
+
const [label, setLabel] = useState("");
|
|
10
|
+
const [error, setError] = useState("");
|
|
11
|
+
const total = options?.reduce((sum, option) => sum + option.votes, 0) ?? 0;
|
|
12
|
+
return <main>
|
|
13
|
+
<header><h1>live poll</h1><small>{total} {total === 1 ? "vote" : "votes"}</small></header>
|
|
14
|
+
<h2>What should we build next?</h2>
|
|
15
|
+
{options === undefined ? <p>Loading options…</p> : options.length === 0 ? <p>Add an option to start voting.</p> : <ul>{options.map(option => <li key={option.id}>
|
|
16
|
+
<span>{option.label}</span><meter min={0} max={Math.max(1, total)} value={option.votes} aria-label={`${option.votes} votes`}/><small>{option.votes}</small>
|
|
17
|
+
<button onClick={() => { setError(""); void vote(option.id).catch(error => setError(error instanceof Error ? error.message : String(error))); }}>Vote</button>
|
|
18
|
+
</li>)}</ul>}
|
|
19
|
+
<form onSubmit={async e => { e.preventDefault(); setError(""); try { await add(label); setLabel(""); } catch (error) { setError(error instanceof Error ? error.message : String(error)); } }}>
|
|
20
|
+
<input aria-label="New option" placeholder="Add an option" value={label} maxLength={100} onInput={e => setLabel(e.currentTarget.value)} required/><button disabled={!label.trim()}>Add option</button>
|
|
21
|
+
</form>
|
|
22
|
+
{error && <p role="alert">{error}</p>}
|
|
23
|
+
</main>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
@layer base {
|
|
2
|
+
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #000; color: #fff; }
|
|
3
|
+
body { margin: 0; }
|
|
4
|
+
main { max-width: 760px; margin: 0 auto; padding: 28px 20px; }
|
|
5
|
+
header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid #333; padding-bottom: 18px; }
|
|
6
|
+
h1 { font-family: ui-monospace, monospace; font-size: 24px; letter-spacing: -1px; margin: 0; }
|
|
7
|
+
h2 { font-size: 20px; margin: 32px 0 16px; }
|
|
8
|
+
p { line-height: 1.5; }
|
|
9
|
+
ul { list-style: none; padding: 0; }
|
|
10
|
+
li { padding: 14px 0; border-bottom: 1px solid #333; display: flex; gap: 12px; align-items: baseline; }
|
|
11
|
+
li > span { flex: 1; overflow-wrap: anywhere; }
|
|
12
|
+
form { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; }
|
|
13
|
+
input { background: #000; border: 1px solid #666; padding: 10px; color: #fff; min-width: 0; flex: 1; }
|
|
14
|
+
input[type=checkbox] { flex: none; accent-color: #fff; }
|
|
15
|
+
button { background: #fff; color: #000; padding: 10px 16px; border: 1px solid #fff; cursor: pointer; }
|
|
16
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
17
|
+
button.subtle { background: #000; color: #fff; border-color: #666; }
|
|
18
|
+
:focus-visible { outline: 2px solid #83b5ff; outline-offset: 3px; }
|
|
19
|
+
small, time { font-family: ui-monospace, monospace; font-size: 12px; }
|
|
20
|
+
[role=alert] { color: #ff9a9a; }
|
|
21
|
+
meter { flex: 1; width: 100%; }
|
|
22
|
+
meter::-webkit-meter-bar { background: #222; border: 0; border-radius: 0; }
|
|
23
|
+
meter::-webkit-meter-optimum-value { background: #fff; }
|
|
24
|
+
meter::-moz-meter-bar { background: #fff; }
|
|
25
|
+
.done { text-decoration: line-through; }
|
|
26
|
+
.presence { padding: 12px 0; font-size: 13px; }
|
|
27
|
+
@media (max-width: 480px) { main { padding: 20px 12px; } li { flex-wrap: wrap; } }
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { capsule, table, query, mutation, string, number } from "eraspace/server";
|
|
2
|
+
export default capsule({
|
|
3
|
+
schema: { options: table({ label: string(), votes: number().default(0) }) },
|
|
4
|
+
queries: { results: query(async ctx => ctx.db.options.collect()) },
|
|
5
|
+
mutations: {
|
|
6
|
+
addOption: mutation(async (ctx, label: string) => {
|
|
7
|
+
const trimmed = label.trim();
|
|
8
|
+
if (!trimmed || trimmed.length > 100) throw new Error("Use an option of 1 to 100 characters.");
|
|
9
|
+
return ctx.db.options.insert({ label: trimmed, votes: 0 });
|
|
10
|
+
}),
|
|
11
|
+
vote: mutation(async (ctx, id: string) => {
|
|
12
|
+
const option = await ctx.db.options.get(id);
|
|
13
|
+
if (!option) throw new Error("Option no longer exists.");
|
|
14
|
+
await ctx.db.options.update(id, { votes: option.votes + 1 });
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createClient } from "eraspace/client";
|
|
2
|
+
import { useState } from "preact/hooks";
|
|
3
|
+
import type app from "../server/index";
|
|
4
|
+
const client = createClient<typeof app>({ reloadOnDeploy: true });
|
|
5
|
+
export default function App() {
|
|
6
|
+
const todos = client.useQuery("list");
|
|
7
|
+
const [title, setTitle] = useState("");
|
|
8
|
+
const [error, setError] = useState("");
|
|
9
|
+
const [adding, setAdding] = useState(false);
|
|
10
|
+
const add = client.useMutation("add");
|
|
11
|
+
const toggle = client.useMutation("toggle", { optimistic(store, id) { store.update("list", [], rows => rows.map(row => row.id === id ? { ...row, done: !row.done } : row)); } });
|
|
12
|
+
const remove = client.useMutation("remove", { optimistic(store, id) { store.update("list", [], rows => rows.filter(row => row.id !== id)); } });
|
|
13
|
+
function report(error: unknown) { setError(error instanceof Error ? error.message : String(error)); }
|
|
14
|
+
return <main>
|
|
15
|
+
<header><h1>todos</h1><small>{todos?.filter(todo => !todo.done).length ?? 0} remaining</small></header>
|
|
16
|
+
<form onSubmit={async e => { e.preventDefault(); setError(""); setAdding(true); try { await add(title); setTitle(""); } catch (error) { report(error); } finally { setAdding(false); } }}>
|
|
17
|
+
<input aria-label="New task" placeholder="What needs doing?" value={title} onInput={e => setTitle(e.currentTarget.value)} maxLength={200} required/><button disabled={adding || !title.trim()}>Add task</button>
|
|
18
|
+
</form>
|
|
19
|
+
{todos === undefined ? <p>Loading tasks…</p> : todos.length === 0 ? <p>Add your first task.</p> : <ul>{todos.map(todo => <li key={todo.id}>
|
|
20
|
+
<input type="checkbox" aria-label={`Complete ${todo.title}`} checked={todo.done} onChange={() => { setError(""); void toggle(todo.id).catch(report); }}/>
|
|
21
|
+
<span class={todo.done ? "done" : ""}>{todo.title}</span>
|
|
22
|
+
<button class="subtle" onClick={() => { setError(""); void remove(todo.id).catch(report); }}>Delete</button>
|
|
23
|
+
</li>)}</ul>}
|
|
24
|
+
{error && <p role="alert">{error}</p>}
|
|
25
|
+
</main>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
@layer base {
|
|
2
|
+
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #000; color: #fff; }
|
|
3
|
+
body { margin: 0; }
|
|
4
|
+
main { max-width: 760px; margin: 0 auto; padding: 28px 20px; }
|
|
5
|
+
header { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid #333; padding-bottom: 18px; }
|
|
6
|
+
h1 { font-family: ui-monospace, monospace; font-size: 24px; letter-spacing: -1px; margin: 0; }
|
|
7
|
+
h2 { font-size: 20px; margin: 32px 0 16px; }
|
|
8
|
+
p { line-height: 1.5; }
|
|
9
|
+
ul { list-style: none; padding: 0; }
|
|
10
|
+
li { padding: 14px 0; border-bottom: 1px solid #333; display: flex; gap: 12px; align-items: baseline; }
|
|
11
|
+
li > span { flex: 1; overflow-wrap: anywhere; }
|
|
12
|
+
form { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; }
|
|
13
|
+
input { background: #000; border: 1px solid #666; padding: 10px; color: #fff; min-width: 0; flex: 1; }
|
|
14
|
+
input[type=checkbox] { flex: none; accent-color: #fff; }
|
|
15
|
+
button { background: #fff; color: #000; padding: 10px 16px; border: 1px solid #fff; cursor: pointer; }
|
|
16
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
17
|
+
button.subtle { background: #000; color: #fff; border-color: #666; }
|
|
18
|
+
:focus-visible { outline: 2px solid #83b5ff; outline-offset: 3px; }
|
|
19
|
+
small, time { font-family: ui-monospace, monospace; font-size: 12px; }
|
|
20
|
+
[role=alert] { color: #ff9a9a; }
|
|
21
|
+
meter { flex: 1; width: 100%; }
|
|
22
|
+
meter::-webkit-meter-bar { background: #222; border: 0; border-radius: 0; }
|
|
23
|
+
meter::-webkit-meter-optimum-value { background: #fff; }
|
|
24
|
+
meter::-moz-meter-bar { background: #fff; }
|
|
25
|
+
.done { text-decoration: line-through; }
|
|
26
|
+
.presence { padding: 12px 0; font-size: 13px; }
|
|
27
|
+
@media (max-width: 480px) { main { padding: 20px 12px; } li { flex-wrap: wrap; } }
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { capsule, table, query, mutation, string, boolean } from "eraspace/server";
|
|
2
|
+
export default capsule({
|
|
3
|
+
schema: { todos: table({ title: string(), done: boolean().default(false) }) },
|
|
4
|
+
queries: { list: query(async ctx => ctx.db.todos.order("desc").take(200)) },
|
|
5
|
+
mutations: {
|
|
6
|
+
add: mutation(async (ctx, title: string) => {
|
|
7
|
+
const trimmed = title.trim();
|
|
8
|
+
if (!trimmed || trimmed.length > 200) throw new Error("Use a title of 1 to 200 characters.");
|
|
9
|
+
return ctx.db.todos.insert({ title: trimmed, done: false });
|
|
10
|
+
}),
|
|
11
|
+
toggle: mutation(async (ctx, id: string) => {
|
|
12
|
+
const todo = await ctx.db.todos.get(id);
|
|
13
|
+
if (!todo) throw new Error("Todo no longer exists.");
|
|
14
|
+
await ctx.db.todos.update(id, { done: !todo.done });
|
|
15
|
+
}),
|
|
16
|
+
remove: mutation(async (ctx, id: string) => ctx.db.todos.delete(id)),
|
|
17
|
+
},
|
|
18
|
+
});
|