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,98 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color: #0f172a;
|
|
3
|
+
background: #f8fafc;
|
|
4
|
+
font-family: system-ui, sans-serif;
|
|
5
|
+
line-height: 1.5;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
a {
|
|
17
|
+
color: #2563eb;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.app-shell {
|
|
21
|
+
max-width: 960px;
|
|
22
|
+
margin: 0 auto;
|
|
23
|
+
padding: 1.5rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.site-header {
|
|
27
|
+
display: flex;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 1rem;
|
|
31
|
+
margin-bottom: 1.5rem;
|
|
32
|
+
padding-bottom: 1rem;
|
|
33
|
+
border-bottom: 1px solid #e2e8f0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.site-header nav {
|
|
37
|
+
display: flex;
|
|
38
|
+
gap: 1rem;
|
|
39
|
+
flex-wrap: wrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.card {
|
|
43
|
+
background: #fff;
|
|
44
|
+
border: 1px solid #e2e8f0;
|
|
45
|
+
border-radius: 0.75rem;
|
|
46
|
+
padding: 1rem;
|
|
47
|
+
margin-bottom: 1rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.stack-form {
|
|
51
|
+
display: grid;
|
|
52
|
+
gap: 0.75rem;
|
|
53
|
+
max-width: 24rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.stack-form label {
|
|
57
|
+
display: grid;
|
|
58
|
+
gap: 0.25rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.stack-form input {
|
|
62
|
+
padding: 0.5rem 0.75rem;
|
|
63
|
+
border: 1px solid #cbd5e1;
|
|
64
|
+
border-radius: 0.5rem;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
button {
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
border: none;
|
|
70
|
+
border-radius: 0.5rem;
|
|
71
|
+
padding: 0.5rem 0.875rem;
|
|
72
|
+
background: #2563eb;
|
|
73
|
+
color: #fff;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
button.secondary {
|
|
77
|
+
background: #64748b;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.error {
|
|
81
|
+
color: #b91c1c;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
table {
|
|
85
|
+
width: 100%;
|
|
86
|
+
border-collapse: collapse;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
th,
|
|
90
|
+
td {
|
|
91
|
+
text-align: left;
|
|
92
|
+
padding: 0.5rem 0.75rem;
|
|
93
|
+
border-bottom: 1px solid #e2e8f0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.hint {
|
|
97
|
+
color: #64748b;
|
|
98
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { apiFetch } from "../api/client";
|
|
11
|
+
import { clearToken, readToken, writeToken } from "./tokenStorage";
|
|
12
|
+
|
|
13
|
+
interface AuthUser {
|
|
14
|
+
id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
email: string;
|
|
17
|
+
role: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AuthContextValue {
|
|
21
|
+
token: string | null;
|
|
22
|
+
user: AuthUser | null;
|
|
23
|
+
loading: boolean;
|
|
24
|
+
login: (email: string, password: string) => Promise<void>;
|
|
25
|
+
logout: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
29
|
+
|
|
30
|
+
function AuthProvider({ children }: { children: ReactNode }) {
|
|
31
|
+
const [token, setToken] = useState<string | null>(() => readToken());
|
|
32
|
+
const [user, setUser] = useState<AuthUser | null>(null);
|
|
33
|
+
const [loading, setLoading] = useState(Boolean(readToken()));
|
|
34
|
+
|
|
35
|
+
const loadProfile = useCallback(async (activeToken: string) => {
|
|
36
|
+
const profile = await apiFetch<AuthUser>("/auth/me", { token: activeToken });
|
|
37
|
+
setUser(profile);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!token) {
|
|
42
|
+
setUser(null);
|
|
43
|
+
setLoading(false);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
loadProfile(token)
|
|
48
|
+
.catch(() => {
|
|
49
|
+
clearToken();
|
|
50
|
+
setToken(null);
|
|
51
|
+
setUser(null);
|
|
52
|
+
})
|
|
53
|
+
.finally(() => {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
});
|
|
56
|
+
}, [loadProfile, token]);
|
|
57
|
+
|
|
58
|
+
const login = useCallback(
|
|
59
|
+
async (email: string, password: string) => {
|
|
60
|
+
const body = await apiFetch<{ token: string }>("/auth/login", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: JSON.stringify({ email, password }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
writeToken(body.token);
|
|
66
|
+
setToken(body.token);
|
|
67
|
+
await loadProfile(body.token);
|
|
68
|
+
},
|
|
69
|
+
[loadProfile],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const logout = useCallback(() => {
|
|
73
|
+
clearToken();
|
|
74
|
+
setToken(null);
|
|
75
|
+
setUser(null);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const value = useMemo(
|
|
79
|
+
() => ({
|
|
80
|
+
token,
|
|
81
|
+
user,
|
|
82
|
+
loading,
|
|
83
|
+
login,
|
|
84
|
+
logout,
|
|
85
|
+
}),
|
|
86
|
+
[loading, login, logout, token, user],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function useAuth(): AuthContextValue {
|
|
93
|
+
const context = useContext(AuthContext);
|
|
94
|
+
|
|
95
|
+
if (!context) {
|
|
96
|
+
throw new Error("useAuth must be used within AuthProvider");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return context;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type { AuthUser };
|
|
103
|
+
export { AuthProvider, useAuth };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const TOKEN_KEY = "strata_api_token";
|
|
2
|
+
|
|
3
|
+
function readToken(): string | null {
|
|
4
|
+
return localStorage.getItem(TOKEN_KEY);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function writeToken(token: string): void {
|
|
8
|
+
localStorage.setItem(TOKEN_KEY, token);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function clearToken(): void {
|
|
12
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { clearToken, readToken, TOKEN_KEY, writeToken };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { BrowserRouter } from "react-router-dom";
|
|
4
|
+
import App from "./App";
|
|
5
|
+
import { AuthProvider } from "./auth/AuthContext";
|
|
6
|
+
import "./app.css";
|
|
7
|
+
|
|
8
|
+
const rootElement = document.getElementById("root");
|
|
9
|
+
|
|
10
|
+
if (!rootElement) {
|
|
11
|
+
throw new Error("Root element #root not found");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
createRoot(rootElement).render(
|
|
15
|
+
<StrictMode>
|
|
16
|
+
<BrowserRouter basename="/app">
|
|
17
|
+
<AuthProvider>
|
|
18
|
+
<App />
|
|
19
|
+
</AuthProvider>
|
|
20
|
+
</BrowserRouter>
|
|
21
|
+
</StrictMode>,
|
|
22
|
+
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useAuth } from "../auth/AuthContext";
|
|
2
|
+
|
|
3
|
+
export default function HomePage() {
|
|
4
|
+
const { user } = useAuth();
|
|
5
|
+
|
|
6
|
+
return (
|
|
7
|
+
<section>
|
|
8
|
+
<h1>Signed in</h1>
|
|
9
|
+
<p className="hint">
|
|
10
|
+
This page reads <code>GET /api/v1/auth/me</code> with the bearer token from the login
|
|
11
|
+
response. It is the only resource the generated backend serves, so it is the only one this
|
|
12
|
+
page shows.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<div className="card">
|
|
16
|
+
<table>
|
|
17
|
+
<tbody>
|
|
18
|
+
<tr>
|
|
19
|
+
<th>Name</th>
|
|
20
|
+
<td>{user?.name ?? "—"}</td>
|
|
21
|
+
</tr>
|
|
22
|
+
<tr>
|
|
23
|
+
<th>Email</th>
|
|
24
|
+
<td>{user?.email ?? "—"}</td>
|
|
25
|
+
</tr>
|
|
26
|
+
<tr>
|
|
27
|
+
<th>Role</th>
|
|
28
|
+
<td>
|
|
29
|
+
<code>{user?.role ?? "—"}</code>
|
|
30
|
+
</td>
|
|
31
|
+
</tr>
|
|
32
|
+
</tbody>
|
|
33
|
+
</table>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<h2>Add your first resource</h2>
|
|
37
|
+
<p>
|
|
38
|
+
Create a module under <code>src/modules/</code> and return routes from it. The kernel picks
|
|
39
|
+
it up on the next boot.
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<pre className="card">
|
|
43
|
+
<code>{`// src/modules/notes/index.ts
|
|
44
|
+
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
45
|
+
import { jsonResponse } from "@getstrata/core/http/response";
|
|
46
|
+
import { getSql } from "../../bootstrap/database.ts";
|
|
47
|
+
|
|
48
|
+
const notesModule: AppModule = {
|
|
49
|
+
name: "notes",
|
|
50
|
+
order: 2,
|
|
51
|
+
routes({ kernel }) {
|
|
52
|
+
return {
|
|
53
|
+
"/api/v1/notes": kernel.wrap("api", async () => {
|
|
54
|
+
const rows = await getSql().unsafe<{ id: number; body: string }>(
|
|
55
|
+
"SELECT id, body FROM notes ORDER BY id DESC",
|
|
56
|
+
);
|
|
57
|
+
return jsonResponse({ data: rows });
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default notesModule;`}</code>
|
|
64
|
+
</pre>
|
|
65
|
+
|
|
66
|
+
<p className="hint">
|
|
67
|
+
Then fetch it here with <code>apiFetch("/notes", {"{ token }"})</code>. The{" "}
|
|
68
|
+
<code>notes</code> table already exists after <code>bun run db:migrate</code>.
|
|
69
|
+
</p>
|
|
70
|
+
</section>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type FormEvent, useState } from "react";
|
|
2
|
+
import { Navigate, useNavigate } from "react-router-dom";
|
|
3
|
+
import { ApiError } from "../api/client";
|
|
4
|
+
import { useAuth } from "../auth/AuthContext";
|
|
5
|
+
|
|
6
|
+
export default function LoginPage() {
|
|
7
|
+
const { login, token } = useAuth();
|
|
8
|
+
const navigate = useNavigate();
|
|
9
|
+
const [email, setEmail] = useState("admin@example.test");
|
|
10
|
+
const [password, setPassword] = useState("password");
|
|
11
|
+
const [error, setError] = useState<string | null>(null);
|
|
12
|
+
const [submitting, setSubmitting] = useState(false);
|
|
13
|
+
|
|
14
|
+
if (token) {
|
|
15
|
+
return <Navigate to="/" replace />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
setSubmitting(true);
|
|
21
|
+
setError(null);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await login(email, password);
|
|
25
|
+
navigate("/");
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
setError(cause instanceof ApiError ? cause.message : "Login failed");
|
|
28
|
+
} finally {
|
|
29
|
+
setSubmitting(false);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<section className="card">
|
|
35
|
+
<h1>Sign in</h1>
|
|
36
|
+
<p className="hint">
|
|
37
|
+
Seeded users are <code>demo@example.com</code> and <code>admin@example.test</code>, password
|
|
38
|
+
<code>password</code>.
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
{error ? <p className="error">{error}</p> : null}
|
|
42
|
+
|
|
43
|
+
<form className="stack-form" onSubmit={onSubmit}>
|
|
44
|
+
<label>
|
|
45
|
+
Email
|
|
46
|
+
<input
|
|
47
|
+
type="email"
|
|
48
|
+
value={email}
|
|
49
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
50
|
+
required
|
|
51
|
+
/>
|
|
52
|
+
</label>
|
|
53
|
+
|
|
54
|
+
<label>
|
|
55
|
+
Password
|
|
56
|
+
<input
|
|
57
|
+
type="password"
|
|
58
|
+
value={password}
|
|
59
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
60
|
+
required
|
|
61
|
+
/>
|
|
62
|
+
</label>
|
|
63
|
+
|
|
64
|
+
<button type="submit" disabled={submitting}>
|
|
65
|
+
{submitting ? "Signing in…" : "Sign in"}
|
|
66
|
+
</button>
|
|
67
|
+
</form>
|
|
68
|
+
</section>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"moduleDetection": "force",
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"strict": true,
|
|
14
|
+
"types": ["bun"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src", "build.ts", "dev-server.ts", "bun-env.d.ts"]
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "strata dev",
|
|
8
|
+
"start": "strata start",
|
|
9
|
+
"db:migrate": "strata migrate",
|
|
10
|
+
"db:fresh": "strata migrate:fresh",
|
|
11
|
+
"check": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@getstrata/bootstrap": "^1.0.1",
|
|
15
|
+
"@getstrata/cli": "^1.0.1",
|
|
16
|
+
"@getstrata/core": "^1.0.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "^1.4.0",
|
|
20
|
+
"typescript": "^5.9.2"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
font-family: system-ui, sans-serif;
|
|
4
|
+
line-height: 1.5;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 0 1.5rem 2rem;
|
|
10
|
+
max-width: 48rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.site-header {
|
|
14
|
+
padding: 1rem 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.brand {
|
|
18
|
+
font-weight: 700;
|
|
19
|
+
text-decoration: none;
|
|
20
|
+
color: inherit;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.section h1 {
|
|
24
|
+
margin-top: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
code {
|
|
28
|
+
font-size: 0.9em;
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface AppConfig {
|
|
2
|
+
port: number;
|
|
3
|
+
appUrl: string;
|
|
4
|
+
databaseUrl: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function loadConfig(): AppConfig {
|
|
8
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
9
|
+
if (!databaseUrl) {
|
|
10
|
+
throw new Error("DATABASE_URL is required");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
port: Number(process.env.PORT ?? 3000),
|
|
15
|
+
appUrl: process.env.APP_URL ?? "http://localhost:3000",
|
|
16
|
+
databaseUrl,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cookie sessions and signed cookies are keyed by this; there is no default. */
|
|
21
|
+
export function sessionSecret(): string {
|
|
22
|
+
const secret = process.env.SESSION_SECRET?.trim();
|
|
23
|
+
if (!secret) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"SESSION_SECRET is required. Copy .env.example to .env and set it (32+ characters).",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return secret;
|
|
29
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { runProviderPhase } from "@getstrata/bootstrap/context";
|
|
2
|
+
import {
|
|
3
|
+
type AppContext,
|
|
4
|
+
type AppDependencies,
|
|
5
|
+
type AppRouteMap,
|
|
6
|
+
assertAppDependenciesComplete,
|
|
7
|
+
type ConfigStore,
|
|
8
|
+
type MutableAppDependencies,
|
|
9
|
+
type ProviderContext,
|
|
10
|
+
ServiceContainer,
|
|
11
|
+
} from "@getstrata/bootstrap/contracts";
|
|
12
|
+
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
13
|
+
import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
|
|
14
|
+
import { migrate } from "../db/migrate.ts";
|
|
15
|
+
import { buildRoutes } from "../routes.ts";
|
|
16
|
+
import { loadConfig } from "./config.ts";
|
|
17
|
+
import { starterProviders } from "./providers/index.ts";
|
|
18
|
+
|
|
19
|
+
export interface BootstrapOptions {
|
|
20
|
+
migrate?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BootstrappedApp {
|
|
24
|
+
context: AppContext;
|
|
25
|
+
routes: AppRouteMap;
|
|
26
|
+
config: ReturnType<typeof loadConfig>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class AppConfigStore {
|
|
30
|
+
private readonly values = new Map<string, unknown>();
|
|
31
|
+
|
|
32
|
+
set<T>(key: string, value: T): T {
|
|
33
|
+
this.values.set(key, value);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get<T>(key: string): T | undefined {
|
|
38
|
+
return this.values.get(key) as T | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
require<T>(key: string): T {
|
|
42
|
+
const value = this.get<T>(key);
|
|
43
|
+
if (value === undefined) {
|
|
44
|
+
throw new Error(`Missing required config value "${key}".`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
has(key: string): boolean {
|
|
50
|
+
return this.values.has(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createAppContext(): AppContext {
|
|
55
|
+
const container = new ServiceContainer();
|
|
56
|
+
const config = new AppConfigStore() as unknown as ConfigStore;
|
|
57
|
+
const dependencies: MutableAppDependencies = { container };
|
|
58
|
+
const context: ProviderContext = { container, config, dependencies };
|
|
59
|
+
|
|
60
|
+
runProviderPhase(starterProviders, "register", context);
|
|
61
|
+
runProviderPhase(starterProviders, "boot", context);
|
|
62
|
+
|
|
63
|
+
assertAppDependenciesComplete(dependencies);
|
|
64
|
+
|
|
65
|
+
const appContext = { container, config, dependencies: dependencies as AppDependencies };
|
|
66
|
+
setActiveApplicationContext(appContext as never);
|
|
67
|
+
return appContext;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
|
|
71
|
+
const isProduction = process.env.APP_ENV === "production";
|
|
72
|
+
const { migrate: runMigrate = !isProduction } = options;
|
|
73
|
+
|
|
74
|
+
const appConfig = loadConfig();
|
|
75
|
+
const context = createAppContext();
|
|
76
|
+
|
|
77
|
+
if (runMigrate) {
|
|
78
|
+
await migrate();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const routes = buildRoutes(context.dependencies);
|
|
82
|
+
|
|
83
|
+
return { context, routes, config: appConfig };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createAppServer(routes: AppRouteMap, port = 0) {
|
|
87
|
+
return createWebServer({
|
|
88
|
+
port,
|
|
89
|
+
publicDir: "./public",
|
|
90
|
+
routes,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { createAppContext };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
|
|
3
|
+
export type SqlClient = InstanceType<typeof SQL>;
|
|
4
|
+
|
|
5
|
+
let sql: SqlClient | null = null;
|
|
6
|
+
|
|
7
|
+
export function getSql(): SqlClient {
|
|
8
|
+
if (!sql) {
|
|
9
|
+
const url = process.env.DATABASE_URL;
|
|
10
|
+
if (!url) throw new Error("DATABASE_URL is required");
|
|
11
|
+
sql = new SQL({ url, max: 5 });
|
|
12
|
+
}
|
|
13
|
+
return sql;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function pingDatabase(): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
await getSql()`SELECT 1`;
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function closeDatabase() {
|
|
26
|
+
if (sql) {
|
|
27
|
+
await sql.close();
|
|
28
|
+
sql = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
|
|
3
|
+
|
|
4
|
+
process.env.DATABASE_URL ??= "postgresql://postgres:postgres@localhost:5432/{{PROJECT_NAME}}";
|
|
5
|
+
configureModulesDirectory(join(import.meta.dir, "../modules"));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CORE_AUTH_TOKEN } from "@getstrata/bootstrap/config";
|
|
2
|
+
import type { AuthUser } from "@getstrata/core/auth/authContext";
|
|
3
|
+
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
4
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
5
|
+
|
|
6
|
+
class StarterAuthManager {
|
|
7
|
+
async resolve(request?: Request): Promise<AuthUser | null> {
|
|
8
|
+
if (request) {
|
|
9
|
+
const userId = request.headers.get("x-authenticated-user-id");
|
|
10
|
+
if (!userId) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const role = request.headers.get("x-authenticated-user-role");
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
id: userId,
|
|
18
|
+
...(role ? { role } : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return currentAuthUser();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
user(request?: Request) {
|
|
26
|
+
return this.resolve(request);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async check(request: Request) {
|
|
30
|
+
return (await this.user(request)) !== null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const authProvider: ServiceProvider = {
|
|
35
|
+
name: "starter.auth",
|
|
36
|
+
register({ container }) {
|
|
37
|
+
container.set(CORE_AUTH_TOKEN, new StarterAuthManager());
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default authProvider;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CORE_CACHE_TOKEN } from "@getstrata/bootstrap/config";
|
|
2
|
+
import { createCacheStore } from "@getstrata/core/cache/createCacheStore";
|
|
3
|
+
import { CacheRepository } from "@getstrata/core/cache/repository";
|
|
4
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
5
|
+
|
|
6
|
+
const cacheProvider: ServiceProvider = {
|
|
7
|
+
name: "starter.cache",
|
|
8
|
+
register({ container, config, dependencies }) {
|
|
9
|
+
container.singleton(CORE_CACHE_TOKEN, () => {
|
|
10
|
+
const ttlMs = config.get<number>("cache.ttlMs") ?? 3_600_000;
|
|
11
|
+
const maxEntries = config.get<number>("cache.maxEntries") ?? 100;
|
|
12
|
+
const driver = config.get<"array" | "redis">("cache.driver") ?? "array";
|
|
13
|
+
|
|
14
|
+
return new CacheRepository(
|
|
15
|
+
createCacheStore({
|
|
16
|
+
driver,
|
|
17
|
+
ttlMs,
|
|
18
|
+
maxEntries,
|
|
19
|
+
redisUrl: process.env.REDIS_URL,
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
Reflect.set(dependencies, "cache", container.resolve(CORE_CACHE_TOKEN));
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default cacheProvider;
|