~repos /edge-city

#react#js#ssr

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

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


e85a6973 Peter John

2 years ago
new stuff
Files changed (38) hide show
  1. __render.js +208 -0
  2. bun.lockb +0 -0
  3. package.json +1 -2
  4. packages/create-parotta-app/bun.lockb +0 -0
  5. packages/{create-parotta-app → create-parotta}/index.js +0 -0
  6. packages/{create-parotta-app → create-parotta}/package.json +0 -0
  7. packages/example/.gitignore +3 -1
  8. packages/example/.vscode/extensions.json +5 -0
  9. packages/example/.vscode/settings.json +13 -0
  10. packages/example/bun.lockb +0 -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 +88 -9
  14. packages/example/db/index.js +22 -0
  15. packages/example/db/migrations/0000_legal_natasha_romanoff.sql +4 -0
  16. packages/example/db/migrations/meta/0000_snapshot.json +36 -0
  17. packages/example/db/migrations/meta/_journal.json +1 -0
  18. packages/example/drizzle.config.json +4 -0
  19. packages/example/package.json +37 -4
  20. packages/example/playwright.config.js +65 -0
  21. packages/example/readme.md +14 -0
  22. packages/example/routes/about/page.jsx +1 -3
  23. packages/example/routes/{api.js → api/index.js} +0 -0
  24. packages/example/routes/api/todos/index.js +50 -0
  25. packages/example/routes/layout.jsx +1 -0
  26. packages/example/routes/page.jsx +2 -8
  27. packages/example/routes/page.spec.js +19 -0
  28. packages/example/routes/todos/:id/api.js +0 -55
  29. packages/example/routes/todos/:id/page.css +0 -4
  30. packages/example/routes/todos/:id/page.jsx +0 -21
  31. packages/example/routes/todos/api.js +0 -26
  32. packages/example/routes/todos/page.jsx +19 -6
  33. packages/example/services/collections.js +0 -35
  34. packages/parotta/bun.lockb +0 -0
  35. packages/parotta/cli.js +14 -18
  36. packages/parotta/package.json +1 -1
  37. packages/parotta/router.js +10 -5
  38. readme.md +3 -3
__render.js ADDED
@@ -0,0 +1,208 @@
1
+ import React from 'react';
2
+ import { renderToReadableStream } from 'react-dom/server';
3
+ import { StreamProvider, SuspenseData } from './hooks';
4
+
5
+ function wrapStreamEnd(streamEnd, didError) {
6
+ return (
7
+ streamEnd
8
+ // Needed because of the `afterReactBugCatch()` hack above, otherwise `onBoundaryError` triggers after `streamEnd` resolved
9
+ .then(() => new Promise((r) => setTimeout(r, 0)))
10
+ .then(() => !didError)
11
+ )
12
+ }
13
+
14
+ function createBuffer(streamOperations) {
15
+ const buffer = []
16
+ let state = 'UNSTARTED'
17
+ let writePermission = null // Set to `null` because React fails to hydrate if something is injected before the first react write
18
+
19
+ return { injectToStream, onBeforeWrite, onBeforeEnd }
20
+
21
+ function injectToStream(chunk, options) {
22
+ if (state === 'ENDED') {
23
+ console.error(`Cannot inject following chunk after stream has ended: \`${chunk}\``)
24
+ }
25
+ buffer.push({ chunk, flush: options?.flush })
26
+ flushBuffer()
27
+ }
28
+
29
+ function flushBuffer() {
30
+ if (!writePermission) {
31
+ return
32
+ }
33
+ if (buffer.length === 0) {
34
+ return
35
+ }
36
+ if (state !== 'STREAMING') {
37
+ console.error(state === 'UNSTARTED')
38
+ return
39
+ }
40
+ let flushStream = false
41
+ buffer.forEach((bufferEntry) => {
42
+ // assert(streamOperations.operations)
43
+ const { writeChunk } = streamOperations.operations
44
+ writeChunk(bufferEntry.chunk)
45
+ if (bufferEntry.flush) {
46
+ flushStream = true
47
+ }
48
+ })
49
+ buffer.length = 0
50
+ // assert(streamOperations.operations)
51
+ if (flushStream && streamOperations.operations.flush !== null) {
52
+ streamOperations.operations.flush()
53
+ }
54
+ }
55
+
56
+ function onBeforeWrite(chunk) {
57
+ // state === 'UNSTARTED' && debug('>>> START')
58
+ state = 'STREAMING'
59
+ if (writePermission) {
60
+ flushBuffer()
61
+ }
62
+ if (writePermission == true || writePermission === null) {
63
+ writePermission = false
64
+ // debug('writePermission =', writePermission)
65
+ setTimeout(() => {
66
+ // debug('>>> setTimeout()')
67
+ writePermission = true
68
+ // debug('writePermission =', writePermission)
69
+ flushBuffer()
70
+ })
71
+ }
72
+ }
73
+
74
+ function onBeforeEnd() {
75
+ writePermission = true
76
+ // debug('writePermission =', writePermission)
77
+ flushBuffer()
78
+ // assert(buffer.length === 0)
79
+ state = 'ENDED'
80
+ // debug('>>> END')
81
+ }
82
+ }
83
+
84
+
85
+ const createReadableWrapper = (readableFromReact) => {
86
+ const streamOperations = {
87
+ operations: null
88
+ }
89
+ let controllerOfUserStream;
90
+ let onEnded;
91
+ const streamEnd = new Promise((r) => {
92
+ onEnded = () => r()
93
+ })
94
+ const readableForUser = new ReadableStream({
95
+ start(controller) {
96
+ controllerOfUserStream = controller
97
+ onReady(onEnded)
98
+ }
99
+ })
100
+ const { injectToStream, onBeforeWrite, onBeforeEnd } = createBuffer(streamOperations)
101
+ return { readableForUser, streamEnd, injectToStream }
102
+ async function onReady(onEnded) {
103
+ streamOperations.operations = {
104
+ writeChunk(chunk) {
105
+ controllerOfUserStream.enqueue(encodeForWebStream(chunk))
106
+ },
107
+ flush: null
108
+ }
109
+
110
+ const reader = readableFromReact.getReader()
111
+
112
+ while (true) {
113
+ let result;
114
+ try {
115
+ result = await reader.read()
116
+ } catch (err) {
117
+ controllerOfUserStream.close()
118
+ throw err
119
+ }
120
+ const { value, done } = result
121
+ if (done) {
122
+ break
123
+ }
124
+ onBeforeWrite(value)
125
+ streamOperations.operations.writeChunk(value)
126
+ }
127
+
128
+ // Collect `injectToStream()` calls stuck in an async call
129
+ setTimeout(() => {
130
+ onBeforeEnd()
131
+ controllerOfUserStream.close()
132
+ onEnded()
133
+ }, 0)
134
+ }
135
+ }
136
+
137
+ let encoder;
138
+ function encodeForWebStream(thing) {
139
+ if (!encoder) {
140
+ encoder = new TextEncoder()
141
+ }
142
+ if (typeof thing === 'string') {
143
+ return encoder.encode(thing)
144
+ }
145
+ return thing
146
+ }
147
+
148
+ export const renderToWebStream = async (element, disable) => {
149
+ let didError = false
150
+ let firstErr = null
151
+ let reactBug = null
152
+ const onError = (err) => {
153
+ didError = true
154
+ firstErr = firstErr || err
155
+ afterReactBugCatch(() => {
156
+ // Is not a React internal error (i.e. a React bug)
157
+ if (err !== reactBug) {
158
+ options.onBoundaryError?.(err)
159
+ }
160
+ })
161
+ }
162
+ const readableOriginal = await renderToReadableStream(element, { onError })
163
+ const { allReady } = readableOriginal
164
+ let promiseResolved = false
165
+ // Upon React internal errors (i.e. React bugs), React rejects `allReady`.
166
+ // React doesn't reject `allReady` upon boundary errors.
167
+ allReady.catch((err) => {
168
+ // debug('react bug')
169
+ didError = true
170
+ firstErr = firstErr || err
171
+ reactBug = err
172
+ // Only log if it wasn't used as rejection for `await renderToStream()`
173
+ if (reactBug !== firstErr || promiseResolved) {
174
+ console.error(reactBug)
175
+ }
176
+ })
177
+ if (didError) throw firstErr
178
+ if (disable) await allReady
179
+ if (didError) throw firstErr
180
+ const { readableForUser, streamEnd, injectToStream } = createReadableWrapper(readableOriginal)
181
+ promiseResolved = true
182
+ return {
183
+ readable: readableForUser,
184
+ streamEnd: wrapStreamEnd(streamEnd, didError),
185
+ injectToStream
186
+ }
187
+ }
188
+
189
+ export const renderToStream = async (element, options = {}) => {
190
+ const buffer = []
191
+ let injectToStream = (chunk) => buffer.push(chunk);
192
+ const disable = options.disable //?? resolveSeoStrategy(options).disableStream)
193
+ const result = await renderToWebStream(React.createElement(
194
+ StreamProvider,
195
+ {
196
+ value: {
197
+ injectToStream: (chunk) => {
198
+ injectToStream(chunk)
199
+ }
200
+ }
201
+ },
202
+ React.createElement(SuspenseData, null, element)
203
+ ), disable, options);
204
+ injectToStream = result.injectToStream
205
+ buffer.forEach((chunk) => injectToStream(chunk));
206
+ buffer.length = 0;
207
+ return result
208
+ }
bun.lockb CHANGED
Binary file
package.json CHANGED
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "workspaces": [
3
- "packages/example",
4
- "packages/parotta"
3
+ "packages/*"
5
4
  ]
6
5
  }
packages/create-parotta-app/bun.lockb DELETED
Binary file
packages/{create-parotta-app → create-parotta}/index.js RENAMED
File without changes
packages/{create-parotta-app → create-parotta}/package.json RENAMED
File without changes
packages/example/.gitignore CHANGED
@@ -1,3 +1,5 @@
1
1
  .cache
2
2
  .dist
3
- node_modules
3
+ node_modules
4
+ .env
5
+ test-results
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/bun.lockb CHANGED
Binary file
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 CHANGED
@@ -1,13 +1,92 @@
1
- import React from 'react';
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";
2
7
 
8
+ // const propTypes = {
9
+ // // todo: PropTypes.shape(TodoPropType).isRequired,
10
+ // }
11
+
3
- const Todo = ({ id, text }) => {
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
+ // })
4
22
  return (
5
- <div id={id}>
23
+ <li className="todo">
24
+ {!state.editing && (
6
- <p>
25
+ <label>
7
- {text}
8
- </p>
26
+ <input
27
+ type="checkbox"
28
+ checked={todo.completed}
29
+ // onChange={(e) => updateMutation.mutate({ completed: e.target.checked })}
30
+ />{" "}
31
+ <span className={todo.completed ? "done" : undefined}>{todo.text}</span>{" "}
9
- </div>
32
+ </label>
10
- )
33
+ )}
34
+
35
+ {/* {state.editing && (
36
+ <InputGroup
37
+ autoFocus
38
+ value={state.text}
39
+ onChange={(e) => setState({ text: e.target.value, editing: true })}
40
+ onKeyDown={async (e) => {
41
+ if (e.key === "Enter") {
42
+ await updateMutation.mutate({ text: state.text });
43
+ setState({ text: todo.text, editing: false });
44
+ } else if (e.key === "Escape") {
45
+ setState({ text: todo.text, editing: false });
11
- }
46
+ }
47
+ }}
48
+ />
49
+ )}
50
+
51
+ <span>
52
+ {!todo.completed && !state.editing && (
53
+ <Button
54
+ onClick={() => setState({ text: todo.text, editing: true })}
55
+ >
56
+ Edit
57
+ </Button>
58
+ )}
59
+
60
+ {todo.completed && (
61
+ <Button loading={deleteMutation.isMutating} onClick={deleteMutation.mutate}>
62
+ Delete
63
+ </Button>
64
+ )}
65
+
66
+ {state.editing && state.text !== todo.text && (
67
+ <Button
68
+ loading={updateMutation.isMutating}
69
+ onClick={async () => {
70
+ await updateMutation.mutate({ text: state.text });
71
+ setState({ text: todo.text, editing: false });
72
+ }}
73
+ >
74
+ Save
75
+ </Button>
76
+ )}
77
+
78
+ {state.editing && (
79
+ <Button
80
+ onClick={() => setState({ text: todo.text, editing: false })}
81
+ >
82
+ Cancel
83
+ </Button>
84
+ )}
85
+ </span> */}
86
+ </li>
87
+ );
88
+ };
89
+
90
+ // Todo.propTypes = propTypes;
12
91
 
13
92
  export default Todo;
packages/example/db/index.js ADDED
@@ -0,0 +1,22 @@
1
+ import { pgTable, serial, text } from 'drizzle-orm/pg-core';
2
+ import { drizzle } from 'drizzle-orm/neon-serverless';
3
+ import { Pool } from '@neondatabase/serverless';
4
+ import { migrate } from 'drizzle-orm/neon-serverless/migrator';
5
+ import { highlight } from 'sql-highlight';
6
+
7
+ export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
8
+ export const db = drizzle(pool, {
9
+ logger: {
10
+ logQuery: (query, params) => {
11
+ console.log("\033[38;2;0;0;255mSQL:\033[0m", highlight(query));
12
+ console.log("\033[38;2;225;225;0mVAL:\033[0m", params);
13
+ }
14
+ }
15
+ });
16
+
17
+ // await migrate(db, { migrationsFolder: './migrations' });
18
+
19
+ export const todos = pgTable('todos', {
20
+ id: serial('id').primaryKey(),
21
+ text: text('text').notNull(),
22
+ });
packages/example/db/migrations/0000_legal_natasha_romanoff.sql ADDED
@@ -0,0 +1,4 @@
1
+ CREATE TABLE IF NOT EXISTS "todos" (
2
+ "id" serial PRIMARY KEY NOT NULL,
3
+ "text" text
4
+ );
packages/example/db/migrations/meta/0000_snapshot.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "version": "5",
3
+ "dialect": "pg",
4
+ "id": "f8beaba0-4db8-4b64-be93-05eb0482c077",
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": false
22
+ }
23
+ },
24
+ "indexes": {},
25
+ "foreignKeys": {},
26
+ "compositePrimaryKeys": {}
27
+ }
28
+ },
29
+ "enums": {},
30
+ "schemas": {},
31
+ "_meta": {
32
+ "schemas": {},
33
+ "tables": {},
34
+ "columns": {}
35
+ }
36
+ }
packages/example/db/migrations/meta/_journal.json ADDED
@@ -0,0 +1 @@
1
+ {"version":"5","dialect":"pg","entries":[]}
packages/example/drizzle.config.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "out": "./db/migrations/",
3
+ "schema": "./db/index.js"
4
+ }
packages/example/package.json CHANGED
@@ -1,17 +1,50 @@
1
1
  {
2
- "name": "quickstart",
2
+ "name": "parotta-example",
3
3
  "type": "module",
4
4
  "scripts": {
5
5
  "dev": "bunx parotta",
6
6
  "start": "bunx parotta",
7
7
  "build": "docker build . -t example",
8
- "run": "docker run -p 3000:3000 example"
8
+ "run": "docker run -p 3000:3000 example",
9
+ "test": "playwright test",
10
+ "test:report": "playwright show-report"
9
11
  },
10
12
  "dependencies": {
13
+ "@neondatabase/serverless": "^0.2.9",
14
+ "@planetscale/database": "^1.6.0",
15
+ "drizzle-orm": "^0.23.4",
11
16
  "react": "18.2.0",
12
- "react-dom": "^18.2.0"
17
+ "react-dom": "^18.2.0",
18
+ "sql-highlight": "^4.3.2"
19
+ },
20
+ "devDependencies": {
21
+ "drizzle-kit": "^0.17.1",
22
+ "@playwright/test": "^1.31.2",
23
+ "eslint": "^8.35.0",
24
+ "eslint-config-react-app": "^7.0.1",
25
+ "eslint-config-recommended": "^4.1.0"
13
26
  },
14
27
  "parotta": {
15
- "hydrate": true
28
+ "hydrate": true,
29
+ "css": "@blueprintjs/core/lib/css/blueprint.css"
30
+ },
31
+ "eslintConfig": {
32
+ "root": true,
33
+ "parserOptions": {
34
+ "ecmaVersion": "latest",
35
+ "sourceType": "module"
36
+ },
37
+ "extends": [
38
+ "eslint:recommended",
39
+ "react-app"
40
+ ],
41
+ "ignorePatterns": [
42
+ "dist"
43
+ ],
44
+ "rules": {
45
+ "react/prop-types": "warn",
46
+ "react/react-in-jsx-scope": "off",
47
+ "no-unused-vars": "warn"
48
+ }
16
49
  }
17
50
  }
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,14 @@
1
+ # parotta-example
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
+ ## Testing
13
+
14
+ `bun run test`
packages/example/routes/about/page.jsx CHANGED
@@ -14,9 +14,7 @@ export const Body = () => {
14
14
  <div className="about-page">
15
15
  <div>
16
16
  <h1>About Page</h1>
17
- <p>
18
- Path: {router.pathname}
17
+ <p>Showcase of using parotta meta-framework.</p>
19
- </p>
20
18
  </div>
21
19
  <footer>
22
20
  <Link href="/">Back</Link>
packages/example/routes/{api.js → api/index.js} RENAMED
File without changes
packages/example/routes/api/todos/index.js ADDED
@@ -0,0 +1,50 @@
1
+ import { gt, eq } from 'drizzle-orm/expressions';
2
+ import { db, todos } from "@/db";
3
+
4
+ export const onGet = async (req) => {
5
+ const url = new URL(req.url);
6
+ const page = parseInt(url.searchParams.get("page") || "1", 10);
7
+ const results = await db.select().from(todos).where(gt(todos.id, 0)).limit(page * 5).offset((page - 1) * 5);
8
+ return new Response(JSON.stringify(results), {
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ },
12
+ status: 200,
13
+ });
14
+ }
15
+
16
+ export const onPost = async (req) => {
17
+ const body = await req.body();
18
+ const input = JSON.parse(body);
19
+ const data = await db.insert(todos).values(input).returning();
20
+ return new Response(JSON.stringify(data), {
21
+ headers: {
22
+ "Content-Type": "application/json",
23
+ },
24
+ status: 200,
25
+ });
26
+ }
27
+
28
+ export const onPatch = async (req) => {
29
+ const body = await req.body();
30
+ const input = JSON.parse(body);
31
+ const data = await db.update(todos).set(input).where(eq(todos.id, input.id)).returning();
32
+ return new Response(JSON.stringify(data), {
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ },
36
+ status: 200,
37
+ });
38
+ }
39
+
40
+ export const onDelete = async (req) => {
41
+ const url = new URL(req.url);
42
+ const id = url.searchParams.get("id");
43
+ const data = await db.delete(todos).where(eq(todos.id, id)).returning();
44
+ return new Response(JSON.stringify(data), {
45
+ headers: {
46
+ "Content-Type": "application/json",
47
+ },
48
+ status: 200,
49
+ });
50
+ }
packages/example/routes/layout.jsx CHANGED
@@ -2,6 +2,7 @@ import React, { Suspense } from 'react';
2
2
  import { Link } from "parotta/router";
3
3
  import { ErrorBoundary } from "parotta/error";
4
4
  import "./layout.css";
5
+ // import '@blueprintjs/core/lib/css/blueprint.css';
5
6
 
6
7
  const Layout = ({ children }) => {
7
8
  return (
packages/example/routes/page.jsx CHANGED
@@ -1,24 +1,18 @@
1
1
  import React, { useEffect } from 'react';
2
2
  import { useRouter } from "parotta/router";
3
- import { useFetch } from "parotta/fetch";
4
3
  import Counter from "@/components/Counter/Counter";
5
4
  import "./page.css";
6
5
 
7
6
  export const Head = () => {
8
7
  return (
9
- <title>Parotta</title>
8
+ <title>Parotta App</title>
10
9
  )
11
10
  }
12
11
 
13
12
  export const Body = () => {
14
13
  const router = useRouter();
15
- const { data, cache } = useFetch("/todos");
16
- console.log('page');
17
- console.log('data', data);
18
14
  useEffect(() => {
19
- setTimeout(() => {
15
+
20
- cache.invalidate(/todos/);
21
- }, 3000)
22
16
  }, []);
23
17
  return (
24
18
  <div>
packages/example/routes/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/routes/todos/:id/api.js DELETED
@@ -1,55 +0,0 @@
1
- import { todosCollection } from "@/services/collections";
2
-
3
- const getId = (req) => {
4
- const url = new URL(req.url);
5
- const res = new RegExp("/todos/(.*?)$").exec(url.pathname)
6
- return res[1];
7
- }
8
-
9
- export const onGet = async (req) => {
10
- const id = getId(req);
11
- const item = await todosCollection.findOne({
12
- filter: { id },
13
- });
14
- if (!item) {
15
- return new Response(`todo '${id}' not found`, {
16
- headers: {
17
- "Content-Type": "application/json",
18
- },
19
- status: 404,
20
- });
21
- }
22
- const data = JSON.stringify(item);
23
- return new Response(data, {
24
- headers: {
25
- "Content-Type": "application/json",
26
- },
27
- status: 200,
28
- });
29
- }
30
-
31
- export const onPut = async (req) => {
32
- const item = await req.body();
33
- const updated = await todosCollection.insertOrReplaceOne(item);
34
- const data = JSON.stringify(updated);
35
- return new Response(data, {
36
- headers: {
37
- "Content-Type": "application/json",
38
- },
39
- status: 200,
40
- });
41
- }
42
-
43
- export const onDelete = async (req) => {
44
- const id = getId(req);
45
- const res = await todosCollection.deleteOne({
46
- filter: { id },
47
- });
48
- const data = JSON.stringify(res);
49
- return new Response(data, {
50
- headers: {
51
- "Content-Type": "application/json",
52
- },
53
- status: 200,
54
- });
55
- }
packages/example/routes/todos/:id/page.css DELETED
@@ -1,4 +0,0 @@
1
- .body{
2
- padding: 10px;
3
- background-color: turquoise;
4
- }
packages/example/routes/todos/:id/page.jsx DELETED
@@ -1,21 +0,0 @@
1
- import { Suspense } from "react";
2
- import { Link, useRouter } from "parotta/router";
3
- import { useFetch } from "parotta/fetch";
4
-
5
- export const Head = () => {
6
- const { params } = useRouter();
7
- return (
8
- <title>Todo: {params.id}</title>
9
- )
10
- }
11
-
12
- export const Body = () => {
13
- const { params } = useRouter();
14
- const data = useFetch(`/todos/${params.id}`);
15
- console.log('data', data);
16
- return (
17
- <div>
18
- <h1>Todo no: {params.id}</h1>
19
- </div>
20
- )
21
- }
packages/example/routes/todos/api.js DELETED
@@ -1,26 +0,0 @@
1
- import { todosCollection } from "@/services/collections";
2
-
3
- export const onGet = async (req) => {
4
- const cursor = todosCollection.findMany({});
5
- const items = await cursor.toArray();
6
- const data = JSON.stringify(items);
7
- return new Response(data, {
8
- headers: {
9
- "Content-Type": "application/json",
10
- },
11
- status: 200,
12
- });
13
- }
14
-
15
- export const onPost = async (req) => {
16
- const body = await req.body();
17
- const item = JSON.parse(body);
18
- const inserted = await todosCollection.insertOne(item);
19
- const data = JSON.stringify(inserted);
20
- return new Response(data, {
21
- headers: {
22
- "Content-Type": "application/json",
23
- },
24
- status: 200,
25
- });
26
- }
packages/example/routes/todos/page.jsx CHANGED
@@ -1,5 +1,7 @@
1
+ import React, { useEffect } from 'react';
1
- import { Suspense } from "react";
2
+ import { useFetch } from "parotta/fetch";
2
- import TodoList from "@/containers/TodoList/TodoList";
3
+ import Todo from "@/components/Todo/Todo";
4
+ import "./page.css";
3
5
 
4
6
  export const Head = () => {
5
7
  return (
@@ -8,12 +10,23 @@ export const Head = () => {
8
10
  }
9
11
 
10
12
  export const Body = () => {
13
+ const { data, cache } = useFetch("/api/todos");
14
+ useEffect(() => {
15
+ setTimeout(() => {
16
+ cache.invalidate(/todos/);
17
+ }, 3000)
18
+ }, []);
11
19
  return (
12
20
  <div>
13
21
  <h1>Todos</h1>
22
+ <ul>
23
+ {data?.map((item) => (
14
- {/* <Suspense>
24
+ <li key={item.id}>
15
- <TodoList todos={todos} />
25
+ <Todo todo={item} />
26
+ </li>
27
+ ))}
16
- </Suspense> */}
28
+ </ul>
17
29
  </div>
18
30
  )
19
- }
31
+ }
32
+
packages/example/services/collections.js DELETED
@@ -1,35 +0,0 @@
1
- const getCollection = (name) => {
2
- let items = [];
3
- return {
4
- findMany: () => {
5
- return {
6
- toArray: async () => {
7
- await new Promise((res) => setTimeout(res, 500));
8
- return items;
9
- }
10
- }
11
- },
12
- findOne: ({ filter }) => {
13
- return items.find((item) => item.id === filter.id)
14
- },
15
- insertOne: (item) => {
16
- items.push(item);
17
- return item;
18
- },
19
- insertOrReplaceOne: (item) => {
20
- const index = items.findIndex((it) => it.id === item.id);
21
- if (index) {
22
- items[index] = item;
23
- } else {
24
- items.push(item);
25
- }
26
- return item;
27
- },
28
- deleteOne: ({ filter }) => {
29
- items = items.filter((item) => item.id !== filter.id);
30
- return { id: filter.id }
31
- },
32
- }
33
- }
34
-
35
- export const todosCollection = getCollection("todos");
packages/parotta/bun.lockb CHANGED
Binary file
packages/parotta/cli.js CHANGED
@@ -12,7 +12,7 @@ import postcssNesting from "postcss-nesting";
12
12
  import { createMemoryHistory } from "history";
13
13
  import { createRouter } from 'radix3';
14
14
  import mimeTypes from "mime-types";
15
- import { Head, Body } from "./router";
15
+ import { HeadApp, BodyApp } from "./router";
16
16
  import { renderToReadableStream } from 'react-dom/server';
17
17
  // import { renderToStream } from './render';
18
18
 
@@ -44,9 +44,9 @@ const mapFiles = () => {
44
44
  const key = item.route || "/";
45
45
  routes[key].layout = item.path;
46
46
  });
47
- dirs.filter((p) => p.includes('api.js'))
47
+ dirs.filter((p) => p.includes('index.js'))
48
48
  .map((s) => s.replace(process.cwd(), ""))
49
- .map((s) => ({ path: s, route: s.replace("/api.js", "") }))
49
+ .map((s) => ({ path: s, route: s.replace("/index.js", "") }))
50
50
  .forEach((api) => {
51
51
  const key = api.route || "/";
52
52
  routes[key] = routes[key] || { key };
@@ -91,7 +91,6 @@ const clientRoutes = await clientSideRoutes.reduce(async (accp, r) => {
91
91
  const lpath = exists ? `/routes${r}/layout.jsx` : `/routes/layout.jsx`;
92
92
  const lsrc = await import(`${process.cwd()}${lpath}`);
93
93
  acc[r === "" ? "/" : r] = {
94
- r,
95
94
  Head: src.Head,
96
95
  Body: src.Body,
97
96
  Layout: lsrc.default,
@@ -132,6 +131,8 @@ const renderApi = async (filePath, req) => {
132
131
  }
133
132
  }
134
133
 
134
+ console.log(clientRoutes)
135
+
135
136
  const renderPage = async (url) => {
136
137
  const packageJson = await import(path.join(process.cwd(), "package.json"));
137
138
  const config = packageJson.default.parotta || { hydrate: true };
@@ -166,18 +167,14 @@ const renderPage = async (url) => {
166
167
  const stream = await renderToReadableStream(
167
168
  <html lang="en">
168
169
  <head>
169
- <Head
170
+ <HeadApp
170
171
  history={history}
171
172
  radixRouter={clientRouter}
172
173
  importMap={importMap}
173
174
  />
174
175
  </head>
175
176
  <body>
176
- <Body
177
- App={React.lazy(() => import(`${process.cwd()}/routes/app.jsx`))}
178
- history={history}
179
- radixRouter={clientRouter}
177
+ <BodyApp history={history} radixRouter={clientRouter} />
180
- />
181
178
  {config.hydrate &&
182
179
  <>
183
180
  <script type="module" defer={true} dangerouslySetInnerHTML={{
@@ -186,13 +183,14 @@ import React from "react";
186
183
  import { hydrateRoot } from "react-dom/client";
187
184
  import { createBrowserHistory } from "history";
188
185
  import { createRouter } from "radix3";
189
- import { Head, Body } from "parotta/router";
186
+ import { HeadApp, BodyApp } from "parotta/router";
187
+
190
188
 
191
189
  const history = createBrowserHistory();
192
190
  const radixRouter = createRouter({
193
191
  strictTrailingSlash: true,
194
192
  routes: {
195
- ${Object.keys(clientRoutes).map((r) => `"${r === "" ? "/" : r}": {
193
+ ${Object.keys(clientRoutes).map((r) => `"${r}": {
196
194
  Head: React.lazy(() => import("/routes${r}/page.jsx").then((js) => ({ default: js.Head }))),
197
195
  Body: React.lazy(() => import("/routes${r}/page.jsx").then((js) => ({ default: js.Body }))),
198
196
  Layout: React.lazy(() => import("${clientRoutes[r].LayoutPath}")),
@@ -201,13 +199,12 @@ const radixRouter = createRouter({
201
199
  },
202
200
  });
203
201
 
204
- hydrateRoot(document.head, React.createElement(Head, {
202
+ hydrateRoot(document.head, React.createElement(HeadApp, {
205
203
  history,
206
204
  radixRouter,
207
205
  }))
208
206
 
209
- hydrateRoot(document.body, React.createElement(Body, {
207
+ hydrateRoot(document.body, React.createElement(BodyApp, {
210
- App: React.lazy(() => import("/routes/app.jsx")),
211
208
  history,
212
209
  radixRouter,
213
210
  }));`}}>
@@ -258,10 +255,9 @@ const renderJs = async (src) => {
258
255
  try {
259
256
  const jsText = await Bun.file(src).text();
260
257
  const result = await transpiler.transform(jsText);
261
- const js = result.replaceAll(`import"./page.css";`, "").replaceAll(`import"./layout.css";`, "");;
258
+ const filteredJsx = result.split("\n").filter((ln) => !ln.includes(".css")).join("\n");
262
- // TODO
263
259
  //.replaceAll("$jsx", "React.createElement");
264
- return new Response(js, {
260
+ return new Response(filteredJsx, {
265
261
  headers: {
266
262
  'Content-Type': 'application/javascript',
267
263
  },
packages/parotta/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parotta",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "autoprefixer": "^10.4.14",
packages/parotta/router.js CHANGED
@@ -16,10 +16,10 @@ const getMatch = (radixRouter, pathname) => {
16
16
 
17
17
  const getCssUrl = (pathname) => `/routes${pathname === "/" ? "" : pathname}`;
18
18
 
19
- export const Head = ({ history, radixRouter, importMap }) => {
19
+ export const HeadApp = ({ history, radixRouter, importMap }) => {
20
20
  const pathname = useSyncExternalStore(history.listen, (v) => v ? v.location.pathname : history.location.pathname, () => history.location.pathname);
21
21
  const match = getMatch(radixRouter, pathname);
22
- const initialCss = useMemo(() => getCssUrl(history.location.pathname), []);
22
+ // const initialCss = useMemo(() => getCssUrl(history.location.pathname), []);
23
23
  return createElement(Fragment, {
24
24
  children: [
25
25
  createElement("link", {
@@ -50,7 +50,7 @@ export const Head = ({ history, radixRouter, importMap }) => {
50
50
  });
51
51
  }
52
52
 
53
- export const Body = ({ history, radixRouter }) => {
53
+ export const BodyApp = ({ history, radixRouter }) => {
54
54
  const [isPending, startTransition] = useTransition();
55
55
  const [match, setMatch] = useState(() => getMatch(radixRouter, history.location.pathname));
56
56
  useEffect(() => {
@@ -82,8 +82,7 @@ export const Body = ({ history, radixRouter }) => {
82
82
  if (!isPending) {
83
83
  nProgress.done();
84
84
  }
85
- }, [isPending])
85
+ }, [isPending]);
86
- console.log('Router', isPending);
87
86
  return createElement(RouterContext.Provider, {
88
87
  value: {
89
88
  history: history,
@@ -126,4 +125,10 @@ export const Link = (props) => {
126
125
  router.push(props.href)
127
126
  },
128
127
  })
128
+ }
129
+
130
+ export const NavLink = ({ children, className, activeClassName, ...props }) => {
131
+ const { pathname } = useRouter();
132
+ const classNames = pathname === props.href ? [activeClassName, className] : [className];
133
+ return <Link className={classNames} {...props} >{children}</Link>
129
134
  }
readme.md CHANGED
@@ -6,7 +6,7 @@ It is very opionated and has set of idiomatic ways of doing things.
6
6
 
7
7
  ### Todo
8
8
  1. Add build step
9
- 2. Deploy to Docker, Deno deploy, Vercel edge functions, Cloudflare workers, Bun edge (whenever it releases)
9
+ 2. Deploy to Node (using edge-runtime), Docker, Deno deploy, Vercel edge functions, Cloudflare workers, Bun edge (whenever it releases)
10
10
  3. Hydrate fetch cache
11
- 4. Build a proper example (create-parotta-app)
11
+ 4. Build a proper example (bunx create-parotta@latest)
12
- 5. Build a Webiste with Docs using parotta
12
+ 5. Build a Website with Docs using parotta