edge-city

#react#js#ssr

git clone https://git.pyrossh.dev/edge-city

edge-city is a next level meta-framework for react that runs only on edge runtimes


e284d5b — Peter John 2023-05-20T20:20:34+05:30
initial build

Generated files not shown: pnpm-lock.yaml

Files changed (46) hide show
  1. .gitignore +1 -0
  2. packages/cli/index.js +388 -0
  3. packages/cli/package.json +27 -0
  4. packages/example/.gitignore +4 -0
  5. packages/example/.vscode/extensions.json +5 -0
  6. packages/example/.vscode/settings.json +13 -0
  7. packages/example/Dockerfile +8 -0
  8. packages/example/components/Counter/Counter.jsx +16 -0
  9. packages/example/components/Layout/Layout.css +5 -0
  10. packages/example/components/Layout/Layout.jsx +19 -0
  11. packages/example/components/Timer/Timer.jsx +18 -0
  12. packages/example/components/Todo/Todo.css +16 -0
  13. packages/example/components/Todo/Todo.jsx +94 -0
  14. packages/example/db/index.js +20 -0
  15. packages/example/db/migrations/0000_empty_shatterstar.sql +7 -0
  16. packages/example/db/migrations/meta/0000_snapshot.json +54 -0
  17. packages/example/db/migrations/meta/_journal.json +12 -0
  18. packages/example/drizzle.config.json +4 -0
  19. packages/example/jsconfig.json +9 -0
  20. packages/example/main.js +6 -0
  21. packages/example/package.json +59 -0
  22. packages/example/pages/_404/page.css +38 -0
  23. packages/example/pages/_404/page.jsx +19 -0
  24. packages/example/pages/_500/page.css +38 -0
  25. packages/example/pages/_500/page.jsx +19 -0
  26. packages/example/pages/about/page.css +10 -0
  27. packages/example/pages/about/page.jsx +28 -0
  28. packages/example/pages/page.css +17 -0
  29. packages/example/pages/page.jsx +29 -0
  30. packages/example/pages/page.spec.js +19 -0
  31. packages/example/pages/todos/page.css +71 -0
  32. packages/example/pages/todos/page.jsx +61 -0
  33. packages/example/playwright.config.js +65 -0
  34. packages/example/readme.md +18 -0
  35. packages/example/services/auth.service.js +50 -0
  36. packages/example/services/todos.service.js +49 -0
  37. packages/example/services/todos.service.test.js +41 -0
  38. packages/example/static/favicon.ico +0 -0
  39. packages/example/static/logo192.png +0 -0
  40. packages/example/static/logo512.png +0 -0
  41. packages/example/static/manifest.json +25 -0
  42. packages/example/static/robots.txt +3 -0
  43. packages/runtime/index.js +315 -0
  44. packages/runtime/package.json +14 -0
  45. pnpm-workspace.yaml +2 -0
  46. readme.md +20 -0
.gitignore ADDED
@@ -0,0 +1 @@
1
+ node_modules/
packages/cli/index.js ADDED
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env bun
2
+ import meow from 'meow';
3
+ import React from "react";
4
+ import esbuild from 'esbuild';
5
+ import resolve from 'esbuild-plugin-resolve';
6
+ import { renderToReadableStream } from "react-dom/server";
7
+ import fs, { mkdir } from "fs";
8
+ import path from 'path';
9
+ import walkdir from 'walkdir';
10
+ import postcss from "postcss"
11
+ import autoprefixer from "autoprefixer";
12
+ import postcssCustomMedia from "postcss-custom-media";
13
+ import postcssNesting from "postcss-nesting";
14
+ import mimeTypes from "mime-types";
15
+ import bytes from 'bytes';
16
+ import pc from 'picocolors';
17
+ import ms from 'ms';
18
+ import pkg from "./package.json";
19
+
20
+ const cli = meow(`
21
+ parotta v${pkg.version}
22
+
23
+ Usage
24
+ $ parotta build cloudflare
25
+ $ parotta build vercel
26
+ `, {
27
+ importMeta: import.meta,
28
+ autoVersion: true,
29
+ });
30
+ if (cli.input.length != 2) {
31
+ cli.showHelp();
32
+ process.exit(0);
33
+ }
34
+
35
+
36
+
37
+ if (!globalThis.firstRun) {
38
+ globalThis.firstRun = true
39
+ const version = (await import(path.join(import.meta.dir, "package.json"))).default.version;
40
+ console.log(`parotta v${version}`)
41
+ console.log(`running with cwd=${path.basename(process.cwd())} node_env=${process.env.NODE_ENV}`);
42
+ } else {
43
+ console.log(`server reloading`);
44
+ }
45
+ const isProd = process.env.NODE_ENV === "production";
46
+ const routes = walkdir.sync(path.join(process.cwd(), "pages"))
47
+ .filter((p) => p.includes("page.jsx"));
48
+ const services = walkdir.sync(path.join(process.cwd(), "services"))
49
+ .map((s) => s.replace(process.cwd(), ""))
50
+ .filter((s) => s.includes(".service.js"))
51
+ .forEach((s) => {
52
+ const serviceName = s.replace(".service.js", "");
53
+ routes[serviceName + "/*"] = { key: serviceName, service: s };
54
+ });
55
+
56
+ const mapDeps = (dir) => {
57
+ return walkdir.sync(path.join(process.cwd(), dir))
58
+ .map((s) => s.replace(process.cwd(), ""))
59
+ .filter((s) => s.includes(".jsx") || s.includes(".js"))
60
+ .reduce((acc, s) => {
61
+ if (s.includes(".jsx")) {
62
+ acc['@' + s.replace(".jsx", "")] = s
63
+ }
64
+ if (s.includes(".js")) {
65
+ acc['@' + s.replace(".js", "")] = s
66
+ }
67
+ return acc;
68
+ }, {});
69
+ }
70
+
71
+ const staticDir = path.join(process.cwd(), "build", "static");
72
+
73
+ const createDirs = () => {
74
+ if (!fs.existsSync(staticDir)) {
75
+ fs.mkdirSync(staticDir, { recursive: true });
76
+ }
77
+ }
78
+
79
+ const buildImportMap = async () => {
80
+ const packageJson = await import(path.join(process.cwd(), "package.json"));
81
+ const config = packageJson.default.parotta || { hydrate: true };
82
+ const devTag = !isProd ? "-dev-" : "";
83
+ const devQueryParam = !isProd ? `?dev` : "";
84
+ const nodeDeps = Object.keys(packageJson.default.dependencies).reduce((acc, dep) => {
85
+ acc[dep] = `https://esm.sh/${dep}@${packageJson.default.dependencies[dep]}`;
86
+ return acc;
87
+ }, {})
88
+ const components = mapDeps("components");
89
+ const importmap = {
90
+ "radix3": `https://esm.sh/[email protected]`,
91
+ "history": "https://esm.sh/[email protected]",
92
+ "react": `https://esm.sh/[email protected]${devQueryParam}`,
93
+ [`react/jsx${devTag}runtime`]: `https://esm.sh/[email protected]${devQueryParam}/jsx${devTag}runtime`,
94
+ "react-dom/client": `https://esm.sh/[email protected]${devQueryParam}/client`,
95
+ "nprogress": "https://esm.sh/[email protected]",
96
+ ...nodeDeps,
97
+ ...components,
98
+ }
99
+ const outfile = path.join(staticDir, "importmap.json");
100
+ fs.writeFileSync(outfile, JSON.stringify(importmap, null, 2));
101
+ }
102
+
103
+ const buildRouteMap = () => {
104
+ const routemap = routes.reduce((acc, p) => {
105
+ const r = p.replace(process.cwd(), "");
106
+ const key = r.replace("/pages", "").replace("/page.jsx", "")
107
+ acc[key === "" ? "/" : key] = r;
108
+ return acc
109
+ }, {});
110
+ const outfile = path.join(staticDir, "routemap.json");
111
+ fs.writeFileSync(outfile, JSON.stringify(routemap, null, 2));
112
+ }
113
+
114
+ const buildServer = async (r) => {
115
+ const buildStart = Date.now();
116
+ const shortName = r.replace(process.cwd(), "").replace("/pages", "");
117
+ const outfile = `${process.cwd()}/build/functions${shortName.replace("page.jsx", "index.js")}`;
118
+ const result = await esbuild.build({
119
+ bundle: true,
120
+ target: ['es2022'],
121
+ entryPoints: [r],
122
+ outfile: outfile,
123
+ format: 'esm',
124
+ keepNames: true,
125
+ external: ["node:*"],
126
+ color: true,
127
+ treeShaking: true,
128
+ // metafile: true,
129
+ jsxDev: !isProd,
130
+ jsx: 'automatic',
131
+ define: {
132
+ 'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`,
133
+ },
134
+ plugins: [resolve({
135
+ "/static/routemap.json": `${staticDir}/routemap.json`
136
+ })]
137
+ });
138
+ // console.log(await analyzeMetafile(result.metafile))
139
+ const outLength = fs.statSync(outfile).size;
140
+ const builtTime = ms(Date.now() - buildStart);
141
+ console.log(
142
+ `${pc.green("✓ Bundled")} ${outfile.replace(process.cwd() + "/", "")} ${pc.cyan(`(${bytes(outLength)})`)} ${pc.gray(`[${builtTime}]`)}`
143
+ );
144
+ }
145
+
146
+ const bundleBun = async (r) => {
147
+ const buildStart = Date.now();
148
+ const shortName = r.replace(process.cwd(), "").replace("/page.jsx", "");
149
+ const result = await Bun.build({
150
+ entrypoints: [r],
151
+ outdir: `${process.cwd()}/bb/functions/${shortName}`,
152
+ });
153
+ if (!result.success) {
154
+ console.error("Build failed");
155
+ for (const message of result.logs) {
156
+ // Bun will pretty print the message object
157
+ console.error(message);
158
+ }
159
+ }
160
+ for (const o of result.outputs) {
161
+ const outLength = (await o.arrayBuffer()).byteLength;
162
+ const builtTime = ms(Date.now() - buildStart);
163
+ console.log(
164
+ `✓ Bundled ${o.kind} ${o.path.replace(process.cwd() + "/bb", "")} ${pc.cyan(`(${bytes(outLength)})`)} ${pc.gray(`[${builtTime}]`)}`
165
+ );
166
+ }
167
+ }
168
+
169
+ const main = async () => {
170
+ createDirs();
171
+ buildImportMap();
172
+ buildRouteMap();
173
+ for (const r of routes) {
174
+ buildServer(r);
175
+ }
176
+ }
177
+
178
+ main();
179
+
180
+ // const createServerRouter = async () => {
181
+ // const routes = {};
182
+ // const dirs = walkdir.sync(path.join(process.cwd(), "pages"))
183
+ // .map((s) => s.replace(process.cwd(), "")
184
+ // .replace("/pages", "")
185
+ // // .replaceAll("[", ":")
186
+ // // .replaceAll("]", "")
187
+ // )
188
+
189
+ // dirs.filter((p) => p.includes('page.jsx'))
190
+ // .map((s) => ({ path: s, route: s.replace("/page.jsx", "") }))
191
+ // .forEach((page) => {
192
+ // const key = page.route || "/";
193
+ // routes[key] = { key: key, page: page.path };
194
+ // });
195
+ // walkdir.sync(path.join(process.cwd(), "static"))
196
+ // .map((s) => s.replace(process.cwd(), "").replace("/static", ""))
197
+ // .forEach((route) => {
198
+ // routes[route] = { key: route, file: route }
199
+ // });
200
+
201
+ // return createRouter({
202
+ // strictTrailingSlash: true,
203
+ // routes: routes,
204
+ // });
205
+ // }
206
+
207
+ // const createClientRouter = async () => {
208
+ // const routes = await walkdir.sync(path.join(process.cwd(), "pages"))
209
+ // .filter((p) => p.includes("page.jsx"))
210
+ // .filter((p) => !p.includes("/_"))
211
+ // .map((s) => s.replace(process.cwd(), ""))
212
+ // .map((s) => s.replace("/pages", ""))
213
+ // .map((s) => s.replace("/page.jsx", ""))
214
+ // .reduce(async (accp, r) => {
215
+ // const acc = await accp;
216
+ // const src = await import(`${process.cwd()}/pages${r}/page.jsx`);
217
+ // if (!result.success) {
218
+ // console.error("Build failed");
219
+ // for (const message of result.logs) {
220
+ // // Bun will pretty print the message object
221
+ // console.error(message);
222
+ // }
223
+ // }
224
+ // acc[r === "" ? "/" : r] = src.default;
225
+ // return acc
226
+ // }, Promise.resolve({}));
227
+ // // console.log(clientRoutes);
228
+ // };
229
+
230
+
231
+ // const serverRouter = await createServerRouter();
232
+ // const clientRouter = await createClientRouter();
233
+ // const transpiler = new Bun.Transpiler({
234
+ // loader: "jsx",
235
+ // autoImportJSX: true,
236
+ // jsxOptimizationInline: true,
237
+
238
+ // // TODO
239
+ // // autoImportJSX: false,
240
+ // // jsxOptimizationInline: false,
241
+ // });
242
+
243
+ // const renderApi = async (key, filePath, req) => {
244
+ // const url = new URL(req.url);
245
+ // const params = req.method === "POST" ? await req.json() : Object.fromEntries(url.searchParams);
246
+ // const funcName = url.pathname.replace(`${key}/`, "");
247
+ // const js = await import(path.join(process.cwd(), filePath));
248
+ // try {
249
+ // const result = await js[funcName](params);
250
+ // return new Response(JSON.stringify(result), {
251
+ // headers: { 'Content-Type': 'application/json' },
252
+ // status: 200,
253
+ // });
254
+ // } catch (err) {
255
+ // const message = err.format ? err.format() : err;
256
+ // return new Response(JSON.stringify(message), {
257
+ // headers: { 'Content-Type': 'application/json' },
258
+ // status: 400,
259
+ // });
260
+ // }
261
+
262
+ // }
263
+
264
+ // const renderCss = async (src) => {
265
+ // try {
266
+ // const cssText = await Bun.file(src).text();
267
+ // const result = await postcss([
268
+ // autoprefixer(),
269
+ // postcssCustomMedia(),
270
+ // // postcssNormalize({ browsers: 'last 2 versions' }),
271
+ // postcssNesting,
272
+ // ]).process(cssText, { from: src, to: src });
273
+ // return new Response(result.css, {
274
+ // headers: { 'Content-Type': 'text/css' },
275
+ // status: 200,
276
+ // });
277
+ // } catch (err) {
278
+ // return new Response(`Not Found`, {
279
+ // headers: { 'Content-Type': 'text/html' },
280
+ // status: 404,
281
+ // });
282
+ // }
283
+ // }
284
+
285
+ // const renderJs = async (srcFile) => {
286
+ // try {
287
+ // const jsText = await Bun.file(srcFile).text();
288
+ // const result = await transpiler.transform(jsText);
289
+ // // inject code which calls the api for that function
290
+ // const lines = result.split("\n");
291
+ // // lines.unshift(`import React from "react";`);
292
+
293
+ // // replace all .service imports which rpc interface
294
+ // let addRpcImport = false;
295
+ // lines.forEach((ln) => {
296
+ // if (ln.includes(".service")) {
297
+ // addRpcImport = true;
298
+ // const [importName, serviceName] = ln.match(/\@\/services\/(.*)\.service/);
299
+ // const funcsText = ln.replace(`from "${importName}"`, "").replace("import", "").replace("{", "").replace("}", "").replace(";", "");
300
+ // const funcsName = funcsText.split(",");
301
+ // funcsName.forEach((fnName) => {
302
+ // lines.push(`const ${fnName} = rpc("${serviceName}/${fnName.trim()}")`);
303
+ // })
304
+ // }
305
+ // })
306
+ // if (addRpcImport) {
307
+ // lines.unshift(`import { rpc } from "parotta/runtime";`);
308
+ // }
309
+ // // remove .css and .service imports
310
+ // const filteredJsx = lines.filter((ln) => !ln.includes(`.css"`) && !ln.includes(`.service"`)).join("\n");
311
+ // //.replaceAll("$jsx", "React.createElement");
312
+ // return new Response(filteredJsx, {
313
+ // headers: {
314
+ // 'Content-Type': 'application/javascript',
315
+ // },
316
+ // status: 200,
317
+ // });
318
+ // } catch (err) {
319
+ // return new Response(`Not Found`, {
320
+ // headers: { 'Content-Type': 'text/html' },
321
+ // status: 404,
322
+ // });
323
+ // }
324
+ // }
325
+
326
+ // const sendFile = async (src) => {
327
+ // try {
328
+ // const contentType = mimeTypes.lookup(src) || "application/octet-stream";
329
+ // const stream = await Bun.file(src).stream();
330
+ // return new Response(stream, {
331
+ // headers: { 'Content-Type': contentType },
332
+ // status: 200,
333
+ // });
334
+ // } catch (err) {
335
+ // return new Response(`Not Found`, {
336
+ // headers: { 'Content-Type': 'text/html' },
337
+ // status: 404,
338
+ // });
339
+ // }
340
+ // }
341
+
342
+ // // const mf = new Miniflare({
343
+ // // script: `
344
+ // // addEventListener("fetch", (event) => {
345
+ // // event.respondWith(new Response("Hello Miniflare!"));
346
+ // // });
347
+ // // `,
348
+ // // });
349
+ // // const res = await mf.dispatchFetch("http://localhost:3000/");
350
+ // // console.log(await res.text()); // Hello Miniflare!
351
+
352
+ // const server = async (req) => {
353
+ // const url = new URL(req.url);
354
+ // console.log(req.method, url.pathname);
355
+ // // maybe this is needed
356
+ // if (url.pathname.startsWith("/parotta/")) {
357
+ // return renderJs(path.join(import.meta.dir, url.pathname.replace("/parotta/", "")));
358
+ // }
359
+ // if (url.pathname.endsWith(".css")) {
360
+ // return renderCss(path.join(process.cwd(), url.pathname));
361
+ // }
362
+ // if (url.pathname.endsWith(".js") || url.pathname.endsWith(".jsx")) {
363
+ // return renderJs(path.join(process.cwd(), url.pathname));
364
+ // }
365
+ // const match = serverRouter.lookup(url.pathname);
366
+ // if (match && !match.key.includes("/_")) {
367
+ // if (match.file) {
368
+ // return sendFile(path.join(process.cwd(), `/static${match.file}`));
369
+ // }
370
+ // if (match.page && req.headers.get("Accept")?.includes('text/html')) {
371
+ // return renderPage(url);
372
+ // }
373
+ // if (match.service) {
374
+ // return renderApi(match.key, match.service, req);
375
+ // }
376
+ // }
377
+ // if (req.headers.get("Accept")?.includes('text/html')) {
378
+ // // not found html page
379
+ // return renderPage(new URL(`${url.protocol}//${url.host}/_404`));
380
+ // }
381
+ // // not found generic page
382
+ // return new Response(`{"message": "not found"}`, {
383
+ // headers: { 'Content-Type': 'application/json' },
384
+ // status: 404,
385
+ // });
386
+ // }
387
+
388
+ // export default server;
packages/cli/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "parotta-cli",
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "dependencies": {
6
+ "autoprefixer": "^10.4.14",
7
+ "bytes": "3.1.2",
8
+ "esbuild": "0.17.19",
9
+ "meow": "12.0.1",
10
+ "mime-types": "2.1.35",
11
+ "ms": "2.1.3",
12
+ "parotta-runtime": "workspace:*",
13
+ "picocolors": "1.0.0",
14
+ "postcss": "^8.4.21",
15
+ "postcss-custom-media": "^9.1.2",
16
+ "postcss-nesting": "^11.2.1",
17
+ "walkdir": "0.4.1",
18
+ "esbuild-plugin-resolve": "2.0.0"
19
+ },
20
+ "peerDependencies": {
21
+ "react": "*",
22
+ "react-dom": "*"
23
+ },
24
+ "bin": {
25
+ "parotta": "index.js"
26
+ }
27
+ }
packages/example/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ .env
3
+ test-results
4
+ build
packages/example/.vscode/extensions.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "recommendations": [
3
+ "ms-playwright.playwright"
4
+ ]
5
+ }
packages/example/.vscode/settings.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "files.exclude": {
3
+ "**/.git": true,
4
+ "**/.svn": true,
5
+ "**/.hg": true,
6
+ "**/CVS": true,
7
+ "**/.DS_Store": true,
8
+ "**/Thumbs.db": true,
9
+ "**/dist": true,
10
+ "**/playwright-report": true,
11
+ "test-results": true
12
+ }
13
+ }
packages/example/Dockerfile ADDED
@@ -0,0 +1,8 @@
1
+ FROM oven/bun:0.5.8
2
+
3
+ ENV NODE_ENV production
4
+
5
+ WORKDIR /app
6
+ COPY . /app
7
+
8
+ CMD ["bun", "start"]
packages/example/components/Counter/Counter.jsx ADDED
@@ -0,0 +1,16 @@
1
+ import React, { useState } from "react";
2
+
3
+ const Counter = () => {
4
+ const [count, setCount] = useState(5);
5
+ return (
6
+ <div>
7
+ <button onClick={() => setCount(count - 1)}>-</button>
8
+ <span className="count">
9
+ {count}
10
+ </span>
11
+ <button onClick={() => setCount(count + 1)}>+</button>
12
+ </div>
13
+ )
14
+ }
15
+
16
+ export default Counter;
packages/example/components/Layout/Layout.css ADDED
@@ -0,0 +1,5 @@
1
+ .layout-header {
2
+ & a {
3
+ margin-right: 20px;
4
+ }
5
+ }
packages/example/components/Layout/Layout.jsx ADDED
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { Link } from "parotta-runtime";
3
+ import "./Layout.css";
4
+
5
+ const Layout = ({ children }) => {
6
+ return (
7
+ <div>
8
+ <header className="layout-header">
9
+ <Link href="/about">About us</Link>
10
+ <Link href="/todos">Todos</Link>
11
+ </header>
12
+ <div>
13
+ {children}
14
+ </div>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export default Layout;
packages/example/components/Timer/Timer.jsx ADDED
@@ -0,0 +1,18 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ export default function Timer() {
4
+ const [counter, setCounter] = useState(0);
5
+ useEffect(() => {
6
+ const ref = setInterval(() => {
7
+ setCounter((c) => c + 1);
8
+ }, 100);
9
+ return () => {
10
+ clearInterval(ref);
11
+ }
12
+ }, []);
13
+ return (
14
+ <div>
15
+ <p>(This page is interactive while data is loading: {counter})</p>
16
+ </div>
17
+ );
18
+ }
packages/example/components/Todo/Todo.css ADDED
@@ -0,0 +1,16 @@
1
+ .todo {
2
+ & label {
3
+ list-style-type: none;
4
+ padding: 1em;
5
+ border-radius: 0.5em;
6
+ background-color: #ddd;
7
+ margin-top: 1em;
8
+ display: flex;
9
+ justify-content: space-between;
10
+ align-items: center;
11
+ }
12
+
13
+ & .done {
14
+ text-decoration: line-through;
15
+ }
16
+ }
packages/example/components/Todo/Todo.jsx ADDED
@@ -0,0 +1,94 @@
1
+ import { useState } from "react";
2
+ // import PropTypes from 'prop-types';
3
+ // import { Button, InputGroup } from "@blueprintjs/core";
4
+ // import useMutation from '@/hooks/useMutation';
5
+ // import { TodoPropType } from '@/models/Todo';
6
+ import "./Todo.css";
7
+
8
+ // const propTypes = {
9
+ // // todo: PropTypes.shape(TodoPropType).isRequired,
10
+ // }
11
+
12
+ const Todo = ({ todo }) => {
13
+ const [state, setState] = useState({ text: todo.text, editing: false });
14
+ // const updateMutation = useMutation(async (data) => {
15
+ // await onUpdate({ ...todo, ...data });
16
+ // await refetch();
17
+ // });
18
+ // const deleteMutation = useMutation(async () => {
19
+ // await onDelete(todo.id);
20
+ // await refetch();
21
+ // })
22
+ return (
23
+ <li className="todo">
24
+ {!state.editing && (
25
+ <label>
26
+ <input
27
+ type="checkbox"
28
+ checked={todo.completed}
29
+ onChange={(e) => {
30
+ // updateMutation.mutate({ completed: e.target.checked })
31
+ }}
32
+ />{" "}
33
+ <span className={todo.completed ? "done" : undefined}>{todo.text}</span>{" "}
34
+ </label>
35
+ )}
36
+
37
+ {/* {state.editing && (
38
+ <InputGroup
39
+ autoFocus
40
+ value={state.text}
41
+ onChange={(e) => setState({ text: e.target.value, editing: true })}
42
+ onKeyDown={async (e) => {
43
+ if (e.key === "Enter") {
44
+ await updateMutation.mutate({ text: state.text });
45
+ setState({ text: todo.text, editing: false });
46
+ } else if (e.key === "Escape") {
47
+ setState({ text: todo.text, editing: false });
48
+ }
49
+ }}
50
+ />
51
+ )}
52
+
53
+ <span>
54
+ {!todo.completed && !state.editing && (
55
+ <Button
56
+ onClick={() => setState({ text: todo.text, editing: true })}
57
+ >
58
+ Edit
59
+ </Button>
60
+ )}
61
+
62
+ {todo.completed && (
63
+ <Button loading={deleteMutation.isMutating} onClick={deleteMutation.mutate}>
64
+ Delete
65
+ </Button>
66
+ )}
67
+
68
+ {state.editing && state.text !== todo.text && (
69
+ <Button
70
+ loading={updateMutation.isMutating}
71
+ onClick={async () => {
72
+ await updateMutation.mutate({ text: state.text });
73
+ setState({ text: todo.text, editing: false });
74
+ }}
75
+ >
76
+ Save
77
+ </Button>
78
+ )}
79
+
80
+ {state.editing && (
81
+ <Button
82
+ onClick={() => setState({ text: todo.text, editing: false })}
83
+ >
84
+ Cancel
85
+ </Button>
86
+ )}
87
+ </span> */}
88
+ </li>
89
+ );
90
+ };
91
+
92
+ // Todo.propTypes = propTypes;
93
+
94
+ export default Todo;
packages/example/db/index.js ADDED
@@ -0,0 +1,20 @@
1
+ import { drizzle } from 'drizzle-orm/neon-serverless';
2
+ import { Pool } from '@neondatabase/serverless';
3
+ import { highlight } from 'sql-highlight';
4
+
5
+ export const pool = new Pool({ connectionString: process.env.PG_CONN_URL });
6
+ const db = drizzle(pool, {
7
+ logger: {
8
+ logQuery: (query, params) => {
9
+ const sqlString = params.reduce((acc, v, i) => acc.replaceAll("$" + (i + 1), v), query);
10
+ console.log(highlight(sqlString));
11
+ }
12
+ }
13
+ });
14
+
15
+ export default db;
16
+
17
+ // import { migrate } from 'drizzle-orm/neon-serverless/migrator';
18
+ // export const migrateAll = async () => {
19
+ // await migrate(db, { migrationsFolder: './db/migrations' });
20
+ // }
packages/example/db/migrations/0000_empty_shatterstar.sql ADDED
@@ -0,0 +1,7 @@
1
+ CREATE TABLE IF NOT EXISTS "todos" (
2
+ "id" serial PRIMARY KEY NOT NULL,
3
+ "text" text NOT NULL,
4
+ "completed" boolean NOT NULL,
5
+ "createdAt" date NOT NULL,
6
+ "updatedAt" date
7
+ );
packages/example/db/migrations/meta/0000_snapshot.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "version": "5",
3
+ "dialect": "pg",
4
+ "id": "7b876e3f-3db0-4282-b1c9-a5961b930b99",
5
+ "prevId": "00000000-0000-0000-0000-000000000000",
6
+ "tables": {
7
+ "todos": {
8
+ "name": "todos",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "serial",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "text": {
18
+ "name": "text",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "completed": {
24
+ "name": "completed",
25
+ "type": "boolean",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "createdAt": {
30
+ "name": "createdAt",
31
+ "type": "date",
32
+ "primaryKey": false,
33
+ "notNull": true
34
+ },
35
+ "updatedAt": {
36
+ "name": "updatedAt",
37
+ "type": "date",
38
+ "primaryKey": false,
39
+ "notNull": false
40
+ }
41
+ },
42
+ "indexes": {},
43
+ "foreignKeys": {},
44
+ "compositePrimaryKeys": {}
45
+ }
46
+ },
47
+ "enums": {},
48
+ "schemas": {},
49
+ "_meta": {
50
+ "schemas": {},
51
+ "tables": {},
52
+ "columns": {}
53
+ }
54
+ }
packages/example/db/migrations/meta/_journal.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": "5",
3
+ "dialect": "pg",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "5",
8
+ "when": 1683227001598,
9
+ "tag": "0000_empty_shatterstar"
10
+ }
11
+ ]
12
+ }
packages/example/drizzle.config.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "out": "./db/migrations/",
3
+ "schema": "./db/index.js"
4
+ }
packages/example/jsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "compilerOptions": {
3
+ "paths": {
4
+ "@/*": [
5
+ "./*"
6
+ ]
7
+ }
8
+ }
9
+ }
packages/example/main.js ADDED
@@ -0,0 +1,6 @@
1
+ import server from "parotta/server.js";
2
+
3
+ export default {
4
+ port: 3000,
5
+ fetch: server,
6
+ }
packages/example/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "parotta-example",
3
+ "type": "module",
4
+ "scripts": {
5
+ "start": "bun main.js",
6
+ "build": "parotta build cloudflare",
7
+ "run": "docker run -p 3000:3000 example",
8
+ "test": "bun test",
9
+ "test-e2e": "playwright test"
10
+ },
11
+ "dependencies": {
12
+ "@neondatabase/serverless": "^0.2.9",
13
+ "drizzle-orm": "0.26.0",
14
+ "normalize.css": "^8.0.1",
15
+ "react": "18.2.0",
16
+ "react-aria-components": "1.0.0-alpha.3",
17
+ "react-dom": "18.2.0",
18
+ "react-error-boundary": "4.0.4",
19
+ "react-helmet-async": "1.3.0",
20
+ "react-hook-form": "7.43.9",
21
+ "sql-highlight": "^4.3.2",
22
+ "zod": "^3.21.4",
23
+ "parotta-runtime": "workspace:*"
24
+ },
25
+ "devDependencies": {
26
+ "parotta-cli": "workspace:*",
27
+ "@playwright/test": "^1.31.2",
28
+ "eslint": "^8.35.0",
29
+ "eslint-config-react-app": "^7.0.1"
30
+ },
31
+ "parotta": {
32
+ "hydrate": true,
33
+ "css": [
34
+ "node_modules/normalize.css/normalize.css"
35
+ ]
36
+ },
37
+ "prettier": {
38
+ "printWidth": 120
39
+ },
40
+ "eslintConfig": {
41
+ "root": true,
42
+ "parserOptions": {
43
+ "ecmaVersion": "latest",
44
+ "sourceType": "module"
45
+ },
46
+ "extends": [
47
+ "eslint:recommended",
48
+ "react-app"
49
+ ],
50
+ "ignorePatterns": [
51
+ "build"
52
+ ],
53
+ "rules": {
54
+ "react/prop-types": "warn",
55
+ "react/react-in-jsx-scope": "off",
56
+ "no-unused-vars": "warn"
57
+ }
58
+ }
59
+ }
packages/example/pages/_404/page.css ADDED
@@ -0,0 +1,38 @@
1
+ body {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ justify-content: center;
6
+ color: #000;
7
+ background: #fff;
8
+ font-family: -apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", "Fira Sans", Avenir, "Helvetica Neue", "Lucida Grande", sans-serif;
9
+ height: 100vh;
10
+ text-align: center;
11
+
12
+ & h1 {
13
+ display: inline-block;
14
+ border-right: 1px solid rgba(0, 0, 0, .3);
15
+ margin: 0;
16
+ margin-right: 20px;
17
+ padding: 10px 23px 10px 0;
18
+ font-size: 24px;
19
+ font-weight: 500;
20
+ vertical-align: top;
21
+ }
22
+
23
+ & .content {
24
+ display: inline-block;
25
+ text-align: left;
26
+ line-height: 49px;
27
+ height: 49px;
28
+ vertical-align: middle;
29
+ }
30
+
31
+ & h2 {
32
+ font-size: 14px;
33
+ font-weight: normal;
34
+ line-height: inherit;
35
+ margin: 0;
36
+ padding: 0;
37
+ }
38
+ }
packages/example/pages/_404/page.jsx ADDED
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { Helmet } from 'react-helmet-async';
3
+ import "./page.css";
4
+
5
+ const Page = () => {
6
+ return (
7
+ <div>
8
+ <Helmet>
9
+ <title>Page not found</title>
10
+ </Helmet>
11
+ <h1>404 - Page not found</h1>
12
+ <div className="content">
13
+ <h2>This page could not be found</h2>
14
+ </div>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export default Page;
packages/example/pages/_500/page.css ADDED
@@ -0,0 +1,38 @@
1
+ body {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ justify-content: center;
6
+ color: #000;
7
+ background: #fff;
8
+ font-family: -apple-system, BlinkMacSystemFont, Roboto, "Segoe UI", "Fira Sans", Avenir, "Helvetica Neue", "Lucida Grande", sans-serif;
9
+ height: 100vh;
10
+ text-align: center;
11
+
12
+ & h1 {
13
+ display: inline-block;
14
+ border-right: 1px solid rgba(0, 0, 0, .3);
15
+ margin: 0;
16
+ margin-right: 20px;
17
+ padding: 10px 23px 10px 0;
18
+ font-size: 24px;
19
+ font-weight: 500;
20
+ vertical-align: top;
21
+ }
22
+
23
+ & .content {
24
+ display: inline-block;
25
+ text-align: left;
26
+ line-height: 49px;
27
+ height: 49px;
28
+ vertical-align: middle;
29
+ }
30
+
31
+ & h2 {
32
+ font-size: 14px;
33
+ font-weight: normal;
34
+ line-height: inherit;
35
+ margin: 0;
36
+ padding: 0;
37
+ }
38
+ }
packages/example/pages/_500/page.jsx ADDED
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { Helmet } from 'react-helmet-async';
3
+ import "./page.css";
4
+
5
+ const Page = () => {
6
+ return (
7
+ <div>
8
+ <Helmet>
9
+ <title>Oop's Something went wrong</title>
10
+ </Helmet>
11
+ <h1>Oop's Something went wrong</h1>
12
+ <div className="content">
13
+ <h2>Internal Server Error</h2>
14
+ </div>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export default Page;
packages/example/pages/about/page.css ADDED
@@ -0,0 +1,10 @@
1
+ body {
2
+ margin: 0;
3
+ padding: 20px;
4
+ padding-bottom: 130px;
5
+ background-color: violet;
6
+
7
+ & footer {
8
+ margin-top: 100px;
9
+ }
10
+ }
packages/example/pages/about/page.jsx ADDED
@@ -0,0 +1,28 @@
1
+ import React from 'react';
2
+ import { Link, useRouter } from "parotta-runtime";
3
+ import { Helmet } from 'react-helmet-async';
4
+ import Layout from '@/components/Layout/Layout';
5
+ import "./page.css";
6
+
7
+ export const Page = () => {
8
+ const router = useRouter();
9
+ return (
10
+ <Layout>
11
+ <div className="about-page">
12
+ <Helmet>
13
+ <title>About Page @ {router.pathname}</title>
14
+ <meta name="description" content="Showcase of using parotta meta-framework." />
15
+ </Helmet>
16
+ <div>
17
+ <h1>About Page @ {router.pathname}</h1>
18
+ <p>Showcase of using parotta meta-framework.</p>
19
+ </div>
20
+ <footer>
21
+ <Link href="/">Back</Link>
22
+ </footer>
23
+ </div>
24
+ </Layout>
25
+ )
26
+ }
27
+
28
+ export default Page;
packages/example/pages/page.css ADDED
@@ -0,0 +1,17 @@
1
+ body {
2
+ margin: 0;
3
+ padding: 20px;
4
+ margin: 0;
5
+ background-color: turquoise;
6
+
7
+ & .count {
8
+ color: black;
9
+ padding: 40px;
10
+ font-size: 30px;
11
+ font-weight: 600;
12
+ }
13
+
14
+ & footer {
15
+ margin-top: 100px;
16
+ }
17
+ }
packages/example/pages/page.jsx ADDED
@@ -0,0 +1,29 @@
1
+ import React, { useEffect } from 'react';
2
+ import { useRouter } from "parotta-runtime";
3
+ import Layout from '@/components/Layout/Layout';
4
+ import Counter from "@/components/Counter/Counter";
5
+ import { Helmet } from 'react-helmet-async';
6
+ import "./page.css";
7
+
8
+ const Page = () => {
9
+ const router = useRouter();
10
+ useEffect(() => {
11
+
12
+ }, []);
13
+ return (
14
+ <Layout>
15
+ <Helmet>
16
+ <title>Parotta App</title>
17
+ </Helmet>
18
+ <div>
19
+ <h1>Home Page</h1>
20
+ <p>
21
+ Path: {router.pathname}
22
+ </p>
23
+ <Counter />
24
+ </div>
25
+ </Layout>
26
+ )
27
+ }
28
+
29
+ export default Page;
packages/example/pages/page.spec.js ADDED
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+ // import { test, expect } from '@playwright/test';
3
+
4
+ // test.beforeEach(async ({ page }) => {
5
+ // await page.goto('/');
6
+ // })
7
+
8
+ // test('has title', async ({ page }) => {
9
+ // await expect(page).toHaveTitle(/Parotta/);
10
+ // });
11
+
12
+ // test('has links', async ({ page }) => {
13
+ // // await page.getByRole('link', { name: 'About us' }).click();
14
+ // });
15
+
16
+ // test('has counter', async ({ page }) => {
17
+ // const counter = page.getByText("Counter");
18
+ // expect(counter.innerText).toEqual("123");
19
+ // });
packages/example/pages/todos/page.css ADDED
@@ -0,0 +1,71 @@
1
+ body {
2
+ padding: 10px;
3
+ background-color: turquoise;
4
+ }
5
+
6
+
7
+ :root {
8
+ --spectrum-alias-border-color: black;
9
+ --spectrum-global-color-gray-50: white;
10
+ }
11
+
12
+
13
+ .react-aria-TextField {
14
+ --field-border: var(--spectrum-alias-border-color);
15
+ --field-border-disabled: var(--spectrum-alias-border-color-disabled);
16
+ --field-background: var(--spectrum-global-color-gray-50);
17
+ --text-color: var(--spectrum-alias-text-color);
18
+ --text-color-disabled: var(--spectrum-alias-text-color-disabled);
19
+ --focus-ring-color: slateblue;
20
+ --invalid-color: var(--spectrum-global-color-red-600);
21
+
22
+ display: flex;
23
+ flex-direction: column;
24
+ width: fit-content;
25
+
26
+ .react-aria-Input {
27
+ padding: 0.286rem;
28
+ margin: 0;
29
+ border: 1px solid var(--field-border);
30
+ border-radius: 6px;
31
+ background: var(--field-background);
32
+ font-size: 1.143rem;
33
+ color: var(--text-color);
34
+
35
+ &[aria-invalid] {
36
+ border-color: var(--invalid-color);
37
+ }
38
+
39
+ &:focus {
40
+ outline: none;
41
+ border-color: var(--focus-ring-color);
42
+ box-shadow: 0 0 0 1px var(--focus-ring-color);
43
+ }
44
+
45
+ &:disabled {
46
+ border-color: var(--field-border-disabled);
47
+ color: var(--text-color-disabled);
48
+ }
49
+ }
50
+
51
+ [slot=description] {
52
+ font-size: 12px;
53
+ }
54
+
55
+ [slot=errorMessage] {
56
+ font-size: 12px;
57
+ color: var(--invalid-color);
58
+ }
59
+ }
60
+
61
+ @media (forced-colors: active) {
62
+ .react-aria-TextField {
63
+ --field-border: ButtonBorder;
64
+ --field-border-disabled: GrayText;
65
+ --field-background: Field;
66
+ --text-color: FieldText;
67
+ --text-color-disabled: GrayText;
68
+ --focus-ring-color: Highlight;
69
+ --invalid-color: LinkText;
70
+ }
71
+ }
packages/example/pages/todos/page.jsx ADDED
@@ -0,0 +1,61 @@
1
+ import React, { Suspense } from 'react';
2
+ import { Helmet } from 'react-helmet-async';
3
+ import { useQuery, useMutation } from "parotta-runtime";
4
+ import { useForm } from 'react-hook-form';
5
+ import Todo from "@/components/Todo/Todo";
6
+ import { TextField, Label, Input } from 'react-aria-components';
7
+ import { Button } from 'react-aria-components';
8
+ import { getTodos, createTodo } from "@/services/todos.service";
9
+ import Layout from '@/components/Layout/Layout';
10
+ import "./page.css";
11
+
12
+ const TodoList = () => {
13
+ const { data, refetch } = useQuery("todos", () => getTodos());
14
+ const { mutate, isMutating, err } = useMutation(async ({ text }) => {
15
+ await createTodo({
16
+ text,
17
+ completed: false,
18
+ })
19
+ await refetch();
20
+ });
21
+ const { register, handleSubmit, formState: { errors } } = useForm();
22
+ console.log('err', err, errors);
23
+ return (
24
+ <div>
25
+ <ul>
26
+ {data.map((item) => (
27
+ <Todo key={item.id} todo={item} />
28
+ ))}
29
+ </ul>
30
+ <form onSubmit={handleSubmit(mutate)}>
31
+ <TextField isRequired isReadOnly={isMutating}>
32
+ <Label>Text (required)</Label>
33
+ <Input {...register('text')} />
34
+ {err?.text && <p>{err.text._errors[0]}</p>}
35
+ </TextField>
36
+ <Button type="submit" isDisabled={isMutating}>Add Todo</Button>
37
+ {isMutating && <div>
38
+ <p>Creating...</p>
39
+ </div>}
40
+ </form>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ const Page = () => {
46
+ return (
47
+ <Layout>
48
+ <h1>Todos</h1>
49
+ <Helmet>
50
+ <title>Todos Page</title>
51
+ </Helmet>
52
+ <div>
53
+ <Suspense fallback="Loading...">
54
+ <TodoList />
55
+ </Suspense>
56
+ </div>
57
+ </Layout>
58
+ )
59
+ }
60
+
61
+ export default Page;
packages/example/playwright.config.js ADDED
@@ -0,0 +1,65 @@
1
+ // @ts-check
2
+ import { defineConfig, devices } from '@playwright/test';
3
+
4
+ /**
5
+ * @see https://playwright.dev/docs/test-configuration
6
+ */
7
+ export default defineConfig({
8
+ testDir: './routes',
9
+ timeout: 30 * 1000,
10
+ expect: {
11
+ timeout: 5000
12
+ },
13
+ fullyParallel: true,
14
+ forbidOnly: !!process.env.CI,
15
+ retries: process.env.CI ? 2 : 0,
16
+ workers: process.env.CI ? 1 : undefined,
17
+ reporter: 'html',
18
+ use: {
19
+ actionTimeout: 0,
20
+ baseURL: 'http://localhost:3000',
21
+ trace: 'on-first-retry',
22
+ },
23
+ projects: [
24
+ {
25
+ name: 'chromium',
26
+ use: { ...devices['Desktop Chrome'] },
27
+ },
28
+
29
+ {
30
+ name: 'firefox',
31
+ use: { ...devices['Desktop Firefox'] },
32
+ },
33
+
34
+ {
35
+ name: 'webkit',
36
+ use: { ...devices['Desktop Safari'] },
37
+ },
38
+
39
+ /* Test against mobile viewports. */
40
+ // {
41
+ // name: 'Mobile Chrome',
42
+ // use: { ...devices['Pixel 5'] },
43
+ // },
44
+ // {
45
+ // name: 'Mobile Safari',
46
+ // use: { ...devices['iPhone 12'] },
47
+ // },
48
+
49
+ /* Test against branded browsers. */
50
+ // {
51
+ // name: 'Microsoft Edge',
52
+ // use: { channel: 'msedge' },
53
+ // },
54
+ // {
55
+ // name: 'Google Chrome',
56
+ // use: { channel: 'chrome' },
57
+ // },
58
+ ],
59
+ outputDir: 'test-results/',
60
+ webServer: {
61
+ command: '../parotta/cli.js',
62
+ port: 3000,
63
+ },
64
+ });
65
+
packages/example/readme.md ADDED
@@ -0,0 +1,18 @@
1
+ # Sample Parotta Application
2
+
3
+ ## Requirements
4
+
5
+ 1. bun >= v0.5.8
6
+
7
+ ## Setup
8
+
9
+ 1. `bun i`
10
+ 2. `bunx playright install`
11
+
12
+ ## Running
13
+
14
+ `bun run dev`
15
+
16
+ ## Testing
17
+
18
+ `bun test`
packages/example/services/auth.service.js ADDED
@@ -0,0 +1,50 @@
1
+ // import NextAuth from "next-auth";
2
+ // import EmailProvider from "next-auth/providers/email";
3
+ // import GoogleProvider from "next-auth/providers/google";
4
+ // import DrizzleAuthAdapterPG from "drizzle-auth-adaptor-pg";
5
+ // import db from "@/db";
6
+
7
+ // GET /api/auth/signin
8
+ // POST /api/auth/signin/:provider
9
+ // GET/POST /api/auth/callback/:provider
10
+ // GET /api/auth/signout
11
+ // POST /api/auth/signout
12
+ // GET /api/auth/session
13
+ // GET /api/auth/csrf
14
+ // GET /api/auth/providers
15
+
16
+ // NEXTAUTH_SECRET="This is an example"
17
+ // NEXTAUTH_URL
18
+
19
+ // import { SessionProvider } from "next-auth/react"
20
+ // export default function App({
21
+ // Component,
22
+ // pageProps: { session, ...pageProps },
23
+ // }) {
24
+ // return (
25
+ // <SessionProvider session={session}>
26
+ // <Component {...pageProps} />
27
+ // </SessionProvider>
28
+ // )
29
+ // }
30
+
31
+ // const handler = NextAuth({
32
+ // adapter: DrizzleAuthAdapterPG(db),
33
+ // providers: [
34
+ // EmailProvider({
35
+ // server: {
36
+ // host: process.env.SMTP_HOST,
37
+ // port: Number(process.env.SMTP_PORT),
38
+ // auth: {
39
+ // user: process.env.SMTP_USER,
40
+ // pass: process.env.SMTP_PASSWORD,
41
+ // },
42
+ // },
43
+ // from: process.env.EMAIL_FROM,
44
+ // }),
45
+ // GoogleProvider({
46
+ // clientId: process.env.GOOGLE_CLIENT_ID,
47
+ // clientSecret: process.env.GOOGLE_CLIENT_SECRET,
48
+ // }),
49
+ // ],
50
+ // });
packages/example/services/todos.service.js ADDED
@@ -0,0 +1,49 @@
1
+ import { eq, asc } from 'drizzle-orm';
2
+ import db from "@/db";
3
+ import { boolean, date, pgTable, serial, text } from 'drizzle-orm/pg-core';
4
+ import { z } from 'zod';
5
+
6
+ const todos = pgTable('todos', {
7
+ id: serial('id').primaryKey(),
8
+ text: text('text').notNull(),
9
+ completed: boolean('completed').notNull(),
10
+ createdAt: date('createdAt').notNull(),
11
+ updatedAt: date('updatedAt'),
12
+ });
13
+
14
+ export const createSchema = z.object({
15
+ text: z.string().nonempty("please enter some text"),
16
+ completed: z.boolean(),
17
+ });
18
+
19
+ export const updateSchema = z.object({
20
+ text: z.string().nonempty("please enter some text"),
21
+ completed: z.boolean(),
22
+ });
23
+
24
+ export const getTodos = async () => {
25
+ return await db.select().from(todos).orderBy(asc(todos.id));
26
+ }
27
+
28
+ /** @param {z.infer<typeof createSchema>} params */
29
+ export const createTodo = async (params) => {
30
+ const item = createSchema.parse(params);
31
+ item.createdAt = new Date();
32
+ return await db.insert(todos).values(item).returning();
33
+ }
34
+
35
+ export const getTodo = async (id) => {
36
+ const results = await db.select().from(todos).where(eq(todos.id, id));
37
+ return results[0]
38
+ }
39
+
40
+ /** @param {z.infer<typeof updateSchema>} params */
41
+ export const updateTodo = async (params) => {
42
+ const item = updateSchema.parse(params);
43
+ item.updatedAt = new Date();
44
+ return await db.update(todos).set(item).where(eq(todos.id, item.id)).returning();
45
+ }
46
+
47
+ export const deleteTodo = async (id) => {
48
+ return await db.delete(todos).where(eq(todos.id, id)).returning();
49
+ }
packages/example/services/todos.service.test.js ADDED
@@ -0,0 +1,41 @@
1
+ import { test, expect } from "bun:test";
2
+ import { createSchema } from "./todos.service";
3
+
4
+ test("validate createSchema", () => {
5
+ expect(createSchema.safeParse({}).error.issues).toEqual([
6
+ {
7
+ "code": "invalid_type",
8
+ "expected": "string",
9
+ "message": "Required",
10
+ "path": [
11
+ "text"
12
+ ],
13
+ "received": "undefined"
14
+ },
15
+ {
16
+ "code": "invalid_type",
17
+ "expected": "boolean",
18
+ "message": "Required",
19
+ "path": [
20
+ "completed"
21
+ ],
22
+ "received": "undefined"
23
+ }
24
+ ])
25
+ expect(createSchema.safeParse({
26
+ text: '',
27
+ completed: true,
28
+ }).error.issues).toEqual([
29
+ {
30
+ "code": "too_small",
31
+ "exact": false,
32
+ "inclusive": true,
33
+ "message": "please enter some text",
34
+ "minimum": 1,
35
+ "path": [
36
+ "text"
37
+ ],
38
+ "type": "string"
39
+ },
40
+ ])
41
+ })
packages/example/static/favicon.ico ADDED
Binary file
packages/example/static/logo192.png ADDED
Binary file
packages/example/static/logo512.png ADDED
Binary file
packages/example/static/manifest.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "short_name": "React App",
3
+ "name": "Create React App Sample",
4
+ "icons": [
5
+ {
6
+ "src": "favicon.ico",
7
+ "sizes": "64x64 32x32 24x24 16x16",
8
+ "type": "image/x-icon"
9
+ },
10
+ {
11
+ "src": "logo192.png",
12
+ "type": "image/png",
13
+ "sizes": "192x192"
14
+ },
15
+ {
16
+ "src": "logo512.png",
17
+ "type": "image/png",
18
+ "sizes": "512x512"
19
+ }
20
+ ],
21
+ "start_url": ".",
22
+ "display": "standalone",
23
+ "theme_color": "#000000",
24
+ "background_color": "#ffffff"
25
+ }
packages/example/static/robots.txt ADDED
@@ -0,0 +1,3 @@
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
packages/runtime/index.js ADDED
@@ -0,0 +1,315 @@
1
+ import React, {
2
+ Suspense, createElement, createContext, useContext, useState, useEffect, useTransition, useCallback
3
+ } from "react";
4
+ import { HelmetProvider } from 'react-helmet-async';
5
+ import { ErrorBoundary } from "react-error-boundary";
6
+ import { createMemoryHistory } from "history";
7
+ import { createRouter } from "radix3";
8
+ import routes from '/static/routemap.json' assert {type: 'json'};
9
+
10
+ /**
11
+ * CSR related functions
12
+ */
13
+
14
+ export const domain = () => typeof window !== 'undefined' ? window.origin : "http://0.0.0.0:3000";
15
+
16
+ export const rpc = (serviceName) => async (params = {}) => {
17
+ const res = await fetch(`${domain()}/services/${serviceName}`, {
18
+ method: "POST",
19
+ headers: {
20
+ "Accept": "application/json",
21
+ "Content-Type": "application/json",
22
+ },
23
+ body: JSON.stringify(params),
24
+ })
25
+ return await res.json();
26
+ }
27
+
28
+ export const RpcContext = createContext(undefined);
29
+
30
+ // global way to refresh maybe without being tied to a hook like refetch
31
+ export const useInvalidate = () => {
32
+ const ctx = useContext(RpcContext);
33
+ return (regex) => {
34
+ Object.keys(ctx)
35
+ .filter((k) => regex.test(k))
36
+ .forEach((k) => {
37
+ delete ctx[k];
38
+ });
39
+ }
40
+ }
41
+
42
+ export const useRpcCache = (k) => {
43
+ const ctx = useContext(RpcContext);
44
+ const [_, rerender] = useState(false);
45
+ const get = () => ctx[k]
46
+ const set = (v) => {
47
+ ctx[k] = v;
48
+ rerender((c) => !c);
49
+ }
50
+ const invalidate = () => {
51
+ delete ctx[k];
52
+ rerender((c) => !c);
53
+ }
54
+ return {
55
+ get,
56
+ set,
57
+ invalidate,
58
+ }
59
+ }
60
+
61
+ /**
62
+ *
63
+ * @param {*} fn
64
+ * @param {*} params
65
+ * @returns
66
+ */
67
+ export const useQuery = (key, fn) => {
68
+ const [isRefetching, setIsRefetching] = useState(false);
69
+ const [err, setErr] = useState(null);
70
+ const cache = useRpcCache(key);
71
+ const refetch = useCallback(async () => {
72
+ try {
73
+ setIsRefetching(true);
74
+ setErr(null);
75
+ cache.set(await fn());
76
+ } catch (err) {
77
+ setErr(err);
78
+ throw err;
79
+ } finally {
80
+ setIsRefetching(false);
81
+ }
82
+ }, [fn]);
83
+ const value = cache.get();
84
+ if (value) {
85
+ if (value instanceof Promise) {
86
+ throw value;
87
+ } else if (value instanceof Error) {
88
+ throw value;
89
+ }
90
+ return { data: value, isRefetching, err, refetch };
91
+ }
92
+ cache.set(fn().then((v) => cache.set(v)));
93
+ throw cache.get();
94
+ }
95
+
96
+ export const useMutation = (fn) => {
97
+ const [isMutating, setIsMutating] = useState(false);
98
+ const [err, setErr] = useState(null);
99
+ const mutate = useCallback(async (params) => {
100
+ try {
101
+ setIsMutating(true);
102
+ setErr(null);
103
+ await fn(params);
104
+ } catch (err) {
105
+ setErr(err)
106
+ throw err;
107
+ } finally {
108
+ setIsMutating(false);
109
+ }
110
+ }, [fn])
111
+ return {
112
+ mutate,
113
+ isMutating,
114
+ err,
115
+ }
116
+ }
117
+
118
+ export const RouterContext = createContext(undefined);
119
+
120
+ const getMatch = (radixRouter, pathname) => {
121
+ const matchedPage = radixRouter.lookup(pathname);
122
+ if (!matchedPage) {
123
+ return radixRouter.lookup("_404")
124
+ }
125
+ return matchedPage;
126
+ }
127
+
128
+ const getCssUrl = (pathname) => `/pages${pathname === "/" ? "" : pathname}/page.css`;
129
+
130
+ export const App = ({ nProgress, history, radixRouter, rpcCache, helmetContext }) => {
131
+ const [isPending, startTransition] = useTransition();
132
+ const [match, setMatch] = useState(() => getMatch(radixRouter, history.location.pathname));
133
+ useEffect(() => {
134
+ return history.listen(({ location }) => {
135
+ const href = getCssUrl(location.pathname);
136
+ // const isLoaded = Array.from(document.getElementsByTagName("link"))
137
+ // .map((link) => link.href.replace(window.origin, "")).includes(href);
138
+ // if (!isLoaded) {
139
+ // const link = document.createElement('link');
140
+ // link.setAttribute("rel", "stylesheet");
141
+ // link.setAttribute("type", "text/css");
142
+ // link.onload = () => {
143
+ // nProgress.start();
144
+ // startTransition(() => {
145
+ // setMatch(getMatch(radixRouter, location.pathname));
146
+ // })
147
+ // };
148
+ // link.setAttribute("href", href);
149
+ // document.getElementsByTagName("head")[0].appendChild(link);
150
+ // } else {
151
+ const link = document.createElement('link');
152
+ link.setAttribute("rel", "stylesheet");
153
+ link.setAttribute("type", "text/css");
154
+ link.setAttribute("href", href);
155
+ document.getElementsByTagName("head")[0].appendChild(link);
156
+ nProgress.start();
157
+ startTransition(() => {
158
+ setMatch(getMatch(radixRouter, location.pathname));
159
+ })
160
+ // }
161
+ });
162
+ }, []);
163
+ useEffect(() => {
164
+ if (!isPending) {
165
+ nProgress.done();
166
+ }
167
+ }, [isPending]);
168
+ return createElement(HelmetProvider, {
169
+ context: helmetContext,
170
+ children: createElement(RpcContext.Provider, {
171
+ value: rpcCache,
172
+ children: createElement(RouterContext.Provider, {
173
+ value: {
174
+ history: history,
175
+ params: match.params || {},
176
+ },
177
+ children: createElement(ErrorBoundary, {
178
+ onError: (err) => console.log(err),
179
+ fallback: createElement("p", {}, "Oops something went wrong"),
180
+ children: createElement(Suspense, {
181
+ fallback: createElement("p", {}, "Loading..."),
182
+ children: createElement(match, {}),
183
+ }),
184
+ }),
185
+ }),
186
+ }),
187
+ });
188
+ }
189
+
190
+ export const useRouter = () => {
191
+ const { history, params } = useContext(RouterContext);
192
+ return {
193
+ pathname: history.location.pathname,
194
+ query: new URLSearchParams(history.location.search),
195
+ params,
196
+ push: history.push,
197
+ replace: history.replace,
198
+ forward: history.forward,
199
+ back: history.back,
200
+ reload: () => window.location.reload(),
201
+ };
202
+ }
203
+
204
+ export const Link = (props) => {
205
+ const router = useRouter();
206
+ return createElement("a", {
207
+ ...props,
208
+ onMouseOver: (e) => {
209
+ // Simple prefetching for now will work only with cache headers
210
+ // fetch(getCssUrl(props.href));
211
+ // fetch(getCssUrl(props.href).replace("css", "jsx"));
212
+ },
213
+ onClick: (e) => {
214
+ e.preventDefault();
215
+ if (props && props.onClick) {
216
+ props.onClick(e);
217
+ }
218
+ router.push(props.href)
219
+ },
220
+ })
221
+ }
222
+
223
+ export const NavLink = ({ children, className, activeClassName, ...props }) => {
224
+ const { pathname } = useRouter();
225
+ const classNames = pathname === props.href ? [activeClassName, className] : [className];
226
+ return createElement(Link, {
227
+ children,
228
+ className: classNames,
229
+ ...props,
230
+ })
231
+ }
232
+
233
+ /**
234
+ * SSR related functions
235
+ */
236
+ export const renderPage = async () => {
237
+ const clientRouter = createRouter({
238
+ strictTrailingSlash: true,
239
+ routes: Object.keys(routes).reduce((acc, r) => {
240
+ acc[r] = React.lazy(() => import(`/pages${r}/page.jsx`));
241
+ return acc;
242
+ }, {}),
243
+ });
244
+ const history = createMemoryHistory({
245
+ initialEntries: [url.pathname + url.search],
246
+ });
247
+ const helmetContext = {}
248
+ const nProgress = { start: () => { }, done: () => { } }
249
+ // const stream = await renderToReadableStream(
250
+ // <html lang="en">
251
+ // <head>
252
+ // <link rel="stylesheet" href="https://unpkg.com/[email protected]/nprogress.css" />
253
+ // <link id="pageCss" rel="stylesheet" href={`/pages${url.pathname}/page.css`} />
254
+ // <script type="importmap" src="/static/importmap.json" />
255
+ // </head>
256
+ // <body>
257
+ // <App
258
+ // nProgress={nProgress}
259
+ // history={history}
260
+ // radixRouter={clientRouter}
261
+ // rpcCache={{}}
262
+ // helmetContext={helmetContext}
263
+ // />
264
+ // {false &&
265
+ // <>
266
+ // <script type="module" defer={true} dangerouslySetInnerHTML={{
267
+ // __html: `
268
+ // import React from "react";
269
+ // import { hydrateRoot } from "react-dom/client";
270
+ // import { createBrowserHistory } from "history";
271
+ // import nProgress from "nprogress";
272
+ // import { createRouter } from "radix3";
273
+ // import { App } from "parotta/runtime";
274
+ // import routes from '/static/routemap.json' assert {type: 'json'};
275
+ // // import sheet from './styles.css' assert { type: 'css' };
276
+
277
+ // const history = createBrowserHistory();
278
+ // const radixRouter = createRouter({
279
+ // strictTrailingSlash: true,
280
+ // routes: {
281
+ // ${Object.keys(routes).map((r) => `"${r}": React.lazy(() => import("/pages${r}/page.jsx"))`).join(',\n ')}
282
+ // },
283
+ // });
284
+
285
+ // hydrateRoot(document.body, React.createElement(App, {
286
+ // nProgress,
287
+ // history,
288
+ // radixRouter,
289
+ // rpcCache: {},
290
+ // helmetContext: {},
291
+ // }));`
292
+ // }}>
293
+ // </script>
294
+ // </>
295
+ // }
296
+ // </body>
297
+ // </html>
298
+ // );
299
+ const stream = await renderToReadableStream(React.createElement(App, {
300
+ nProgress,
301
+ history,
302
+ radixRouter: clientRouter,
303
+ rpcCache: {},
304
+ helmetContext: helmetContext,
305
+ }));
306
+ // TODO:
307
+ // if (bot || isCrawler) {
308
+ // await stream.allReady
309
+ // add helmetContext to head
310
+ // }
311
+ return new Response(stream, {
312
+ headers: { 'Content-Type': 'text/html' },
313
+ status: 200,
314
+ });
315
+ }
packages/runtime/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "parotta-runtime",
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "dependencies": {
6
+ "history": "^5.3.0",
7
+ "radix3": "^1.0.0"
8
+ },
9
+ "peerDependencies": {
10
+ "react": "*",
11
+ "react-error-boundary": "*",
12
+ "react-helmet-async": "*"
13
+ }
14
+ }
pnpm-workspace.yaml ADDED
@@ -0,0 +1,2 @@
1
+ packages:
2
+ - 'packages/*'
readme.md ADDED
@@ -0,0 +1,20 @@
1
+ # parotta
2
+
3
+ parotta is a next level meta-framework for react that runs only on edge runtimes.
4
+ It uses bun as its bundler/transpiler and development mode as its quick and fast.
5
+ It uses File System routing with streaming SSR + CSR as the method to render pages.
6
+ It is very opionated and has set of idiomatic ways of doing things.
7
+ It has an inbuilt rpc mechanism to access server resources instead of a typical REST API.
8
+
9
+ During development each request for a page is executed in a separate vercel edge-runtime vm.
10
+ During production each page is packaged to an esm function adapted to the platform of your choice.
11
+
12
+ ### Todo
13
+ 1. Hydrate rpc cache
14
+ 2. Build a docs website using parotta
15
+
16
+ ### Supported platforms
17
+ 1. [Cloudflare page functions](https://developers.cloudflare.com/pages/platform/functions/routing/)
18
+ 2. [Vercel edge functions](https://vercel.com/docs/concepts/functions/edge-functions)
19
+ 3. [Netlify edge functions](https://docs.netlify.com/edge-functions/overview/)
20
+ 4. [Deno Deploy](https://deno.com/deploy)