@getstrata/starter 0.1.0
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 +21 -0
- package/dist/cli.js +77 -0
- package/dist/templates/.env.example +3 -0
- package/dist/templates/docker-compose.yml +14 -0
- package/dist/templates/package.json +23 -0
- package/dist/templates/public/assets/site.css +29 -0
- package/dist/templates/src/bootstrap/config.ts +18 -0
- package/dist/templates/src/bootstrap/database.ts +28 -0
- package/dist/templates/src/bootstrap/preload.ts +17 -0
- package/dist/templates/src/bootstrap/server.ts +35 -0
- package/dist/templates/src/db/fresh.ts +19 -0
- package/dist/templates/src/db/migrate.ts +35 -0
- package/dist/templates/src/lib/router.ts +60 -0
- package/dist/templates/src/lib/view.ts +24 -0
- package/dist/templates/src/routes.ts +23 -0
- package/dist/templates/templates/.env.example +3 -0
- package/dist/templates/templates/docker-compose.yml +14 -0
- package/dist/templates/templates/package.json +23 -0
- package/dist/templates/templates/public/assets/site.css +29 -0
- package/dist/templates/templates/src/bootstrap/config.ts +18 -0
- package/dist/templates/templates/src/bootstrap/database.ts +28 -0
- package/dist/templates/templates/src/bootstrap/preload.ts +17 -0
- package/dist/templates/templates/src/bootstrap/server.ts +35 -0
- package/dist/templates/templates/src/db/fresh.ts +19 -0
- package/dist/templates/templates/src/db/migrate.ts +35 -0
- package/dist/templates/templates/src/lib/router.ts +60 -0
- package/dist/templates/templates/src/lib/view.ts +24 -0
- package/dist/templates/templates/src/routes.ts +23 -0
- package/dist/templates/templates/tsconfig.json +14 -0
- package/dist/templates/templates/views/home.eta +5 -0
- package/dist/templates/templates/views/layouts/app.eta +18 -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
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# create-strata
|
|
2
|
+
|
|
3
|
+
Scaffold a new [Strata](https://github.com/EyK-26/strata) application.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bunx @getstrata/starter my-app
|
|
9
|
+
# or after install: bunx create-strata my-app
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What you get
|
|
13
|
+
|
|
14
|
+
- Bun + TypeScript app using `@getstrata/core` and `@getstrata/bootstrap`
|
|
15
|
+
- Postgres via Docker Compose
|
|
16
|
+
- Eta templates, simple router, health check
|
|
17
|
+
- `db:migrate` and `db:fresh` scripts
|
|
18
|
+
|
|
19
|
+
## Publish
|
|
20
|
+
|
|
21
|
+
Released from the strata monorepo on npm as `@getstrata/starter`.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// cli.ts
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
6
|
+
import { join, resolve } from "path";
|
|
7
|
+
var PLACEHOLDER = /\{\{PROJECT_NAME\}\}/g;
|
|
8
|
+
function usage() {
|
|
9
|
+
console.log(`Usage: create-strata [project-name]
|
|
10
|
+
|
|
11
|
+
Scaffold a new Strata application with Bun, @getstrata/core, and @getstrata/bootstrap.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
bunx @getstrata/starter my-app
|
|
15
|
+
bunx create-strata my-app
|
|
16
|
+
`);
|
|
17
|
+
}
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const positional = argv.filter((arg) => !arg.startsWith("-"));
|
|
20
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
21
|
+
usage();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
const projectName = positional[0] ?? "strata-app";
|
|
25
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/i.test(projectName)) {
|
|
26
|
+
console.error("Project name must contain only letters, numbers, hyphens, and underscores.");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
projectName,
|
|
31
|
+
targetDir: resolve(process.cwd(), projectName)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function copyTemplate(source, target, projectName) {
|
|
35
|
+
mkdirSync(target, { recursive: true });
|
|
36
|
+
for (const entry of readdirSync(source)) {
|
|
37
|
+
const from = join(source, entry);
|
|
38
|
+
const to = join(target, entry.replace(PLACEHOLDER, projectName));
|
|
39
|
+
const info = statSync(from);
|
|
40
|
+
if (info.isDirectory()) {
|
|
41
|
+
copyTemplate(from, to, projectName);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
let contents = readFileSync(from, "utf8");
|
|
45
|
+
if (contents.includes("{{PROJECT_NAME}}")) {
|
|
46
|
+
contents = contents.replace(PLACEHOLDER, projectName);
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(to, contents, { mode: info.mode & 511 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function main() {
|
|
52
|
+
const options = parseArgs(process.argv.slice(2));
|
|
53
|
+
if (!options)
|
|
54
|
+
process.exit(1);
|
|
55
|
+
const templateDir = join(import.meta.dir, "templates");
|
|
56
|
+
if (!existsSync(templateDir)) {
|
|
57
|
+
console.error("Template directory missing. Reinstall create-strata.");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
if (existsSync(options.targetDir)) {
|
|
61
|
+
console.error(`Directory already exists: ${options.targetDir}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
copyTemplate(templateDir, options.targetDir, options.projectName);
|
|
65
|
+
console.log(`
|
|
66
|
+
Created Strata app in ${options.projectName}/
|
|
67
|
+
`);
|
|
68
|
+
console.log("Next steps:");
|
|
69
|
+
console.log(` cd ${options.projectName}`);
|
|
70
|
+
console.log(" cp .env.example .env");
|
|
71
|
+
console.log(" docker compose up -d");
|
|
72
|
+
console.log(" bun install");
|
|
73
|
+
console.log(" bun run db:migrate");
|
|
74
|
+
console.log(` bun run dev
|
|
75
|
+
`);
|
|
76
|
+
}
|
|
77
|
+
main();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun --preload ./src/bootstrap/preload.ts --watch src/bootstrap/server.ts",
|
|
8
|
+
"start": "bun --preload ./src/bootstrap/preload.ts src/bootstrap/server.ts",
|
|
9
|
+
"db:migrate": "bun --preload ./src/bootstrap/preload.ts src/db/migrate.ts",
|
|
10
|
+
"db:fresh": "bun --preload ./src/bootstrap/preload.ts src/db/fresh.ts",
|
|
11
|
+
"check": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@getstrata/bootstrap": "^0.1.0",
|
|
15
|
+
"@getstrata/core": "^0.3.0",
|
|
16
|
+
"eta": "^4.6.0",
|
|
17
|
+
"postgres": "3.4.7"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "latest",
|
|
21
|
+
"typescript": "^5.9.2"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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,18 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
|
|
3
|
+
let sql: ReturnType<typeof postgres> | null = null;
|
|
4
|
+
|
|
5
|
+
export function getSql() {
|
|
6
|
+
if (!sql) {
|
|
7
|
+
const url = process.env.DATABASE_URL;
|
|
8
|
+
if (!url) throw new Error("DATABASE_URL is required");
|
|
9
|
+
sql = postgres(url, { max: 5 });
|
|
10
|
+
}
|
|
11
|
+
return sql;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function pingDatabase(): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
await getSql()`SELECT 1`;
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function closeDatabase() {
|
|
24
|
+
if (sql) {
|
|
25
|
+
await sql.end();
|
|
26
|
+
sql = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const envPath = join(import.meta.dir, "../../.env");
|
|
5
|
+
if (existsSync(envPath)) {
|
|
6
|
+
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
7
|
+
const trimmed = line.trim();
|
|
8
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
9
|
+
const idx = trimmed.indexOf("=");
|
|
10
|
+
if (idx === -1) continue;
|
|
11
|
+
const key = trimmed.slice(0, idx);
|
|
12
|
+
const value = trimmed.slice(idx + 1);
|
|
13
|
+
if (!process.env[key]) process.env[key] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
process.env.DATABASE_URL ??= "postgresql://postgres:postgres@localhost:5432/{{PROJECT_NAME}}";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createWebServer } from "@getstrata/bootstrap";
|
|
2
|
+
import "./preload.ts";
|
|
3
|
+
import { migrate } from "../db/migrate.ts";
|
|
4
|
+
import { Router } from "../lib/router.ts";
|
|
5
|
+
import { registerRoutes } from "../routes.ts";
|
|
6
|
+
import { loadConfig } from "./config.ts";
|
|
7
|
+
import { closeDatabase, pingDatabase } from "./database.ts";
|
|
8
|
+
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
|
|
11
|
+
await migrate();
|
|
12
|
+
|
|
13
|
+
const router = new Router();
|
|
14
|
+
registerRoutes(router);
|
|
15
|
+
|
|
16
|
+
const server = createWebServer({
|
|
17
|
+
port: config.port,
|
|
18
|
+
publicDir: "./public",
|
|
19
|
+
handle: (request) => router.handle(request),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
console.log(`${config.appUrl} (port ${server.port})`);
|
|
23
|
+
|
|
24
|
+
if (!(await pingDatabase())) {
|
|
25
|
+
console.warn("Warning: database ping failed.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function shutdown() {
|
|
29
|
+
await closeDatabase();
|
|
30
|
+
server.stop();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
process.on("SIGINT", shutdown);
|
|
35
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getSql } from "../bootstrap/database.ts";
|
|
2
|
+
import { migrate, seed } from "./migrate.ts";
|
|
3
|
+
|
|
4
|
+
const tables = ["notes"];
|
|
5
|
+
|
|
6
|
+
export async function fresh() {
|
|
7
|
+
const sql = getSql();
|
|
8
|
+
for (const table of tables) {
|
|
9
|
+
await sql.unsafe(`DROP TABLE IF EXISTS ${table} CASCADE`);
|
|
10
|
+
}
|
|
11
|
+
await migrate();
|
|
12
|
+
await seed();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (import.meta.main) {
|
|
16
|
+
await fresh();
|
|
17
|
+
console.log("Database reset, migrated, and seeded.");
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getSql } from "../bootstrap/database.ts";
|
|
2
|
+
|
|
3
|
+
const migrations = [
|
|
4
|
+
`CREATE TABLE IF NOT EXISTS notes (
|
|
5
|
+
id SERIAL PRIMARY KEY,
|
|
6
|
+
body TEXT NOT NULL,
|
|
7
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
8
|
+
)`,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export async function migrate() {
|
|
12
|
+
const sql = getSql();
|
|
13
|
+
for (const statement of migrations) {
|
|
14
|
+
await sql.unsafe(statement);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function seed() {
|
|
19
|
+
const sql = getSql();
|
|
20
|
+
const [{ count }] = await sql<{ count: string }[]>`
|
|
21
|
+
SELECT COUNT(*)::text AS count FROM notes
|
|
22
|
+
`;
|
|
23
|
+
if (Number(count) > 0) return;
|
|
24
|
+
|
|
25
|
+
await sql`
|
|
26
|
+
INSERT INTO notes (body) VALUES ('Welcome to Strata!')
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (import.meta.main) {
|
|
31
|
+
await migrate();
|
|
32
|
+
await seed();
|
|
33
|
+
console.log("Database migrated and seeded.");
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type HttpMethod = "GET" | "POST";
|
|
2
|
+
|
|
3
|
+
export type RouteHandler = (
|
|
4
|
+
request: Request,
|
|
5
|
+
params: Record<string, string>,
|
|
6
|
+
) => Response | Promise<Response>;
|
|
7
|
+
|
|
8
|
+
interface Route {
|
|
9
|
+
method: HttpMethod;
|
|
10
|
+
pattern: RegExp;
|
|
11
|
+
paramNames: string[];
|
|
12
|
+
handler: RouteHandler;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Router {
|
|
16
|
+
private routes: Route[] = [];
|
|
17
|
+
|
|
18
|
+
get(path: string, handler: RouteHandler) {
|
|
19
|
+
this.add("GET", path, handler);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
post(path: string, handler: RouteHandler) {
|
|
23
|
+
this.add("POST", path, handler);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private add(method: HttpMethod, path: string, handler: RouteHandler) {
|
|
27
|
+
const paramNames: string[] = [];
|
|
28
|
+
const patternSource = path
|
|
29
|
+
.replace(/\//g, "\\/")
|
|
30
|
+
.replace(/:([a-zA-Z_]+)/g, (_, name: string) => {
|
|
31
|
+
paramNames.push(name);
|
|
32
|
+
return "([^/]+)";
|
|
33
|
+
});
|
|
34
|
+
this.routes.push({
|
|
35
|
+
method,
|
|
36
|
+
pattern: new RegExp(`^${patternSource}$`),
|
|
37
|
+
paramNames,
|
|
38
|
+
handler,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async handle(request: Request): Promise<Response | null> {
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
45
|
+
|
|
46
|
+
for (const route of this.routes) {
|
|
47
|
+
if (route.method !== request.method) continue;
|
|
48
|
+
const match = pathname.match(route.pattern);
|
|
49
|
+
if (!match) continue;
|
|
50
|
+
|
|
51
|
+
const params: Record<string, string> = {};
|
|
52
|
+
route.paramNames.forEach((name, index) => {
|
|
53
|
+
params[name] = decodeURIComponent(match[index + 1] ?? "");
|
|
54
|
+
});
|
|
55
|
+
return await route.handler(request, params);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Eta } from "eta";
|
|
3
|
+
|
|
4
|
+
const eta = new Eta({ views: join(import.meta.dir, "../../views") });
|
|
5
|
+
|
|
6
|
+
export interface LayoutData {
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function renderPage(
|
|
12
|
+
template: string,
|
|
13
|
+
data: Record<string, unknown> & { layout: LayoutData },
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
const body = await eta.renderAsync(template, data);
|
|
16
|
+
const html = await eta.renderAsync("layouts/app.eta", { ...data, body });
|
|
17
|
+
return new Response(html, {
|
|
18
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function plainText(body: string): Response {
|
|
23
|
+
return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { withErrorHandling } from "@getstrata/core";
|
|
2
|
+
import { pingDatabase } from "./bootstrap/database.ts";
|
|
3
|
+
import type { Router } from "./lib/router.ts";
|
|
4
|
+
import { plainText, renderPage } from "./lib/view.ts";
|
|
5
|
+
|
|
6
|
+
export function registerRoutes(router: Router) {
|
|
7
|
+
router.get("/", async () =>
|
|
8
|
+
renderPage("home.eta", {
|
|
9
|
+
layout: {
|
|
10
|
+
title: "Home",
|
|
11
|
+
description: "A new Strata application",
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
router.get(
|
|
17
|
+
"/health",
|
|
18
|
+
withErrorHandling(async () => {
|
|
19
|
+
const dbOk = await pingDatabase();
|
|
20
|
+
return plainText(dbOk ? "ok" : "degraded");
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun --preload ./src/bootstrap/preload.ts --watch src/bootstrap/server.ts",
|
|
8
|
+
"start": "bun --preload ./src/bootstrap/preload.ts src/bootstrap/server.ts",
|
|
9
|
+
"db:migrate": "bun --preload ./src/bootstrap/preload.ts src/db/migrate.ts",
|
|
10
|
+
"db:fresh": "bun --preload ./src/bootstrap/preload.ts src/db/fresh.ts",
|
|
11
|
+
"check": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@getstrata/bootstrap": "^0.1.0",
|
|
15
|
+
"@getstrata/core": "^0.3.0",
|
|
16
|
+
"eta": "^4.6.0",
|
|
17
|
+
"postgres": "3.4.7"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "latest",
|
|
21
|
+
"typescript": "^5.9.2"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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,18 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
|
|
3
|
+
let sql: ReturnType<typeof postgres> | null = null;
|
|
4
|
+
|
|
5
|
+
export function getSql() {
|
|
6
|
+
if (!sql) {
|
|
7
|
+
const url = process.env.DATABASE_URL;
|
|
8
|
+
if (!url) throw new Error("DATABASE_URL is required");
|
|
9
|
+
sql = postgres(url, { max: 5 });
|
|
10
|
+
}
|
|
11
|
+
return sql;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function pingDatabase(): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
await getSql()`SELECT 1`;
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function closeDatabase() {
|
|
24
|
+
if (sql) {
|
|
25
|
+
await sql.end();
|
|
26
|
+
sql = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const envPath = join(import.meta.dir, "../../.env");
|
|
5
|
+
if (existsSync(envPath)) {
|
|
6
|
+
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
7
|
+
const trimmed = line.trim();
|
|
8
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
9
|
+
const idx = trimmed.indexOf("=");
|
|
10
|
+
if (idx === -1) continue;
|
|
11
|
+
const key = trimmed.slice(0, idx);
|
|
12
|
+
const value = trimmed.slice(idx + 1);
|
|
13
|
+
if (!process.env[key]) process.env[key] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
process.env.DATABASE_URL ??= "postgresql://postgres:postgres@localhost:5432/{{PROJECT_NAME}}";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createWebServer } from "@getstrata/bootstrap";
|
|
2
|
+
import "./preload.ts";
|
|
3
|
+
import { migrate } from "../db/migrate.ts";
|
|
4
|
+
import { Router } from "../lib/router.ts";
|
|
5
|
+
import { registerRoutes } from "../routes.ts";
|
|
6
|
+
import { loadConfig } from "./config.ts";
|
|
7
|
+
import { closeDatabase, pingDatabase } from "./database.ts";
|
|
8
|
+
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
|
|
11
|
+
await migrate();
|
|
12
|
+
|
|
13
|
+
const router = new Router();
|
|
14
|
+
registerRoutes(router);
|
|
15
|
+
|
|
16
|
+
const server = createWebServer({
|
|
17
|
+
port: config.port,
|
|
18
|
+
publicDir: "./public",
|
|
19
|
+
handle: (request) => router.handle(request),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
console.log(`${config.appUrl} (port ${server.port})`);
|
|
23
|
+
|
|
24
|
+
if (!(await pingDatabase())) {
|
|
25
|
+
console.warn("Warning: database ping failed.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function shutdown() {
|
|
29
|
+
await closeDatabase();
|
|
30
|
+
server.stop();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
process.on("SIGINT", shutdown);
|
|
35
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getSql } from "../bootstrap/database.ts";
|
|
2
|
+
import { migrate, seed } from "./migrate.ts";
|
|
3
|
+
|
|
4
|
+
const tables = ["notes"];
|
|
5
|
+
|
|
6
|
+
export async function fresh() {
|
|
7
|
+
const sql = getSql();
|
|
8
|
+
for (const table of tables) {
|
|
9
|
+
await sql.unsafe(`DROP TABLE IF EXISTS ${table} CASCADE`);
|
|
10
|
+
}
|
|
11
|
+
await migrate();
|
|
12
|
+
await seed();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (import.meta.main) {
|
|
16
|
+
await fresh();
|
|
17
|
+
console.log("Database reset, migrated, and seeded.");
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getSql } from "../bootstrap/database.ts";
|
|
2
|
+
|
|
3
|
+
const migrations = [
|
|
4
|
+
`CREATE TABLE IF NOT EXISTS notes (
|
|
5
|
+
id SERIAL PRIMARY KEY,
|
|
6
|
+
body TEXT NOT NULL,
|
|
7
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
8
|
+
)`,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export async function migrate() {
|
|
12
|
+
const sql = getSql();
|
|
13
|
+
for (const statement of migrations) {
|
|
14
|
+
await sql.unsafe(statement);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function seed() {
|
|
19
|
+
const sql = getSql();
|
|
20
|
+
const [{ count }] = await sql<{ count: string }[]>`
|
|
21
|
+
SELECT COUNT(*)::text AS count FROM notes
|
|
22
|
+
`;
|
|
23
|
+
if (Number(count) > 0) return;
|
|
24
|
+
|
|
25
|
+
await sql`
|
|
26
|
+
INSERT INTO notes (body) VALUES ('Welcome to Strata!')
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (import.meta.main) {
|
|
31
|
+
await migrate();
|
|
32
|
+
await seed();
|
|
33
|
+
console.log("Database migrated and seeded.");
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type HttpMethod = "GET" | "POST";
|
|
2
|
+
|
|
3
|
+
export type RouteHandler = (
|
|
4
|
+
request: Request,
|
|
5
|
+
params: Record<string, string>,
|
|
6
|
+
) => Response | Promise<Response>;
|
|
7
|
+
|
|
8
|
+
interface Route {
|
|
9
|
+
method: HttpMethod;
|
|
10
|
+
pattern: RegExp;
|
|
11
|
+
paramNames: string[];
|
|
12
|
+
handler: RouteHandler;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Router {
|
|
16
|
+
private routes: Route[] = [];
|
|
17
|
+
|
|
18
|
+
get(path: string, handler: RouteHandler) {
|
|
19
|
+
this.add("GET", path, handler);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
post(path: string, handler: RouteHandler) {
|
|
23
|
+
this.add("POST", path, handler);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private add(method: HttpMethod, path: string, handler: RouteHandler) {
|
|
27
|
+
const paramNames: string[] = [];
|
|
28
|
+
const patternSource = path
|
|
29
|
+
.replace(/\//g, "\\/")
|
|
30
|
+
.replace(/:([a-zA-Z_]+)/g, (_, name: string) => {
|
|
31
|
+
paramNames.push(name);
|
|
32
|
+
return "([^/]+)";
|
|
33
|
+
});
|
|
34
|
+
this.routes.push({
|
|
35
|
+
method,
|
|
36
|
+
pattern: new RegExp(`^${patternSource}$`),
|
|
37
|
+
paramNames,
|
|
38
|
+
handler,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async handle(request: Request): Promise<Response | null> {
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
45
|
+
|
|
46
|
+
for (const route of this.routes) {
|
|
47
|
+
if (route.method !== request.method) continue;
|
|
48
|
+
const match = pathname.match(route.pattern);
|
|
49
|
+
if (!match) continue;
|
|
50
|
+
|
|
51
|
+
const params: Record<string, string> = {};
|
|
52
|
+
route.paramNames.forEach((name, index) => {
|
|
53
|
+
params[name] = decodeURIComponent(match[index + 1] ?? "");
|
|
54
|
+
});
|
|
55
|
+
return await route.handler(request, params);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Eta } from "eta";
|
|
3
|
+
|
|
4
|
+
const eta = new Eta({ views: join(import.meta.dir, "../../views") });
|
|
5
|
+
|
|
6
|
+
export interface LayoutData {
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function renderPage(
|
|
12
|
+
template: string,
|
|
13
|
+
data: Record<string, unknown> & { layout: LayoutData },
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
const body = await eta.renderAsync(template, data);
|
|
16
|
+
const html = await eta.renderAsync("layouts/app.eta", { ...data, body });
|
|
17
|
+
return new Response(html, {
|
|
18
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function plainText(body: string): Response {
|
|
23
|
+
return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { withErrorHandling } from "@getstrata/core";
|
|
2
|
+
import { pingDatabase } from "./bootstrap/database.ts";
|
|
3
|
+
import type { Router } from "./lib/router.ts";
|
|
4
|
+
import { plainText, renderPage } from "./lib/view.ts";
|
|
5
|
+
|
|
6
|
+
export function registerRoutes(router: Router) {
|
|
7
|
+
router.get("/", async () =>
|
|
8
|
+
renderPage("home.eta", {
|
|
9
|
+
layout: {
|
|
10
|
+
title: "Home",
|
|
11
|
+
description: "A new Strata application",
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
router.get(
|
|
17
|
+
"/health",
|
|
18
|
+
withErrorHandling(async () => {
|
|
19
|
+
const dbOk = await pingDatabase();
|
|
20
|
+
return plainText(dbOk ? "ok" : "degraded");
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"types": ["bun"],
|
|
11
|
+
"lib": ["ES2022", "DOM"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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" />
|
|
6
|
+
<title><%= it.layout.title %> · Strata</title>
|
|
7
|
+
<% if (it.layout.description) { %>
|
|
8
|
+
<meta name="description" content="<%= it.layout.description %>" />
|
|
9
|
+
<% } %>
|
|
10
|
+
<link rel="stylesheet" href="/assets/site.css" />
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header class="site-header">
|
|
14
|
+
<a class="brand" href="/">Strata</a>
|
|
15
|
+
</header>
|
|
16
|
+
<main><%~ it.body %></main>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"types": ["bun"],
|
|
11
|
+
"lib": ["ES2022", "DOM"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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" />
|
|
6
|
+
<title><%= it.layout.title %> · Strata</title>
|
|
7
|
+
<% if (it.layout.description) { %>
|
|
8
|
+
<meta name="description" content="<%= it.layout.description %>" />
|
|
9
|
+
<% } %>
|
|
10
|
+
<link rel="stylesheet" href="/assets/site.css" />
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header class="site-header">
|
|
14
|
+
<a class="brand" href="/">Strata</a>
|
|
15
|
+
</header>
|
|
16
|
+
<main><%~ it.body %></main>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getstrata/starter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a new Strata app — bun create strata",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/EyK-26/strata.git",
|
|
10
|
+
"directory": "packages/strata-starter"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"create-strata": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "bun build cli.ts --outdir dist --target bun && cp -r templates dist/templates",
|
|
21
|
+
"prepublishOnly": "bun run build"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"bun": ">=1.1.0"
|
|
28
|
+
}
|
|
29
|
+
}
|