@getstrata/starter 0.1.0 → 0.1.2

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.
Files changed (32) hide show
  1. package/README.md +16 -1
  2. package/dist/templates/.env.example +15 -0
  3. package/dist/templates/package.json +3 -5
  4. package/dist/templates/src/bootstrap/createApp.ts +93 -0
  5. package/dist/templates/src/bootstrap/database.ts +7 -5
  6. package/dist/templates/src/bootstrap/preload.ts +2 -14
  7. package/dist/templates/src/bootstrap/providers/auth.ts +41 -0
  8. package/dist/templates/src/bootstrap/providers/cache.ts +28 -0
  9. package/dist/templates/src/bootstrap/providers/config.ts +27 -0
  10. package/dist/templates/src/bootstrap/providers/index.ts +14 -0
  11. package/dist/templates/src/bootstrap/providers/storage.ts +11 -0
  12. package/dist/templates/src/bootstrap/server.ts +5 -15
  13. package/dist/templates/src/lib/view.ts +4 -7
  14. package/dist/templates/src/modules/site/index.ts +29 -0
  15. package/dist/templates/src/routes.ts +4 -21
  16. package/dist/templates/templates/.env.example +15 -0
  17. package/dist/templates/templates/package.json +3 -5
  18. package/dist/templates/templates/src/bootstrap/createApp.ts +93 -0
  19. package/dist/templates/templates/src/bootstrap/database.ts +7 -5
  20. package/dist/templates/templates/src/bootstrap/preload.ts +2 -14
  21. package/dist/templates/templates/src/bootstrap/providers/auth.ts +41 -0
  22. package/dist/templates/templates/src/bootstrap/providers/cache.ts +28 -0
  23. package/dist/templates/templates/src/bootstrap/providers/config.ts +27 -0
  24. package/dist/templates/templates/src/bootstrap/providers/index.ts +14 -0
  25. package/dist/templates/templates/src/bootstrap/providers/storage.ts +11 -0
  26. package/dist/templates/templates/src/bootstrap/server.ts +5 -15
  27. package/dist/templates/templates/src/lib/view.ts +4 -7
  28. package/dist/templates/templates/src/modules/site/index.ts +29 -0
  29. package/dist/templates/templates/src/routes.ts +4 -21
  30. package/package.json +1 -1
  31. package/dist/templates/src/lib/router.ts +0 -60
  32. 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
+ `createAppContext()` does not call `assertProductionSecrets()`. That helper 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.1.0",
15
- "@getstrata/core": "^0.3.0",
16
- "eta": "^4.6.0",
17
- "postgres": "3.4.7"
14
+ "@getstrata/bootstrap": "^0.2.49",
15
+ "@getstrata/core": "^0.5.58"
18
16
  },
19
17
  "devDependencies": {
20
- "@types/bun": "latest",
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 postgres from "postgres";
1
+ import { SQL } from "bun";
2
2
 
3
- let sql: ReturnType<typeof postgres> | null = null;
3
+ export type SqlClient = InstanceType<typeof SQL>;
4
4
 
5
- export function getSql() {
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 = postgres(url, { max: 5 });
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.end();
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 { migrate } from "../db/migrate.ts";
4
- import { Router } from "../lib/router.ts";
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
- const config = loadConfig();
6
+ await ensureModulesLoaded();
10
7
 
11
- await migrate();
8
+ const { routes, config } = await bootstrapApp();
12
9
 
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
- });
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 { Eta } from "eta";
2
+ import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
3
3
 
4
- const eta = new Eta({ views: join(import.meta.dir, "../../views") });
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 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
- });
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 { 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";
1
+ import { buildWebModuleRoutes } from "@getstrata/bootstrap/buildWebModuleRoutes";
2
+ import type { AppDependencies, AppRouteMap } from "@getstrata/bootstrap/contracts";
5
3
 
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
- );
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.1.0",
15
- "@getstrata/core": "^0.3.0",
16
- "eta": "^4.6.0",
17
- "postgres": "3.4.7"
14
+ "@getstrata/bootstrap": "^0.2.49",
15
+ "@getstrata/core": "^0.5.58"
18
16
  },
19
17
  "devDependencies": {
20
- "@types/bun": "latest",
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 postgres from "postgres";
1
+ import { SQL } from "bun";
2
2
 
3
- let sql: ReturnType<typeof postgres> | null = null;
3
+ export type SqlClient = InstanceType<typeof SQL>;
4
4
 
5
- export function getSql() {
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 = postgres(url, { max: 5 });
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.end();
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 { migrate } from "../db/migrate.ts";
4
- import { Router } from "../lib/router.ts";
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
- const config = loadConfig();
6
+ await ensureModulesLoaded();
10
7
 
11
- await migrate();
8
+ const { routes, config } = await bootstrapApp();
12
9
 
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
- });
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 { Eta } from "eta";
2
+ import { EtaViewEngine, htmlResponse } from "@getstrata/core/view";
3
3
 
4
- const eta = new Eta({ views: join(import.meta.dir, "../../views") });
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 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
- });
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 { 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";
1
+ import { buildWebModuleRoutes } from "@getstrata/bootstrap/buildWebModuleRoutes";
2
+ import type { AppDependencies, AppRouteMap } from "@getstrata/bootstrap/contracts";
5
3
 
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
- );
4
+ export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
5
+ return buildWebModuleRoutes(dependencies);
23
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/starter",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Scaffold a new Strata app — bun create strata",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- }