create-strata 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/cli.js +4222 -0
- package/dist/templates/.env.example +22 -0
- package/dist/templates/docker-compose.yml +14 -0
- package/dist/templates/overlays/api/docs/API.md +49 -0
- package/dist/templates/overlays/server-htmx/public/assets/app.css +89 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/error.eta +14 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/forbidden.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/not-found.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/layouts/app.eta +34 -0
- package/dist/templates/overlays/server-htmx/resources/views/organizations/_table.eta +23 -0
- package/dist/templates/overlays/server-htmx/resources/views/organizations/index.eta +37 -0
- package/dist/templates/overlays/server-htmx/resources/views/pages/home.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/partials/_flash.eta +3 -0
- package/dist/templates/overlays/spa-react/frontend/build.ts +17 -0
- package/dist/templates/overlays/spa-react/frontend/bun-env.d.ts +4 -0
- package/dist/templates/overlays/spa-react/frontend/bun.lock +51 -0
- package/dist/templates/overlays/spa-react/frontend/dev-server.ts +66 -0
- package/dist/templates/overlays/spa-react/frontend/index.html +12 -0
- package/dist/templates/overlays/spa-react/frontend/package.json +21 -0
- package/dist/templates/overlays/spa-react/frontend/src/App.tsx +59 -0
- package/dist/templates/overlays/spa-react/frontend/src/api/client.ts +86 -0
- package/dist/templates/overlays/spa-react/frontend/src/app.css +98 -0
- package/dist/templates/overlays/spa-react/frontend/src/auth/AuthContext.tsx +103 -0
- package/dist/templates/overlays/spa-react/frontend/src/auth/tokenStorage.ts +15 -0
- package/dist/templates/overlays/spa-react/frontend/src/main.tsx +22 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/HomePage.tsx +72 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +70 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/NotFoundPage.tsx +12 -0
- package/dist/templates/overlays/spa-react/frontend/tsconfig.json +17 -0
- package/dist/templates/package.json +22 -0
- package/dist/templates/public/assets/site.css +29 -0
- package/dist/templates/src/bootstrap/config.ts +29 -0
- package/dist/templates/src/bootstrap/createApp.ts +94 -0
- package/dist/templates/src/bootstrap/database.ts +30 -0
- package/dist/templates/src/bootstrap/preload.ts +5 -0
- package/dist/templates/src/bootstrap/providers/auth.ts +41 -0
- package/dist/templates/src/bootstrap/providers/cache.ts +28 -0
- package/dist/templates/src/bootstrap/providers/config.ts +27 -0
- package/dist/templates/src/bootstrap/providers/index.ts +14 -0
- package/dist/templates/src/bootstrap/providers/storage.ts +11 -0
- package/dist/templates/src/bootstrap/server.ts +25 -0
- package/dist/templates/src/db/fresh.ts +19 -0
- package/dist/templates/src/db/migrate.ts +35 -0
- package/dist/templates/src/lib/view.ts +21 -0
- package/dist/templates/src/modules/site/index.ts +29 -0
- package/dist/templates/src/routes.ts +6 -0
- package/dist/templates/strata.config.ts +7 -0
- package/dist/templates/tsconfig.json +14 -0
- package/dist/templates/views/home.eta +5 -0
- package/dist/templates/views/layouts/app.eta +18 -0
- package/package.json +29 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/{{PROJECT_NAME}}
|
|
2
|
+
PORT=3000
|
|
3
|
+
APP_URL=http://localhost:3000
|
|
4
|
+
APP_ENV=local
|
|
5
|
+
APP_NAME={{PROJECT_NAME}}
|
|
6
|
+
# Cookie/Redis namespace. Unset defaults to `strata`.
|
|
7
|
+
# APP_KEY_PREFIX={{PROJECT_NAME}}
|
|
8
|
+
FRONTEND_MODE=api
|
|
9
|
+
# SPA_PREFIX=/app
|
|
10
|
+
TENANCY_DRIVER=none
|
|
11
|
+
|
|
12
|
+
# Required in production when FRONTEND_MODE=server-htmx or hybrid
|
|
13
|
+
# SESSION_SECRET=
|
|
14
|
+
|
|
15
|
+
# Set only behind a trusted reverse proxy
|
|
16
|
+
# TRUST_FORWARDED_FOR=true
|
|
17
|
+
|
|
18
|
+
# Required in production to expose GET /metrics
|
|
19
|
+
# METRICS_TOKEN=
|
|
20
|
+
|
|
21
|
+
# Production should set FEATURE_PUBLIC_READS=false
|
|
22
|
+
# FEATURE_PUBLIC_READS=false
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# API-only scaffold
|
|
2
|
+
|
|
3
|
+
This scaffold sets `FRONTEND_MODE=api`: no server-rendered views beyond the welcome page, and no SPA assets.
|
|
4
|
+
|
|
5
|
+
## What exists after migrating
|
|
6
|
+
|
|
7
|
+
- `GET /health` returns plain text `ok`, or `degraded` when the database ping fails.
|
|
8
|
+
- `GET /` returns the welcome page.
|
|
9
|
+
- A `notes` table, with no routes on it yet.
|
|
10
|
+
|
|
11
|
+
Login endpoints depend on the auth layer you chose. Token auth adds `POST /api/v1/auth/login` and `GET /api/v1/auth/me`; JWT auth adds `POST /api/auth/token`; header auth adds none and expects `x-authenticated-user-id` for local work only.
|
|
12
|
+
|
|
13
|
+
There is no CRUD endpoint for `notes`. Adding your own routes is the first thing you do.
|
|
14
|
+
|
|
15
|
+
## Adding a route
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// src/modules/notes/index.ts
|
|
19
|
+
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
20
|
+
import { jsonResponse } from "@getstrata/core/http/response";
|
|
21
|
+
import { getSql } from "../../bootstrap/database.ts";
|
|
22
|
+
|
|
23
|
+
const notesModule: AppModule = {
|
|
24
|
+
name: "notes",
|
|
25
|
+
order: 2,
|
|
26
|
+
routes({ kernel }) {
|
|
27
|
+
return {
|
|
28
|
+
"/api/v1/notes": kernel.wrap("api", async () => {
|
|
29
|
+
const rows = await getSql().unsafe<{ id: number; body: string }>(
|
|
30
|
+
"SELECT id, body FROM notes ORDER BY id DESC",
|
|
31
|
+
);
|
|
32
|
+
return jsonResponse({ data: rows });
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default notesModule;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Import from `@getstrata/core/...` subpaths rather than the package root, so singleton state such as the database pool stays shared.
|
|
42
|
+
|
|
43
|
+
## Generating an app instead
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bunx create-strata my-app --frontend api --yes
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A generated app writes its own `docs/API.md` listing only the routes its layers actually serve.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
font-family: system-ui, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
background: #f6f7fb;
|
|
9
|
+
color: #1f2937;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.site-header {
|
|
13
|
+
display: flex;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
align-items: center;
|
|
16
|
+
padding: 1rem 1.5rem;
|
|
17
|
+
background: #111827;
|
|
18
|
+
color: #f9fafb;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.site-header a {
|
|
22
|
+
color: inherit;
|
|
23
|
+
text-decoration: none;
|
|
24
|
+
margin-right: 1rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.site-main {
|
|
28
|
+
max-width: 960px;
|
|
29
|
+
margin: 0 auto;
|
|
30
|
+
padding: 1.5rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.page-header,
|
|
34
|
+
.create-form,
|
|
35
|
+
.card {
|
|
36
|
+
background: #fff;
|
|
37
|
+
border-radius: 0.75rem;
|
|
38
|
+
padding: 1.25rem;
|
|
39
|
+
margin-bottom: 1rem;
|
|
40
|
+
box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.stack-form {
|
|
44
|
+
display: grid;
|
|
45
|
+
gap: 0.75rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.flash {
|
|
49
|
+
padding: 0.75rem 1rem;
|
|
50
|
+
border-radius: 0.5rem;
|
|
51
|
+
margin-bottom: 1rem;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.flash-success {
|
|
55
|
+
background: #ecfdf5;
|
|
56
|
+
color: #065f46;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.flash-error {
|
|
60
|
+
background: #fef2f2;
|
|
61
|
+
color: #991b1b;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.form-errors {
|
|
65
|
+
background: #fef2f2;
|
|
66
|
+
color: #991b1b;
|
|
67
|
+
padding: 0.75rem 1rem;
|
|
68
|
+
border-radius: 0.5rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.inline-form {
|
|
72
|
+
display: inline;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.hint {
|
|
76
|
+
color: #6b7280;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
table {
|
|
80
|
+
width: 100%;
|
|
81
|
+
border-collapse: collapse;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
th,
|
|
85
|
+
td {
|
|
86
|
+
padding: 0.5rem;
|
|
87
|
+
border-bottom: 1px solid #e5e7eb;
|
|
88
|
+
text-align: left;
|
|
89
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<section class="page-header">
|
|
2
|
+
<h1><%= it.title ?? "Something went wrong" %></h1>
|
|
3
|
+
<p><%= it.message ?? "Please try again." %></p>
|
|
4
|
+
<% if (it.errors) { %>
|
|
5
|
+
<ul class="error-list">
|
|
6
|
+
<% for (const [field, messages] of Object.entries(it.errors)) { %>
|
|
7
|
+
<% for (const message of messages) { %>
|
|
8
|
+
<li><%= field %>: <%= message %></li>
|
|
9
|
+
<% } %>
|
|
10
|
+
<% } %>
|
|
11
|
+
</ul>
|
|
12
|
+
<p><a href="javascript:history.back()">Go back</a></p>
|
|
13
|
+
<% } %>
|
|
14
|
+
</section>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title><%= it.title ?? "Strata" %></title>
|
|
7
|
+
<meta name="csrf-token" content="<%= it.csrfToken ?? "" %>" />
|
|
8
|
+
<meta name="htmx-config" content='{"inlineStyleNonce":"<%= it.cspNonce ?? "" %>"}' />
|
|
9
|
+
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
10
|
+
<script nonce="<%= it.cspNonce ?? "" %>">
|
|
11
|
+
document.addEventListener("htmx:configRequest", (event) => {
|
|
12
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
|
|
13
|
+
if (token) {
|
|
14
|
+
event.detail.headers["X-CSRF-Token"] = token;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<header class="site-header">
|
|
22
|
+
<a href="/organizations" class="brand">Strata</a>
|
|
23
|
+
<nav>
|
|
24
|
+
<a href="/organizations">Organizations</a>
|
|
25
|
+
</nav>
|
|
26
|
+
</header>
|
|
27
|
+
<main class="site-main">
|
|
28
|
+
<% if (it.flash) { %>
|
|
29
|
+
<div class="flash flash-<%= it.flash.level %>" role="status"><%= it.flash.message %></div>
|
|
30
|
+
<% } %>
|
|
31
|
+
<%~ it.body %>
|
|
32
|
+
</main>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<table>
|
|
2
|
+
<thead>
|
|
3
|
+
<tr>
|
|
4
|
+
<th>Name</th>
|
|
5
|
+
<th>Slug</th>
|
|
6
|
+
<th></th>
|
|
7
|
+
</tr>
|
|
8
|
+
</thead>
|
|
9
|
+
<tbody>
|
|
10
|
+
<% for (const organization of it.organizations ?? []) { %>
|
|
11
|
+
<tr>
|
|
12
|
+
<td><%= organization.name %></td>
|
|
13
|
+
<td><code><%= organization.slug %></code></td>
|
|
14
|
+
<td>
|
|
15
|
+
<form method="post" action="/organizations/<%= organization.id %>/delete" class="inline-form">
|
|
16
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken ?? "" %>" />
|
|
17
|
+
<button type="submit">Delete</button>
|
|
18
|
+
</form>
|
|
19
|
+
</td>
|
|
20
|
+
</tr>
|
|
21
|
+
<% } %>
|
|
22
|
+
</tbody>
|
|
23
|
+
</table>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<section class="page-header">
|
|
2
|
+
<h1>Organizations</h1>
|
|
3
|
+
<p class="hint">Sample HTMX CRUD scaffold with CSRF and flash messages.</p>
|
|
4
|
+
</section>
|
|
5
|
+
|
|
6
|
+
<section class="create-form">
|
|
7
|
+
<h2>Create organization</h2>
|
|
8
|
+
|
|
9
|
+
<% if (Object.keys(it.errors ?? {}).length > 0) { %>
|
|
10
|
+
<div class="form-errors" role="alert">
|
|
11
|
+
<% for (const [field, messages] of Object.entries(it.errors)) { %>
|
|
12
|
+
<% for (const message of messages) { %>
|
|
13
|
+
<p><strong><%= field %></strong>: <%= message %></p>
|
|
14
|
+
<% } %>
|
|
15
|
+
<% } %>
|
|
16
|
+
</div>
|
|
17
|
+
<% } %>
|
|
18
|
+
|
|
19
|
+
<form method="post" action="/organizations" class="stack-form">
|
|
20
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken ?? "" %>" />
|
|
21
|
+
<label>
|
|
22
|
+
Name
|
|
23
|
+
<input type="text" name="name" value="<%= it.old?.name ?? '' %>" required />
|
|
24
|
+
</label>
|
|
25
|
+
|
|
26
|
+
<label>
|
|
27
|
+
Slug
|
|
28
|
+
<input type="text" name="slug" value="<%= it.old?.slug ?? '' %>" required />
|
|
29
|
+
</label>
|
|
30
|
+
|
|
31
|
+
<button type="submit">Create</button>
|
|
32
|
+
</form>
|
|
33
|
+
</section>
|
|
34
|
+
|
|
35
|
+
<div id="org-table">
|
|
36
|
+
<%~ include('./_table', it) %>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const result = await Bun.build({
|
|
2
|
+
entrypoints: ["./index.html"],
|
|
3
|
+
outdir: "./dist",
|
|
4
|
+
target: "browser",
|
|
5
|
+
minify: true,
|
|
6
|
+
publicPath: "/app/",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
if (!result.success) {
|
|
10
|
+
for (const log of result.logs) {
|
|
11
|
+
console.error(log);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
throw new Error("Frontend build failed.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`Built ${result.outputs.length} frontend files into dist/`);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "strata-frontend",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"react": "^19.1.1",
|
|
9
|
+
"react-dom": "^19.1.1",
|
|
10
|
+
"react-router-dom": "^7.8.0",
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/bun": "^1.4.0",
|
|
14
|
+
"@types/react": "^19.1.10",
|
|
15
|
+
"@types/react-dom": "^19.1.7",
|
|
16
|
+
"typescript": "^5.9.2",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
"packages": {
|
|
21
|
+
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
|
22
|
+
|
|
23
|
+
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
|
24
|
+
|
|
25
|
+
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
|
26
|
+
|
|
27
|
+
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
|
|
28
|
+
|
|
29
|
+
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
|
30
|
+
|
|
31
|
+
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
|
32
|
+
|
|
33
|
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
34
|
+
|
|
35
|
+
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
|
36
|
+
|
|
37
|
+
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
|
38
|
+
|
|
39
|
+
"react-router": ["react-router@7.18.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg=="],
|
|
40
|
+
|
|
41
|
+
"react-router-dom": ["react-router-dom@7.18.2", "", { "dependencies": { "react-router": "7.18.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw=="],
|
|
42
|
+
|
|
43
|
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
44
|
+
|
|
45
|
+
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
|
46
|
+
|
|
47
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
48
|
+
|
|
49
|
+
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import index from "./index.html";
|
|
3
|
+
|
|
4
|
+
const API_ORIGIN = process.env.API_ORIGIN ?? "http://localhost:3000";
|
|
5
|
+
const PORT = Number(process.env.FRONTEND_PORT ?? "5173");
|
|
6
|
+
const PREVIEW = process.argv.includes("--preview");
|
|
7
|
+
const DIST_DIRECTORY = join(import.meta.dir, "dist");
|
|
8
|
+
|
|
9
|
+
async function proxyToApi(request: Request): Promise<Response> {
|
|
10
|
+
const incoming = new URL(request.url);
|
|
11
|
+
const target = new URL(`${incoming.pathname}${incoming.search}`, API_ORIGIN);
|
|
12
|
+
const headers = new Headers(request.headers);
|
|
13
|
+
headers.delete("host");
|
|
14
|
+
|
|
15
|
+
return fetch(target, {
|
|
16
|
+
method: request.method,
|
|
17
|
+
headers,
|
|
18
|
+
body: request.body,
|
|
19
|
+
redirect: "manual",
|
|
20
|
+
verbose: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function servePreview(request: Request): Promise<Response> {
|
|
25
|
+
const pathname = new URL(request.url).pathname;
|
|
26
|
+
const relativePath = pathname.replace(/^\/app\/?/, "");
|
|
27
|
+
const asset = Bun.file(join(DIST_DIRECTORY, relativePath));
|
|
28
|
+
|
|
29
|
+
if (relativePath.length > 0 && (await asset.exists())) {
|
|
30
|
+
return new Response(asset);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const indexFile = Bun.file(join(DIST_DIRECTORY, "index.html"));
|
|
34
|
+
|
|
35
|
+
if (await indexFile.exists()) {
|
|
36
|
+
return new Response(indexFile, {
|
|
37
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Response("SPA build not found. Run `bun run build`.", { status: 503 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const server = Bun.serve({
|
|
45
|
+
port: PORT,
|
|
46
|
+
development: PREVIEW
|
|
47
|
+
? false
|
|
48
|
+
: {
|
|
49
|
+
hmr: true,
|
|
50
|
+
console: true,
|
|
51
|
+
},
|
|
52
|
+
routes: {
|
|
53
|
+
"/api/*": proxyToApi,
|
|
54
|
+
"/health": proxyToApi,
|
|
55
|
+
"/ready": proxyToApi,
|
|
56
|
+
"/app": PREVIEW ? servePreview : index,
|
|
57
|
+
"/app/": PREVIEW ? servePreview : index,
|
|
58
|
+
"/app/*": PREVIEW ? servePreview : index,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
console.log(
|
|
63
|
+
PREVIEW
|
|
64
|
+
? `SPA preview on ${server.url}app/ (proxy ${API_ORIGIN})`
|
|
65
|
+
: `SPA dev server on ${server.url}app/ (proxy ${API_ORIGIN})`,
|
|
66
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Strata</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "strata-frontend",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "bun --hot dev-server.ts",
|
|
7
|
+
"build": "bun run build.ts",
|
|
8
|
+
"preview": "bun run build.ts && bun dev-server.ts --preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"react": "^19.1.1",
|
|
12
|
+
"react-dom": "^19.1.1",
|
|
13
|
+
"react-router-dom": "^7.8.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "^1.4.0",
|
|
17
|
+
"@types/react": "^19.1.10",
|
|
18
|
+
"@types/react-dom": "^19.1.7",
|
|
19
|
+
"typescript": "^5.9.2"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Link, Navigate, Route, Routes } from "react-router-dom";
|
|
2
|
+
import { useAuth } from "./auth/AuthContext";
|
|
3
|
+
import HomePage from "./pages/HomePage";
|
|
4
|
+
import LoginPage from "./pages/LoginPage";
|
|
5
|
+
import NotFoundPage from "./pages/NotFoundPage";
|
|
6
|
+
|
|
7
|
+
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
8
|
+
const { token, loading } = useAuth();
|
|
9
|
+
|
|
10
|
+
if (loading) {
|
|
11
|
+
return <p className="hint">Loading session…</p>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!token) {
|
|
15
|
+
return <Navigate to="/login" replace />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return children;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function AppShell() {
|
|
22
|
+
const { user, logout } = useAuth();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="app-shell">
|
|
26
|
+
<header className="site-header">
|
|
27
|
+
<strong>Strata SPA</strong>
|
|
28
|
+
<nav>
|
|
29
|
+
<Link to="/">Home</Link>
|
|
30
|
+
{user ? (
|
|
31
|
+
<>
|
|
32
|
+
<span className="hint">{user.email}</span>
|
|
33
|
+
<button type="button" className="secondary" onClick={logout}>
|
|
34
|
+
Sign out
|
|
35
|
+
</button>
|
|
36
|
+
</>
|
|
37
|
+
) : null}
|
|
38
|
+
</nav>
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
<Routes>
|
|
42
|
+
<Route path="/login" element={<LoginPage />} />
|
|
43
|
+
<Route
|
|
44
|
+
path="/"
|
|
45
|
+
element={
|
|
46
|
+
<ProtectedRoute>
|
|
47
|
+
<HomePage />
|
|
48
|
+
</ProtectedRoute>
|
|
49
|
+
}
|
|
50
|
+
/>
|
|
51
|
+
<Route path="*" element={<NotFoundPage />} />
|
|
52
|
+
</Routes>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function App() {
|
|
58
|
+
return <AppShell />;
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const API_PREFIX = "/api/v1";
|
|
2
|
+
|
|
3
|
+
interface ApiErrorBody {
|
|
4
|
+
error?: string;
|
|
5
|
+
details?: Record<string, string[]>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class ApiError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
readonly status: number,
|
|
11
|
+
message: string,
|
|
12
|
+
readonly details?: Record<string, string[]>,
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ApiError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ApiFetchOptions extends RequestInit {
|
|
20
|
+
token?: string | null;
|
|
21
|
+
etag?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
|
|
25
|
+
const headers = new Headers(options.headers);
|
|
26
|
+
|
|
27
|
+
if (options.token) {
|
|
28
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.etag) {
|
|
32
|
+
headers.set("if-match", options.etag);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.body && !headers.has("content-type")) {
|
|
36
|
+
headers.set("content-type", "application/json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const response = await fetch(`${API_PREFIX}${path}`, {
|
|
40
|
+
...options,
|
|
41
|
+
headers,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (response.status === 204) {
|
|
45
|
+
return undefined as T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
49
|
+
|
|
50
|
+
if (!contentType.includes("application/json")) {
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new ApiError(response.status, `Request failed with ${response.status}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined as T;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const body = (await response.json()) as T & ApiErrorBody;
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new ApiError(response.status, body.error ?? "Request failed", body.details);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchResourceEtag(path: string, token: string): Promise<string> {
|
|
68
|
+
const response = await fetch(`${API_PREFIX}${path}`, {
|
|
69
|
+
headers: { authorization: `Bearer ${token}` },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new ApiError(response.status, `Failed to load ETag for ${path}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const etag = response.headers.get("etag");
|
|
77
|
+
|
|
78
|
+
if (!etag) {
|
|
79
|
+
throw new ApiError(412, `Missing ETag for ${path}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return etag;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type { ApiErrorBody };
|
|
86
|
+
export { API_PREFIX, ApiError, apiFetch, fetchResourceEtag };
|