@getstrata/starter 0.1.0 → 0.1.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 +16 -1
- package/dist/templates/.env.example +15 -0
- package/dist/templates/package.json +3 -5
- package/dist/templates/src/bootstrap/createApp.ts +93 -0
- package/dist/templates/src/bootstrap/database.ts +7 -5
- package/dist/templates/src/bootstrap/preload.ts +2 -14
- 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 +5 -15
- package/dist/templates/src/lib/view.ts +4 -7
- package/dist/templates/src/modules/site/index.ts +29 -0
- package/dist/templates/src/routes.ts +4 -21
- package/dist/templates/templates/.env.example +15 -0
- package/dist/templates/templates/package.json +3 -5
- package/dist/templates/templates/src/bootstrap/createApp.ts +93 -0
- package/dist/templates/templates/src/bootstrap/database.ts +7 -5
- package/dist/templates/templates/src/bootstrap/preload.ts +2 -14
- package/dist/templates/templates/src/bootstrap/providers/auth.ts +41 -0
- package/dist/templates/templates/src/bootstrap/providers/cache.ts +28 -0
- package/dist/templates/templates/src/bootstrap/providers/config.ts +27 -0
- package/dist/templates/templates/src/bootstrap/providers/index.ts +14 -0
- package/dist/templates/templates/src/bootstrap/providers/storage.ts +11 -0
- package/dist/templates/templates/src/bootstrap/server.ts +5 -15
- package/dist/templates/templates/src/lib/view.ts +4 -7
- package/dist/templates/templates/src/modules/site/index.ts +29 -0
- package/dist/templates/templates/src/routes.ts +4 -21
- package/package.json +1 -1
- package/dist/templates/src/lib/router.ts +0 -60
- package/dist/templates/templates/src/lib/router.ts +0 -60
package/README.md
CHANGED
|
@@ -12,10 +12,25 @@ bunx @getstrata/starter my-app
|
|
|
12
12
|
## What you get
|
|
13
13
|
|
|
14
14
|
- Bun + TypeScript app using `@getstrata/core` and `@getstrata/bootstrap`
|
|
15
|
-
- Postgres via Docker Compose
|
|
15
|
+
- Postgres via Docker Compose (no WorkHub `tenant` table; set `TENANCY_DRIVER=none`)
|
|
16
16
|
- Eta templates, simple router, health check
|
|
17
17
|
- `db:migrate` and `db:fresh` scripts
|
|
18
18
|
|
|
19
|
+
## Environment
|
|
20
|
+
|
|
21
|
+
Copy `.env.example` to `.env`. The scaffold is an API app without a `tenant` table:
|
|
22
|
+
|
|
23
|
+
| Variable | Local default | Notes |
|
|
24
|
+
|----------|---------------|--------|
|
|
25
|
+
| `TENANCY_DRIVER` | `none` | Skip Postgres RLS / `tenant` lookups |
|
|
26
|
+
| `FRONTEND_MODE` | `api` | Use `server-htmx` only if you add cookie sessions |
|
|
27
|
+
| `SESSION_SECRET` | unset | Required in production when `FRONTEND_MODE=server-htmx` |
|
|
28
|
+
| `TRUST_FORWARDED_FOR` | unset | Set `true` only behind a trusted reverse proxy |
|
|
29
|
+
| `METRICS_TOKEN` | unset | Required in production to expose `GET /metrics` |
|
|
30
|
+
| `FEATURE_PUBLIC_READS` | unset | Set `false` in production |
|
|
31
|
+
|
|
32
|
+
`assertProductionSecrets()` is WorkHub-oriented (API tokens, Stripe, SCIM). Starter apps do not call it; add it if you enable those features.
|
|
33
|
+
|
|
19
34
|
## Publish
|
|
20
35
|
|
|
21
36
|
Released from the strata monorepo on npm as `@getstrata/starter`.
|
|
@@ -1,3 +1,18 @@
|
|
|
1
1
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/{{PROJECT_NAME}}
|
|
2
2
|
PORT=3000
|
|
3
3
|
APP_URL=http://localhost:3000
|
|
4
|
+
APP_ENV=local
|
|
5
|
+
FRONTEND_MODE=api
|
|
6
|
+
TENANCY_DRIVER=none
|
|
7
|
+
|
|
8
|
+
# Required in production when FRONTEND_MODE=server-htmx
|
|
9
|
+
# SESSION_SECRET=
|
|
10
|
+
|
|
11
|
+
# Set only behind a trusted reverse proxy
|
|
12
|
+
# TRUST_FORWARDED_FOR=true
|
|
13
|
+
|
|
14
|
+
# Required in production to expose GET /metrics
|
|
15
|
+
# METRICS_TOKEN=
|
|
16
|
+
|
|
17
|
+
# Production should set FEATURE_PUBLIC_READS=false
|
|
18
|
+
# FEATURE_PUBLIC_READS=false
|
|
@@ -11,13 +11,11 @@
|
|
|
11
11
|
"check": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@getstrata/bootstrap": "^0.
|
|
15
|
-
"@getstrata/core": "^0.
|
|
16
|
-
"eta": "^4.6.0",
|
|
17
|
-
"postgres": "3.4.7"
|
|
14
|
+
"@getstrata/bootstrap": "^0.2.48",
|
|
15
|
+
"@getstrata/core": "^0.5.57"
|
|
18
16
|
},
|
|
19
17
|
"devDependencies": {
|
|
20
|
-
"@types/bun": "
|
|
18
|
+
"@types/bun": "^1.4.0",
|
|
21
19
|
"typescript": "^5.9.2"
|
|
22
20
|
}
|
|
23
21
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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 { migrate: runMigrate = true } = options;
|
|
72
|
+
|
|
73
|
+
const appConfig = loadConfig();
|
|
74
|
+
const context = createAppContext();
|
|
75
|
+
|
|
76
|
+
if (runMigrate) {
|
|
77
|
+
await migrate();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const routes = buildRoutes(context.dependencies);
|
|
81
|
+
|
|
82
|
+
return { context, routes, config: appConfig };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createAppServer(routes: AppRouteMap, port = 0) {
|
|
86
|
+
return createWebServer({
|
|
87
|
+
port,
|
|
88
|
+
publicDir: "./public",
|
|
89
|
+
routes,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { createAppContext };
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { SQL } from "bun";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
export type SqlClient = InstanceType<typeof SQL>;
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
let sql: SqlClient | null = null;
|
|
6
|
+
|
|
7
|
+
export function getSql(): SqlClient {
|
|
6
8
|
if (!sql) {
|
|
7
9
|
const url = process.env.DATABASE_URL;
|
|
8
10
|
if (!url) throw new Error("DATABASE_URL is required");
|
|
9
|
-
sql =
|
|
11
|
+
sql = new SQL({ url, max: 5 });
|
|
10
12
|
}
|
|
11
13
|
return sql;
|
|
12
14
|
}
|
|
@@ -22,7 +24,7 @@ export async function pingDatabase(): Promise<boolean> {
|
|
|
22
24
|
|
|
23
25
|
export async function closeDatabase() {
|
|
24
26
|
if (sql) {
|
|
25
|
-
await sql.
|
|
27
|
+
await sql.close();
|
|
26
28
|
sql = null;
|
|
27
29
|
}
|
|
28
30
|
}
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
1
|
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
|
-
}
|
|
2
|
+
import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
|
|
16
3
|
|
|
17
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;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APP_PORT_CONFIG_KEY,
|
|
3
|
+
CORE_CONFIG_TOKEN,
|
|
4
|
+
DATABASE_URL_CONFIG_KEY,
|
|
5
|
+
REDIS_URL_CONFIG_KEY,
|
|
6
|
+
} from "@getstrata/bootstrap/config";
|
|
7
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
8
|
+
import { loadConfig } from "../config.ts";
|
|
9
|
+
|
|
10
|
+
const configProvider: ServiceProvider = {
|
|
11
|
+
name: "starter.config",
|
|
12
|
+
register({ container, config }) {
|
|
13
|
+
const appConfig = loadConfig();
|
|
14
|
+
|
|
15
|
+
container.set(CORE_CONFIG_TOKEN, config);
|
|
16
|
+
config.set(DATABASE_URL_CONFIG_KEY, appConfig.databaseUrl);
|
|
17
|
+
config.set(APP_PORT_CONFIG_KEY, appConfig.port);
|
|
18
|
+
config.set(REDIS_URL_CONFIG_KEY, process.env.REDIS_URL ?? "");
|
|
19
|
+
config.set("app.url", appConfig.appUrl);
|
|
20
|
+
config.set("cache.driver", "array");
|
|
21
|
+
config.set("cache.ttlMs", 3_600_000);
|
|
22
|
+
config.set("cache.maxEntries", 100);
|
|
23
|
+
config.set("queue.driver", process.env.QUEUE_DRIVER ?? "sync");
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default configProvider;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
2
|
+
import authProvider from "./auth.ts";
|
|
3
|
+
import cacheProvider from "./cache.ts";
|
|
4
|
+
import configProvider from "./config.ts";
|
|
5
|
+
import storageProvider from "./storage.ts";
|
|
6
|
+
|
|
7
|
+
const starterProviders: ServiceProvider[] = [
|
|
8
|
+
configProvider,
|
|
9
|
+
cacheProvider,
|
|
10
|
+
storageProvider,
|
|
11
|
+
authProvider,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export { starterProviders };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
2
|
+
import { createStorageDriver, StorageManager } from "@getstrata/core/storage/storage";
|
|
3
|
+
|
|
4
|
+
const storageProvider: ServiceProvider = {
|
|
5
|
+
name: "starter.storage",
|
|
6
|
+
register({ dependencies }) {
|
|
7
|
+
Reflect.set(dependencies, "storage", new StorageManager(createStorageDriver()));
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default storageProvider;
|
|
@@ -1,23 +1,13 @@
|
|
|
1
|
-
import { createWebServer } from "@getstrata/bootstrap";
|
|
2
1
|
import "./preload.ts";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { registerRoutes } from "../routes.ts";
|
|
6
|
-
import { loadConfig } from "./config.ts";
|
|
2
|
+
import { ensureModulesLoaded } from "@getstrata/bootstrap/discoverModules";
|
|
3
|
+
import { bootstrapApp, createAppServer } from "./createApp.ts";
|
|
7
4
|
import { closeDatabase, pingDatabase } from "./database.ts";
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
await ensureModulesLoaded();
|
|
10
7
|
|
|
11
|
-
await
|
|
8
|
+
const { routes, config } = await bootstrapApp();
|
|
12
9
|
|
|
13
|
-
const
|
|
14
|
-
registerRoutes(router);
|
|
15
|
-
|
|
16
|
-
const server = createWebServer({
|
|
17
|
-
port: config.port,
|
|
18
|
-
publicDir: "./public",
|
|
19
|
-
handle: (request) => router.handle(request),
|
|
20
|
-
});
|
|
10
|
+
const server = createAppServer(routes, config.port);
|
|
21
11
|
|
|
22
12
|
console.log(`${config.appUrl} (port ${server.port})`);
|
|
23
13
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const engine = new EtaViewEngine(join(import.meta.dir, "../../views"));
|
|
5
5
|
|
|
6
6
|
export interface LayoutData {
|
|
7
7
|
title: string;
|
|
@@ -12,11 +12,8 @@ export async function renderPage(
|
|
|
12
12
|
template: string,
|
|
13
13
|
data: Record<string, unknown> & { layout: LayoutData },
|
|
14
14
|
): Promise<Response> {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
return new Response(html, {
|
|
18
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
19
|
-
});
|
|
15
|
+
const html = await engine.render(template, data);
|
|
16
|
+
return htmlResponse(html);
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
export function plainText(body: string): Response {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
2
|
+
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
3
|
+
import { pingDatabase } from "../../bootstrap/database.ts";
|
|
4
|
+
import { plainText, renderPage } from "../../lib/view.ts";
|
|
5
|
+
|
|
6
|
+
const siteModule: AppModule = {
|
|
7
|
+
name: "site",
|
|
8
|
+
order: 1,
|
|
9
|
+
webRoutes({ kernel }) {
|
|
10
|
+
return {
|
|
11
|
+
"/": kernel.wrapWeb(async () =>
|
|
12
|
+
renderPage("home.eta", {
|
|
13
|
+
layout: {
|
|
14
|
+
title: "Home",
|
|
15
|
+
description: "A new Strata application",
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
"/health": kernel.wrapWeb(
|
|
20
|
+
withErrorHandling(async () => {
|
|
21
|
+
const dbOk = await pingDatabase();
|
|
22
|
+
return plainText(dbOk ? "ok" : "degraded");
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default siteModule;
|
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { Router } from "./lib/router.ts";
|
|
4
|
-
import { plainText, renderPage } from "./lib/view.ts";
|
|
1
|
+
import { buildWebModuleRoutes } from "@getstrata/bootstrap/buildWebModuleRoutes";
|
|
2
|
+
import type { AppDependencies, AppRouteMap } from "@getstrata/bootstrap/contracts";
|
|
5
3
|
|
|
6
|
-
export function
|
|
7
|
-
|
|
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
|
-
);
|
|
4
|
+
export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
|
|
5
|
+
return buildWebModuleRoutes(dependencies);
|
|
23
6
|
}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
1
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/{{PROJECT_NAME}}
|
|
2
2
|
PORT=3000
|
|
3
3
|
APP_URL=http://localhost:3000
|
|
4
|
+
APP_ENV=local
|
|
5
|
+
FRONTEND_MODE=api
|
|
6
|
+
TENANCY_DRIVER=none
|
|
7
|
+
|
|
8
|
+
# Required in production when FRONTEND_MODE=server-htmx
|
|
9
|
+
# SESSION_SECRET=
|
|
10
|
+
|
|
11
|
+
# Set only behind a trusted reverse proxy
|
|
12
|
+
# TRUST_FORWARDED_FOR=true
|
|
13
|
+
|
|
14
|
+
# Required in production to expose GET /metrics
|
|
15
|
+
# METRICS_TOKEN=
|
|
16
|
+
|
|
17
|
+
# Production should set FEATURE_PUBLIC_READS=false
|
|
18
|
+
# FEATURE_PUBLIC_READS=false
|
|
@@ -11,13 +11,11 @@
|
|
|
11
11
|
"check": "tsc --noEmit"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@getstrata/bootstrap": "^0.
|
|
15
|
-
"@getstrata/core": "^0.
|
|
16
|
-
"eta": "^4.6.0",
|
|
17
|
-
"postgres": "3.4.7"
|
|
14
|
+
"@getstrata/bootstrap": "^0.2.48",
|
|
15
|
+
"@getstrata/core": "^0.5.57"
|
|
18
16
|
},
|
|
19
17
|
"devDependencies": {
|
|
20
|
-
"@types/bun": "
|
|
18
|
+
"@types/bun": "^1.4.0",
|
|
21
19
|
"typescript": "^5.9.2"
|
|
22
20
|
}
|
|
23
21
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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 { migrate: runMigrate = true } = options;
|
|
72
|
+
|
|
73
|
+
const appConfig = loadConfig();
|
|
74
|
+
const context = createAppContext();
|
|
75
|
+
|
|
76
|
+
if (runMigrate) {
|
|
77
|
+
await migrate();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const routes = buildRoutes(context.dependencies);
|
|
81
|
+
|
|
82
|
+
return { context, routes, config: appConfig };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createAppServer(routes: AppRouteMap, port = 0) {
|
|
86
|
+
return createWebServer({
|
|
87
|
+
port,
|
|
88
|
+
publicDir: "./public",
|
|
89
|
+
routes,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { createAppContext };
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { SQL } from "bun";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
export type SqlClient = InstanceType<typeof SQL>;
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
let sql: SqlClient | null = null;
|
|
6
|
+
|
|
7
|
+
export function getSql(): SqlClient {
|
|
6
8
|
if (!sql) {
|
|
7
9
|
const url = process.env.DATABASE_URL;
|
|
8
10
|
if (!url) throw new Error("DATABASE_URL is required");
|
|
9
|
-
sql =
|
|
11
|
+
sql = new SQL({ url, max: 5 });
|
|
10
12
|
}
|
|
11
13
|
return sql;
|
|
12
14
|
}
|
|
@@ -22,7 +24,7 @@ export async function pingDatabase(): Promise<boolean> {
|
|
|
22
24
|
|
|
23
25
|
export async function closeDatabase() {
|
|
24
26
|
if (sql) {
|
|
25
|
-
await sql.
|
|
27
|
+
await sql.close();
|
|
26
28
|
sql = null;
|
|
27
29
|
}
|
|
28
30
|
}
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
1
|
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
|
-
}
|
|
2
|
+
import { configureModulesDirectory } from "@getstrata/bootstrap/discoverModules";
|
|
16
3
|
|
|
17
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;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APP_PORT_CONFIG_KEY,
|
|
3
|
+
CORE_CONFIG_TOKEN,
|
|
4
|
+
DATABASE_URL_CONFIG_KEY,
|
|
5
|
+
REDIS_URL_CONFIG_KEY,
|
|
6
|
+
} from "@getstrata/bootstrap/config";
|
|
7
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
8
|
+
import { loadConfig } from "../config.ts";
|
|
9
|
+
|
|
10
|
+
const configProvider: ServiceProvider = {
|
|
11
|
+
name: "starter.config",
|
|
12
|
+
register({ container, config }) {
|
|
13
|
+
const appConfig = loadConfig();
|
|
14
|
+
|
|
15
|
+
container.set(CORE_CONFIG_TOKEN, config);
|
|
16
|
+
config.set(DATABASE_URL_CONFIG_KEY, appConfig.databaseUrl);
|
|
17
|
+
config.set(APP_PORT_CONFIG_KEY, appConfig.port);
|
|
18
|
+
config.set(REDIS_URL_CONFIG_KEY, process.env.REDIS_URL ?? "");
|
|
19
|
+
config.set("app.url", appConfig.appUrl);
|
|
20
|
+
config.set("cache.driver", "array");
|
|
21
|
+
config.set("cache.ttlMs", 3_600_000);
|
|
22
|
+
config.set("cache.maxEntries", 100);
|
|
23
|
+
config.set("queue.driver", process.env.QUEUE_DRIVER ?? "sync");
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default configProvider;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
2
|
+
import authProvider from "./auth.ts";
|
|
3
|
+
import cacheProvider from "./cache.ts";
|
|
4
|
+
import configProvider from "./config.ts";
|
|
5
|
+
import storageProvider from "./storage.ts";
|
|
6
|
+
|
|
7
|
+
const starterProviders: ServiceProvider[] = [
|
|
8
|
+
configProvider,
|
|
9
|
+
cacheProvider,
|
|
10
|
+
storageProvider,
|
|
11
|
+
authProvider,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export { starterProviders };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
2
|
+
import { createStorageDriver, StorageManager } from "@getstrata/core/storage/storage";
|
|
3
|
+
|
|
4
|
+
const storageProvider: ServiceProvider = {
|
|
5
|
+
name: "starter.storage",
|
|
6
|
+
register({ dependencies }) {
|
|
7
|
+
Reflect.set(dependencies, "storage", new StorageManager(createStorageDriver()));
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default storageProvider;
|
|
@@ -1,23 +1,13 @@
|
|
|
1
|
-
import { createWebServer } from "@getstrata/bootstrap";
|
|
2
1
|
import "./preload.ts";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { registerRoutes } from "../routes.ts";
|
|
6
|
-
import { loadConfig } from "./config.ts";
|
|
2
|
+
import { ensureModulesLoaded } from "@getstrata/bootstrap/discoverModules";
|
|
3
|
+
import { bootstrapApp, createAppServer } from "./createApp.ts";
|
|
7
4
|
import { closeDatabase, pingDatabase } from "./database.ts";
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
await ensureModulesLoaded();
|
|
10
7
|
|
|
11
|
-
await
|
|
8
|
+
const { routes, config } = await bootstrapApp();
|
|
12
9
|
|
|
13
|
-
const
|
|
14
|
-
registerRoutes(router);
|
|
15
|
-
|
|
16
|
-
const server = createWebServer({
|
|
17
|
-
port: config.port,
|
|
18
|
-
publicDir: "./public",
|
|
19
|
-
handle: (request) => router.handle(request),
|
|
20
|
-
});
|
|
10
|
+
const server = createAppServer(routes, config.port);
|
|
21
11
|
|
|
22
12
|
console.log(`${config.appUrl} (port ${server.port})`);
|
|
23
13
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const engine = new EtaViewEngine(join(import.meta.dir, "../../views"));
|
|
5
5
|
|
|
6
6
|
export interface LayoutData {
|
|
7
7
|
title: string;
|
|
@@ -12,11 +12,8 @@ export async function renderPage(
|
|
|
12
12
|
template: string,
|
|
13
13
|
data: Record<string, unknown> & { layout: LayoutData },
|
|
14
14
|
): Promise<Response> {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
return new Response(html, {
|
|
18
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
19
|
-
});
|
|
15
|
+
const html = await engine.render(template, data);
|
|
16
|
+
return htmlResponse(html);
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
export function plainText(body: string): Response {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
2
|
+
import { withErrorHandling } from "@getstrata/core/http/response";
|
|
3
|
+
import { pingDatabase } from "../../bootstrap/database.ts";
|
|
4
|
+
import { plainText, renderPage } from "../../lib/view.ts";
|
|
5
|
+
|
|
6
|
+
const siteModule: AppModule = {
|
|
7
|
+
name: "site",
|
|
8
|
+
order: 1,
|
|
9
|
+
webRoutes({ kernel }) {
|
|
10
|
+
return {
|
|
11
|
+
"/": kernel.wrapWeb(async () =>
|
|
12
|
+
renderPage("home.eta", {
|
|
13
|
+
layout: {
|
|
14
|
+
title: "Home",
|
|
15
|
+
description: "A new Strata application",
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
"/health": kernel.wrapWeb(
|
|
20
|
+
withErrorHandling(async () => {
|
|
21
|
+
const dbOk = await pingDatabase();
|
|
22
|
+
return plainText(dbOk ? "ok" : "degraded");
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default siteModule;
|
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { Router } from "./lib/router.ts";
|
|
4
|
-
import { plainText, renderPage } from "./lib/view.ts";
|
|
1
|
+
import { buildWebModuleRoutes } from "@getstrata/bootstrap/buildWebModuleRoutes";
|
|
2
|
+
import type { AppDependencies, AppRouteMap } from "@getstrata/bootstrap/contracts";
|
|
5
3
|
|
|
6
|
-
export function
|
|
7
|
-
|
|
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
|
-
);
|
|
4
|
+
export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
|
|
5
|
+
return buildWebModuleRoutes(dependencies);
|
|
23
6
|
}
|
package/package.json
CHANGED
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
}
|