create-kelpie 0.2.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.
@@ -0,0 +1,118 @@
1
+ import { serve } from '@hono/node-server'
2
+ import {
3
+ ConfigurationError,
4
+ ModuleBootError,
5
+ ModuleConfigFileError,
6
+ connectDatabase,
7
+ createApp,
8
+ createEmailSender,
9
+ createEventBus,
10
+ createIdFactory,
11
+ createLogger,
12
+ createTransactionScope,
13
+ loadConfig,
14
+ readModuleConfigFile,
15
+ registerModules,
16
+ resolveActorFrom,
17
+ runMigrations,
18
+ } from '@kelpie/server'
19
+ import type { CredentialDependencies } from '@kelpie/server'
20
+
21
+ import { modules } from '../kelpie.config.ts'
22
+
23
+ /**
24
+ * The entry point. It reads the environment, registers the configured modules,
25
+ * applies migrations, wires the dependencies, and serves.
26
+ *
27
+ * Registration runs before migrations. Modules declare their migrations
28
+ * directory while registering, so there is nothing to migrate until that pass
29
+ * has finished.
30
+ *
31
+ * `--no-migrate` skips the migration step, for deployments where one release
32
+ * step migrates and many instances then start.
33
+ */
34
+
35
+ function reportFatal(message: string): void {
36
+ process.stderr.write(`${message}\n`)
37
+ }
38
+
39
+ async function start(): Promise<void> {
40
+ const config = loadConfig(process.env)
41
+ const logger = createLogger(config.logLevel)
42
+ const database = connectDatabase(config.databaseUrl, logger)
43
+ const events = createEventBus(logger)
44
+ const createId = createIdFactory()
45
+ const credentials: CredentialDependencies = { db: database.db, now: () => new Date() }
46
+ const moduleConfig = readModuleConfigFile(config.moduleConfigPath)
47
+ const contributions = await registerModules({
48
+ modules,
49
+ environment: process.env,
50
+ logger,
51
+ events,
52
+ moduleConfig,
53
+ resolveActor: (context) => resolveActorFrom(credentials, context),
54
+ services: {
55
+ db: database.db,
56
+ transaction: createTransactionScope({ db: database.db, bus: events, logger }),
57
+ email: createEmailSender(config.email, logger),
58
+ createId,
59
+ now: () => new Date(),
60
+ },
61
+ })
62
+
63
+ if (process.argv.includes('--no-migrate')) {
64
+ logger.info('skipping migrations', { reason: '--no-migrate' })
65
+ } else {
66
+ await runMigrations(database.db, contributions.schemas, logger)
67
+ }
68
+
69
+ const app = createApp({
70
+ logger,
71
+ probeDatabase: database.probe,
72
+ contributions,
73
+ credentials,
74
+ createId,
75
+ })
76
+
77
+ const server = serve({ fetch: app.fetch, port: config.port }, (address) => {
78
+ logger.info('listening', { port: address.port, runtimeMode: config.runtimeMode })
79
+ })
80
+
81
+ const shutdown = (signal: string): void => {
82
+ logger.info('shutting down', { signal })
83
+ server.close(() => {
84
+ // Drain before closing the pool: a handler mid-flight may still be writing.
85
+ void contributions.events
86
+ .drain()
87
+ .then(() => database.close())
88
+ .then(() => process.exit(0))
89
+ })
90
+ }
91
+
92
+ process.on('SIGINT', () => shutdown('SIGINT'))
93
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
94
+ }
95
+
96
+ try {
97
+ await start()
98
+ } catch (error: unknown) {
99
+ if (error instanceof ConfigurationError) {
100
+ reportFatal(error.message)
101
+ reportFatal('Check .env against the table in README.md.')
102
+ process.exit(1)
103
+ }
104
+
105
+ if (error instanceof ModuleConfigFileError) {
106
+ reportFatal(error.message)
107
+ reportFatal('Fix the file at KELPIE_MODULE_CONFIG_PATH, or unset it to let workspaces decide for themselves.')
108
+ process.exit(1)
109
+ }
110
+
111
+ if (error instanceof ModuleBootError) {
112
+ reportFatal(error.message)
113
+ reportFatal('Fix the module list in kelpie.config.ts, or the configuration it needs.')
114
+ process.exit(1)
115
+ }
116
+
117
+ throw error
118
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023"],
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "types": ["node"],
8
+
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "exactOptionalPropertyTypes": true,
12
+ "verbatimModuleSyntax": true,
13
+ "allowImportingTsExtensions": true,
14
+ "erasableSyntaxOnly": true,
15
+
16
+ "skipLibCheck": true,
17
+ "noEmit": true
18
+ },
19
+ "include": ["src", "kelpie.config.ts"]
20
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "esnext",
6
+ "moduleResolution": "bundler",
7
+ "types": ["vite/client", "node"],
8
+ "jsx": "react-jsx",
9
+
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "exactOptionalPropertyTypes": true,
13
+ "verbatimModuleSyntax": true,
14
+ "allowImportingTsExtensions": true,
15
+ "erasableSyntaxOnly": true,
16
+
17
+ "skipLibCheck": true,
18
+ "noEmit": true
19
+ },
20
+ "include": ["web", "vite.config.ts", "kelpie.ui.config.ts"]
21
+ }
@@ -0,0 +1,60 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ import react from '@vitejs/plugin-react'
5
+ import { defineConfig, loadEnv } from 'vite'
6
+
7
+ const projectRoot = fileURLToPath(new URL('./', import.meta.url))
8
+
9
+ /**
10
+ * The dev server proxies the API rather than pointing the browser at a second
11
+ * origin, so the UI runs against same-origin `/v1` in development exactly as it
12
+ * does in production.
13
+ */
14
+ export default defineConfig(({ mode }) => {
15
+ // `loadEnv` rather than `process.env`, because `npm run dev:web` runs `vite`
16
+ // directly and nothing has read `.env` into the environment by then. Reading
17
+ // `process.env.WEB_PORT` here means the port in `.env` is silently ignored and
18
+ // Vite takes 5173, or the next free one, while README and .env both say
19
+ // otherwise.
20
+ //
21
+ // `API_PORT`, not `PORT`. Every process manager sets `PORT` to the port it
22
+ // wants the process it is launching to listen on, and a prefix that matched it
23
+ // would let that value win over `.env`. Vite would then proxy to itself: the
24
+ // page loads and every `/v1` call times out against the dev server it came
25
+ // from.
26
+ const environment = loadEnv(mode, projectRoot, ['API_', 'WEB_'])
27
+ const apiPort = environment.API_PORT
28
+
29
+ if (apiPort === undefined || apiPort.length === 0) {
30
+ throw new Error('API_PORT is not set. It belongs in .env, with the same value as PORT.')
31
+ }
32
+
33
+ const apiOrigin = `http://localhost:${apiPort}`
34
+
35
+ return {
36
+ root: fileURLToPath(new URL('./web', import.meta.url)),
37
+ envDir: projectRoot,
38
+ plugins: [react(), tailwindcss()],
39
+ build: {
40
+ outDir: fileURLToPath(new URL('./dist', import.meta.url)),
41
+ emptyOutDir: true,
42
+ },
43
+ server: {
44
+ port: Number(environment.WEB_PORT ?? 5173),
45
+ // Refuse rather than quietly taking the next port. A launcher that was
46
+ // told 5173 and got 5174 proxies nothing, and the README hands out an
47
+ // address that answers nothing.
48
+ strictPort: true,
49
+ proxy: {
50
+ '/v1': { target: apiOrigin, changeOrigin: false },
51
+ '/healthz': { target: apiOrigin, changeOrigin: false },
52
+ // The MCP page shows the endpoint at the origin the browser reached the
53
+ // app on, which in development is this dev server. Without the proxy
54
+ // that address is right in production and dead here, so anyone copying
55
+ // it out of the page would be debugging the wrong thing.
56
+ '/mcp': { target: apiOrigin, changeOrigin: false },
57
+ },
58
+ },
59
+ }
60
+ })
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__PROJECT_NAME__</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,23 @@
1
+ import { KelpieApp, registerUiModules } from '@kelpie/ui'
2
+ import '@kelpie/ui/styles.css'
3
+ import { StrictMode } from 'react'
4
+ import { createRoot } from 'react-dom/client'
5
+
6
+ import { uiModules } from '../kelpie.ui.config.ts'
7
+
8
+ const container = document.getElementById('root')
9
+
10
+ if (container === null) {
11
+ throw new Error('Expected an element with id "root" in index.html')
12
+ }
13
+
14
+ // Registration is build-time and happens once, above the root. A module
15
+ // clashing with another fails here, at startup, rather than as a tab somebody
16
+ // notices is missing a week later.
17
+ const extensions = registerUiModules(uiModules)
18
+
19
+ createRoot(container).render(
20
+ <StrictMode>
21
+ <KelpieApp extensions={extensions} />
22
+ </StrictMode>,
23
+ )