@atom.io/template-react-node-backend 0.0.2 → 0.0.3

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.
@@ -0,0 +1 @@
1
+ :root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;color:#ffffffde;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#242424;font-family:Charter,serif;font-weight:400;line-height:1.5}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}@media (prefers-color-scheme:light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}a{color:#646cff;-webkit-text-decoration:inherit;text-decoration:inherit;font-weight:500}a:hover{color:#535bf2}body{flex-flow:column;place-items:center;min-width:320px;min-height:100vh;margin:0;display:flex}body #root{flex-flow:column;place-items:center;width:100%;height:100%;display:flex}body #root>header{z-index:100;box-sizing:border-box;justify-content:flex-end;align-items:center;gap:10px;width:100%;padding:20px;display:flex;position:fixed;top:0;left:0}body #root>header>.spacer{flex-grow:1}body #root>header>.pfp{border:1px solid #ccc3;border-radius:50%;place-content:center;place-items:center;width:40px;height:40px;display:flex;position:relative}body #root>header>.pfp.signed-in:before{content:"👤"}body #root>main{flex-direction:column;gap:10px;width:100%;max-width:600px;height:100%;display:flex;position:relative}body #root>main>header{flex-flow:column;justify-content:flex-end;height:200px;display:flex}body #root>main>header>h1{margin:0;padding:0;font-size:72px;line-height:72px}body #root>main>main{flex-flow:column;gap:5px;height:100%;display:flex;position:relative}body #root>main>main>.todo{box-sizing:border-box;text-align:center;border:1px solid #ccc3;flex-flow:row;place-items:center;gap:5px;width:100%;height:29px;padding:0 3px;font-size:18px;display:flex;position:relative}body #root>main>main>.todo>span,body #root>main>main>.todo input[type=text]{text-align:left;flex-grow:1;font-family:Charter,serif;font-size:18px}body #root>main>main>.todo>button.delete:disabled{opacity:.5;background:0 0}body #root>main>main>.todo>button.delete:after{content:"×"}body #root>.takeover{z-index:100;box-sizing:border-box;background-color:#00000080;place-content:center;place-items:center;width:100svw;height:100svh;padding-bottom:60px;display:flex;position:fixed;top:0;left:0}body #root>.takeover>main.card{background-color:#444;flex-direction:column;place-items:center;gap:10px;display:flex}@media (prefers-color-scheme:light){body #root>.takeover>main.card{background-color:#fff}}body #root>.takeover>main.card{border-radius:8px;width:100%;max-width:200px;padding:20px}.loading{background:linear-gradient(90deg,#7773,#7771,#7773) 0 0/200% 100%;animation:4s ease-in-out infinite pulse}.loading:after{content:"⏳";color:#fff;font-size:10px;position:absolute;top:-5px;right:-5px}button{border:1px solid #0000;border-radius:8px}@keyframes pulse{0%{background-position:200% 0}50%{background-position:0 0}to{background-position:200% 0}}
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>vite-react</title>
8
- <script type="module" crossorigin src="/assets/index-CKoSUs2U.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-By2j7w9s.css">
8
+ <script type="module" crossorigin src="/assets/index-CNf_2_tw.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CqTfTEH3.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/node/server.ts CHANGED
@@ -1,10 +1,33 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node --watch
2
2
 
3
3
  import * as http from "node:http"
4
- import { parse as parseUrl } from "node:url"
4
+ import { DatabaseSync } from "node:sqlite"
5
+
6
+ import { atom, getState, setState } from "atom.io"
5
7
 
6
8
  const PORT = process.env.PORT ?? 3000
7
- const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN ?? `http://localhost:5173`
9
+ const SERVER_ORIGIN = `http://localhost:3000`
10
+ const FRONTEND_ORIGIN = `http://localhost:5173`
11
+
12
+ const db = new DatabaseSync(`:memory:`)
13
+ db.exec(`
14
+ CREATE TABLE todos (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ text TEXT NOT NULL,
17
+ done INTEGER DEFAULT 0
18
+ )
19
+ `)
20
+
21
+ const insertStmt = db.prepare(`INSERT INTO todos (text) VALUES (?)`)
22
+ const getAllStmt = db.prepare(`SELECT * FROM todos ORDER BY id`)
23
+ const getOneStmt = db.prepare(`SELECT * FROM todos WHERE id = ?`)
24
+ const updateStmt = db.prepare(`UPDATE todos SET done = ? WHERE id = ?`)
25
+ const deleteStmt = db.prepare(`DELETE FROM todos WHERE id = ?`)
26
+
27
+ const longLoadTimesAtom = atom<boolean>({
28
+ key: `longLoadTimes`,
29
+ default: false,
30
+ })
8
31
 
9
32
  function parseCookies(cookieHeader = ``) {
10
33
  return Object.fromEntries(
@@ -35,66 +58,132 @@ function sendJSON(
35
58
  }
36
59
 
37
60
  const server = http.createServer(async (req, res) => {
38
- const r = req as http.IncomingMessage & { url: string; method: string }
61
+ try {
62
+ const r = req as http.IncomingMessage & { url: string; method: string }
39
63
 
40
- const { pathname, query } = parseUrl(r.url, true)
64
+ const { pathname, searchParams } = new URL(r.url, SERVER_ORIGIN)
41
65
 
42
- console.log(r.method, pathname, { ...query })
66
+ console.log(r.method, pathname, [...searchParams.entries()])
43
67
 
44
- const cookies = parseCookies(r.headers.cookie)
68
+ const cookies = parseCookies(r.headers.cookie)
45
69
 
46
- if (req.method === `OPTIONS`) {
47
- res.writeHead(204, {
48
- "Access-Control-Allow-Origin": FRONTEND_ORIGIN,
49
- "Access-Control-Allow-Credentials": `true`,
50
- "Access-Control-Allow-Methods": `GET,POST,OPTIONS`,
51
- "Access-Control-Allow-Headers": `Content-Type`,
52
- })
53
- return res.end()
54
- }
55
-
56
- // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
57
- switch (pathname) {
58
- case `/redirect`: {
59
- const token = query.token
60
- if (typeof token !== `string`) {
61
- sendJSON(res, 400, { error: `Missing token` })
62
- return
63
- }
64
-
65
- res.writeHead(302, {
66
- "Set-Cookie": `auth_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=10`,
70
+ if (req.method === `OPTIONS`) {
71
+ res.writeHead(204, {
67
72
  "Access-Control-Allow-Origin": FRONTEND_ORIGIN,
68
73
  "Access-Control-Allow-Credentials": `true`,
69
- Location: FRONTEND_ORIGIN,
74
+ "Access-Control-Allow-Methods": `GET,POST,PUT,DELETE,OPTIONS`,
75
+ "Access-Control-Allow-Headers": `Content-Type`,
70
76
  })
71
77
  return res.end()
72
78
  }
73
- case `/random`: {
74
- await new Promise((resolve) => setTimeout(resolve, 1000))
75
- const token = cookies[`auth_token`]
76
- console.log({ token })
77
- if (!token) {
78
- sendJSON(res, 401, { error: `Unauthenticated` }, true)
79
+
80
+ switch (pathname) {
81
+ case `/redirect`:
82
+ {
83
+ const token = searchParams.get(`token`)
84
+ if (typeof token !== `string`) {
85
+ sendJSON(res, 400, { error: `Missing token` })
86
+ return
87
+ }
88
+ res.writeHead(302, {
89
+ "Set-Cookie": `auth_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=1000`,
90
+ "Access-Control-Allow-Origin": FRONTEND_ORIGIN,
91
+ "Access-Control-Allow-Credentials": `true`,
92
+ Location: FRONTEND_ORIGIN,
93
+ })
94
+ res.end()
95
+ }
96
+ return
97
+ case `/long-load-times`:
98
+ switch (r.method) {
99
+ case `GET`:
100
+ break
101
+ case `POST`:
102
+ setState(longLoadTimesAtom, (prev) => !prev)
103
+ break
104
+ default:
105
+ sendJSON(res, 405, { error: `Method not allowed` }, true)
106
+ return
107
+ }
108
+ sendJSON(res, 200, getState(longLoadTimesAtom), true)
79
109
  return
80
- }
81
110
 
82
- const random = Math.floor(Math.random() * 100)
83
- sendJSON(res, 200, random, true)
84
- return
85
- }
86
- case `/logout`: {
87
- res.writeHead(302, {
88
- "Set-Cookie": `auth_token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`,
89
- "Access-Control-Allow-Origin": FRONTEND_ORIGIN,
90
- "Access-Control-Allow-Credentials": `true`,
91
- Location: FRONTEND_ORIGIN,
92
- })
93
- return res.end()
111
+ case `/todos`:
112
+ {
113
+ const longLoadTimes = getState(longLoadTimesAtom)
114
+ if (longLoadTimes) {
115
+ await new Promise((resolve) => setTimeout(resolve, 1000))
116
+ }
117
+ const token = cookies[`auth_token`]
118
+ if (!token) {
119
+ sendJSON(res, 401, { error: `Unauthenticated` }, true)
120
+ return
121
+ }
122
+ switch (r.method) {
123
+ case `GET`:
124
+ {
125
+ const id = Number.parseInt(searchParams.get(`id`) as string, 10)
126
+ if (Number.isNaN(id)) {
127
+ sendJSON(res, 200, { todos: getAllStmt.all() }, true)
128
+ } else {
129
+ const todo = getOneStmt.get(id)
130
+ sendJSON(res, 200, { todo }, true)
131
+ }
132
+ }
133
+ return
134
+ case `POST`:
135
+ {
136
+ let body = ``
137
+ for await (const chunk of r) body += chunk
138
+ const { lastInsertRowid } = insertStmt.run(body)
139
+ const todo = getOneStmt.get(lastInsertRowid)
140
+ sendJSON(res, 200, { todo }, true)
141
+ }
142
+ return
143
+ case `PUT`:
144
+ {
145
+ const id = Number.parseInt(searchParams.get(`id`) as string, 10)
146
+ if (Number.isNaN(id)) {
147
+ sendJSON(res, 400, { error: `Invalid id` }, true)
148
+ return
149
+ }
150
+ let body = ``
151
+ for await (const chunk of r) body += chunk
152
+ const nowChecked = JSON.parse(body)
153
+ updateStmt.run(nowChecked, id)
154
+ sendJSON(res, 200, { success: true }, true)
155
+ }
156
+ return
157
+ case `DELETE`:
158
+ {
159
+ const id = Number.parseInt(searchParams.get(`id`) as string, 10)
160
+ deleteStmt.run(id)
161
+ sendJSON(res, 200, { success: true }, true)
162
+ }
163
+ return
164
+ default:
165
+ sendJSON(res, 405, { error: `Method not allowed` }, true)
166
+ }
167
+ }
168
+ return
169
+ case `/logout`:
170
+ {
171
+ res.writeHead(302, {
172
+ "Set-Cookie": `auth_token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`,
173
+ "Access-Control-Allow-Origin": FRONTEND_ORIGIN,
174
+ "Access-Control-Allow-Credentials": `true`,
175
+ Location: FRONTEND_ORIGIN,
176
+ })
177
+ res.end()
178
+ }
179
+ return
180
+ default:
181
+ sendJSON(res, 404, { error: `Not found` })
94
182
  }
183
+ } catch (thrown) {
184
+ console.error(thrown)
185
+ sendJSON(res, 500, null, true)
95
186
  }
96
-
97
- sendJSON(res, 404, { error: `Not found` })
98
187
  })
99
188
 
100
189
  // Start server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atom.io/template-react-node-backend",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,6 +8,7 @@
8
8
  "dependencies": {
9
9
  "react": "19.2.0",
10
10
  "react-dom": "19.2.0",
11
+ "zod": "4.1.12",
11
12
  "atom.io": "0.44.3"
12
13
  },
13
14
  "devDependencies": {
package/src/App.tsx CHANGED
@@ -1,29 +1,128 @@
1
- import { atom, type Loadable } from "atom.io"
2
- import { useLoadable } from "atom.io/react"
1
+ import type { Loadable } from "atom.io"
2
+ import {
3
+ atom,
4
+ atomFamily,
5
+ disposeState,
6
+ getState,
7
+ resetState,
8
+ selector,
9
+ setState,
10
+ } from "atom.io"
11
+ import { useI, useLoadable, useO } from "atom.io/react"
12
+ import { useCallback } from "react"
13
+ import z from "zod"
3
14
 
4
15
  const SERVER_URL = `http://localhost:3000`
5
16
  const AUTHENTICATOR_URL = `http://localhost:4000`
6
17
 
7
- const randomAtom = atom<Loadable<number>, Error>({
8
- key: `random`,
18
+ const todoSchema = z.object({
19
+ id: z.number(), // real keys are integers; virtual keys are made with Math.random()
20
+ text: z.string(),
21
+ done: z.union([z.literal(0), z.literal(1)]), // keeps things simple with sqlite
22
+ })
23
+ type Todo = z.infer<typeof todoSchema>
24
+ const todoKeysAtom = atom<Loadable<number[]>, Error>({
25
+ key: `todoKeys`,
9
26
  default: async () => {
10
- const url = new URL(`/random`, SERVER_URL)
27
+ const url = new URL(`/todos`, SERVER_URL)
28
+ const response = await fetch(url, { credentials: `include` })
29
+ if (!response.ok) throw new Error(response.status.toString())
30
+ const json = (await response.json()) as { todos: unknown }
31
+ const todos = todoSchema.array().parse(json.todos)
32
+ for (const todo of todos) setState(todoAtoms, todo.id, todo)
33
+ return todos.map((todo) => todo.id)
34
+ },
35
+ catch: [Error],
36
+ })
37
+
38
+ const todoAtoms = atomFamily<Loadable<Todo>, number, Error>({
39
+ key: `todos`,
40
+ default: async (id) => {
41
+ const url = new URL(`/todos`, SERVER_URL)
42
+ url.searchParams.set(`id`, id.toString())
11
43
  const response = await fetch(url, { credentials: `include` })
12
44
  if (!response.ok) throw new Error(response.status.toString())
13
- const data = (await response.json()) as unknown
14
- if (typeof data === `number`) return data
15
- console.error(`Unexpected response from server`, data)
16
- return 0
45
+ const json = (await response.json()) as { todo: unknown }
46
+ const todo = todoSchema.parse(json.todo)
47
+ return todo
17
48
  },
18
49
  catch: [Error],
19
50
  })
20
51
 
21
- function App(): React.JSX.Element {
22
- const { error, value, loading } = useLoadable(randomAtom, 0)
52
+ const todosStatsSelector = selector<
53
+ Loadable<{
54
+ total: number
55
+ done: number
56
+ }>
57
+ >({
58
+ key: `todosStats`,
59
+ get: async ({ get }) => {
60
+ const todoKeys = await get(todoKeysAtom)
61
+ if (Error.isError(todoKeys)) return { total: 0, done: 0 }
62
+ const total = todoKeys.length
63
+ const todos = await Promise.all(todoKeys.map((id) => get(todoAtoms, id)))
64
+ const done = todos.filter((todo) => !Error.isError(todo) && todo.done).length
65
+ return { total, done }
66
+ },
67
+ })
68
+
69
+ async function addTodo() {
70
+ const text = getState(newTodoTextAtom)
71
+ setState(newTodoTextAtom, ``)
72
+ const url = new URL(`todos`, SERVER_URL)
73
+ const tempId = Math.random()
74
+ setState(todoAtoms, tempId, { id: tempId, text, done: 0 })
75
+ setState(todoKeysAtom, async (loadable) => {
76
+ const prev = await loadable
77
+ if (Error.isError(prev)) return prev
78
+ return [...prev, tempId]
79
+ })
80
+ const res = await fetch(url, {
81
+ method: `POST`,
82
+ credentials: `include`,
83
+ body: text,
84
+ })
85
+ if (!res.ok) throw new Error(res.status.toString())
86
+ const { todo } = await res.json()
87
+ const realId = todo.id
88
+ setState(todoAtoms, realId, todo)
89
+ setState(todoKeysAtom, async (loadable) => {
90
+ const prev = await loadable
91
+ if (Error.isError(prev)) return prev
92
+ return prev.map((id) => (id === tempId ? realId : id))
93
+ })
94
+ }
95
+
96
+ async function deleteTodo() {
97
+ const todoKeys = await getState(todoKeysAtom)
98
+ if (Error.isError(todoKeys)) return
99
+ const todoKey = todoKeys.find((key) => !Error.isError(key))
100
+ if (todoKey === undefined) return
101
+ setState(todoKeysAtom, async (loadable) => {
102
+ const prev = await loadable
103
+ if (Error.isError(prev)) return prev
104
+ return prev.filter((id) => id !== todoKey)
105
+ })
106
+ disposeState(todoAtoms, todoKey)
107
+ const url = new URL(`/todos`, SERVER_URL)
108
+ url.searchParams.set(`id`, todoKey.toString())
109
+ await fetch(url, {
110
+ method: `DELETE`,
111
+ credentials: `include`,
112
+ })
113
+ }
114
+
115
+ const TODO_FALLBACK: Todo = { id: 0, text: ``, done: 0 }
116
+ const SKELETON_KEYS = Array.from({ length: 5 }).map(Math.random)
117
+ for (const key of SKELETON_KEYS) setState(todoAtoms, key, TODO_FALLBACK)
118
+
119
+ export function App(): React.JSX.Element {
120
+ const todoKeys = useLoadable(todoKeysAtom, SKELETON_KEYS)
121
+ const stats = useLoadable(todosStatsSelector, { total: 0, done: 0 })
23
122
 
24
123
  return (
25
- <main>
26
- {error ? (
124
+ <>
125
+ {todoKeys.error ? (
27
126
  <article className="takeover">
28
127
  <main className="card">
29
128
  <h1>Signed Out</h1>
@@ -39,9 +138,11 @@ function App(): React.JSX.Element {
39
138
  </article>
40
139
  ) : null}
41
140
  <header>
42
- {error ? (
141
+ <LongLoadTimes />
142
+ <span className="spacer" />
143
+ {todoKeys.error ? (
43
144
  <div className="pfp signed-out" />
44
- ) : loading ? (
145
+ ) : todoKeys.loading ? (
45
146
  <div className="pfp loading" />
46
147
  ) : (
47
148
  <>
@@ -57,13 +158,119 @@ function App(): React.JSX.Element {
57
158
  </>
58
159
  )}
59
160
  </header>
60
- {loading ? (
61
- <div className="data loading">{value}</div>
62
- ) : (
63
- <div className="data">{value}</div>
64
- )}
65
- </main>
161
+ <main>
162
+ <header>
163
+ <h1>todo list</h1>
164
+ <span>
165
+ {stats.value.done}/{stats.value.total} items done
166
+ </span>
167
+ </header>
168
+ <main>
169
+ {todoKeys.value.map((todoKey) => (
170
+ <Todo key={todoKey} todoKey={todoKey} />
171
+ ))}
172
+ <NewTodo />
173
+ </main>
174
+ </main>
175
+ </>
176
+ )
177
+ }
178
+
179
+ function Todo({ todoKey }: { todoKey: number }): React.JSX.Element {
180
+ const todo = useLoadable(todoAtoms, todoKey, TODO_FALLBACK)
181
+ const isSuspended = todo.loading || !Number.isInteger(todoKey)
182
+ const toggle = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
183
+ const url = new URL(`todos`, SERVER_URL)
184
+ url.searchParams.set(`id`, todo.value.id.toString())
185
+ const nowChecked = e.target.checked ? 1 : 0
186
+ setState(todoAtoms, todoKey, async (loadable) => {
187
+ const prev = await loadable
188
+ if (Error.isError(prev)) return prev
189
+ return { ...prev, done: nowChecked } satisfies Todo
190
+ })
191
+ await fetch(url, {
192
+ method: `PUT`,
193
+ credentials: `include`,
194
+ body: nowChecked.toString(),
195
+ })
196
+ resetState(todoAtoms, todoKey)
197
+ }, [])
198
+ return (
199
+ <div className={cn(`todo`, isSuspended && `loading`)}>
200
+ <input
201
+ type="checkbox"
202
+ checked={Boolean(todo.value.done)}
203
+ onChange={toggle}
204
+ disabled={isSuspended}
205
+ />
206
+ <span>{todo.value.text}</span>
207
+ <button
208
+ type="button"
209
+ className="delete"
210
+ onClick={deleteTodo}
211
+ disabled={isSuspended}
212
+ />
213
+ </div>
214
+ )
215
+ }
216
+
217
+ const newTodoTextAtom = atom<string>({
218
+ key: `newTodo`,
219
+ default: ``,
220
+ })
221
+
222
+ function NewTodo(): React.JSX.Element {
223
+ const text = useO(newTodoTextAtom)
224
+ const setText = useI(newTodoTextAtom)
225
+ const change = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
226
+ setText(e.target.value)
227
+ }, [])
228
+ const submit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
229
+ e.preventDefault()
230
+ void addTodo()
231
+ }, [])
232
+ return (
233
+ <form className="todo" onSubmit={submit}>
234
+ <input type="checkbox" checked={false} disabled />
235
+ <input
236
+ type="text"
237
+ contentEditable
238
+ suppressContentEditableWarning
239
+ value={text}
240
+ onChange={change}
241
+ />
242
+ <button type="submit">Add Todo</button>
243
+ </form>
244
+ )
245
+ }
246
+
247
+ const longLoadTimesAtom = atom<Loadable<boolean>>({
248
+ key: `longLoadTimes`,
249
+ default: () =>
250
+ fetch(new URL(`/long-load-times`, SERVER_URL), {
251
+ credentials: `include`,
252
+ }).then(async (res) => res.json()),
253
+ })
254
+
255
+ function LongLoadTimes(): React.JSX.Element {
256
+ const longLoadTimes = useLoadable(longLoadTimesAtom, false)
257
+ const toggle = useCallback(async () => {
258
+ const url = new URL(`/long-load-times`, SERVER_URL)
259
+ const res = await fetch(url, {
260
+ method: `POST`,
261
+ credentials: `include`,
262
+ })
263
+ const newState = await res.json()
264
+ setState(longLoadTimesAtom, newState)
265
+ }, [longLoadTimes])
266
+ return (
267
+ <div className="long-load-times">
268
+ <label>
269
+ <input type="checkbox" checked={longLoadTimes.value} onChange={toggle} />
270
+ Enable long load times
271
+ </label>
272
+ </div>
66
273
  )
67
274
  }
68
275
 
69
- export default App
276
+ const cn = (...c: (boolean | string)[]) => c.filter(Boolean).join(` `)