@better-forge/runtime 0.0.0-stage → 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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -3
  3. package/dist/appLock.d.ts +13 -0
  4. package/dist/appLock.d.ts.map +1 -0
  5. package/dist/appLock.js +67 -0
  6. package/dist/appLock.js.map +1 -0
  7. package/dist/createBetterApp.d.ts +63 -0
  8. package/dist/createBetterApp.d.ts.map +1 -0
  9. package/dist/createBetterApp.js +181 -0
  10. package/dist/createBetterApp.js.map +1 -0
  11. package/dist/dataPaths.d.ts +13 -0
  12. package/dist/dataPaths.d.ts.map +1 -0
  13. package/dist/dataPaths.js +39 -0
  14. package/dist/dataPaths.js.map +1 -0
  15. package/dist/errorHandler.d.ts +8 -0
  16. package/dist/errorHandler.d.ts.map +1 -0
  17. package/dist/errorHandler.js +14 -0
  18. package/dist/errorHandler.js.map +1 -0
  19. package/dist/health.d.ts +57 -0
  20. package/dist/health.d.ts.map +1 -0
  21. package/dist/health.js +62 -0
  22. package/dist/health.js.map +1 -0
  23. package/dist/index.d.ts +15 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +15 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/processLiveness.d.ts +15 -0
  28. package/dist/processLiveness.d.ts.map +1 -0
  29. package/dist/processLiveness.js +30 -0
  30. package/dist/processLiveness.js.map +1 -0
  31. package/dist/proxy.d.ts +4 -0
  32. package/dist/proxy.d.ts.map +1 -0
  33. package/dist/proxy.js +20 -0
  34. package/dist/proxy.js.map +1 -0
  35. package/dist/publicUrl.d.ts +11 -0
  36. package/dist/publicUrl.d.ts.map +1 -0
  37. package/dist/publicUrl.js +29 -0
  38. package/dist/publicUrl.js.map +1 -0
  39. package/dist/rateLimitKey.d.ts +7 -0
  40. package/dist/rateLimitKey.d.ts.map +1 -0
  41. package/dist/rateLimitKey.js +21 -0
  42. package/dist/rateLimitKey.js.map +1 -0
  43. package/dist/redactUrl.d.ts +9 -0
  44. package/dist/redactUrl.d.ts.map +1 -0
  45. package/dist/redactUrl.js +48 -0
  46. package/dist/redactUrl.js.map +1 -0
  47. package/dist/shutdown.d.ts +20 -0
  48. package/dist/shutdown.d.ts.map +1 -0
  49. package/dist/shutdown.js +46 -0
  50. package/dist/shutdown.js.map +1 -0
  51. package/dist/sse.d.ts +58 -0
  52. package/dist/sse.d.ts.map +1 -0
  53. package/dist/sse.js +119 -0
  54. package/dist/sse.js.map +1 -0
  55. package/dist/updateCheck.d.ts +26 -0
  56. package/dist/updateCheck.d.ts.map +1 -0
  57. package/dist/updateCheck.js +95 -0
  58. package/dist/updateCheck.js.map +1 -0
  59. package/dist/version.d.ts +5 -0
  60. package/dist/version.d.ts.map +1 -0
  61. package/dist/version.js +5 -0
  62. package/dist/version.js.map +1 -0
  63. package/package.json +65 -6
  64. package/src/appLock.ts +63 -0
  65. package/src/createBetterApp.ts +236 -0
  66. package/src/dataPaths.ts +48 -0
  67. package/src/errorHandler.ts +15 -0
  68. package/src/health.ts +109 -0
  69. package/src/index.ts +14 -0
  70. package/src/processLiveness.ts +31 -0
  71. package/src/proxy.ts +20 -0
  72. package/src/publicUrl.ts +26 -0
  73. package/src/rateLimitKey.ts +20 -0
  74. package/src/redactUrl.ts +49 -0
  75. package/src/shutdown.ts +62 -0
  76. package/src/sse.ts +154 -0
  77. package/src/updateCheck.ts +107 -0
  78. package/src/version.ts +7 -0
@@ -0,0 +1,95 @@
1
+ const REFRESH_MS = 6 * 60 * 60 * 1000;
2
+ const RETRY_MS = 5 * 60 * 1000;
3
+ const TIMEOUT_MS = 5_000;
4
+ /** `UPDATE_CHECK=false` keeps the instance from contacting GitHub (air-gapped or privacy-minded installs). */
5
+ export function updateCheckEnabled(environment = process.env) {
6
+ return environment.UPDATE_CHECK?.trim().toLowerCase() !== 'false';
7
+ }
8
+ function parseVersion(value) {
9
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
10
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
11
+ }
12
+ /** True when `candidate` is a higher x.y.z than `current`; false when either is not a plain release version. */
13
+ export function isNewerVersion(candidate, current) {
14
+ const a = parseVersion(candidate);
15
+ const b = parseVersion(current);
16
+ if (!a || !b)
17
+ return false;
18
+ for (let i = 0; i < 3; i++) {
19
+ if (a[i] !== b[i])
20
+ return a[i] > b[i];
21
+ }
22
+ return false;
23
+ }
24
+ /**
25
+ * Asks GitHub which release of `repository` ("owner/name") is newest and remembers it, to point out an update to
26
+ * whoever runs an older `version()`. All state is in the returned closure.
27
+ */
28
+ export function createUpdateCheck(options) {
29
+ const RELEASES_API = `https://api.github.com/repos/${options.repository}/releases/latest`;
30
+ const resolveAppVersion = options.version;
31
+ let latest = null;
32
+ let timer;
33
+ /** Bumped by every start and stop, so a check still in flight when the service stops cannot schedule another. */
34
+ let generation = 0;
35
+ /** The newest published release, null when there is none a user should be pointed at; throws when GitHub cannot be asked. */
36
+ async function fetchLatestRelease() {
37
+ const response = await fetch(RELEASES_API, {
38
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': `${options.name}/${resolveAppVersion()}` },
39
+ signal: AbortSignal.timeout(TIMEOUT_MS),
40
+ });
41
+ if (!response.ok)
42
+ throw new Error(`GitHub answered ${response.status}`);
43
+ const body = await response.json();
44
+ if (typeof body !== 'object' || body === null)
45
+ return null;
46
+ const { tag_name: tag, html_url: url, draft, prerelease } = body;
47
+ if (typeof tag !== 'string' || typeof url !== 'string' || draft === true || prerelease === true)
48
+ return null;
49
+ if (!parseVersion(tag) || !url.startsWith(`https://github.com/${options.repository}/`))
50
+ return null;
51
+ return { version: tag.replace(/^v/, ''), url };
52
+ }
53
+ /** Asks GitHub once and remembers the answer; true when it could be asked. Never throws. */
54
+ async function checkForUpdate() {
55
+ try {
56
+ latest = await fetchLatestRelease();
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /** Checks now, in the background, then every few hours (sooner after a failure). A no-op when `UPDATE_CHECK=false`. */
64
+ function startUpdateCheck() {
65
+ stopUpdateCheck();
66
+ if (!updateCheckEnabled())
67
+ return;
68
+ const mine = generation;
69
+ const run = async () => {
70
+ const asked = await checkForUpdate();
71
+ if (mine !== generation)
72
+ return;
73
+ timer = setTimeout(() => void run(), asked ? REFRESH_MS : RETRY_MS);
74
+ timer.unref();
75
+ };
76
+ void run();
77
+ }
78
+ function stopUpdateCheck() {
79
+ generation++;
80
+ clearTimeout(timer);
81
+ timer = undefined;
82
+ }
83
+ /** The newest release when it is newer than the running version, else null. Reads what the background check found. */
84
+ function getAvailableUpdate() {
85
+ if (!updateCheckEnabled() || !latest)
86
+ return null;
87
+ return isNewerVersion(latest.version, resolveAppVersion()) ? latest : null;
88
+ }
89
+ function resetUpdateCheck() {
90
+ stopUpdateCheck();
91
+ latest = null;
92
+ }
93
+ return { checkForUpdate, startUpdateCheck, stopUpdateCheck, getAvailableUpdate, resetUpdateCheck };
94
+ }
95
+ //# sourceMappingURL=updateCheck.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updateCheck.js","sourceRoot":"","sources":["../src/updateCheck.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ /** The running version: the `APP_VERSION` environment variable (set by the image build), else the version in the package. */
2
+ export declare function resolveAppVersion(packageVersion: string, environment?: {
3
+ APP_VERSION?: string | undefined;
4
+ }): string;
5
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,6HAA6H;AAC7H,wBAAgB,iBAAiB,CAC/B,cAAc,EAAE,MAAM,EACtB,WAAW,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAgB,GAC9D,MAAM,CAER"}
@@ -0,0 +1,5 @@
1
+ /** The running version: the `APP_VERSION` environment variable (set by the image build), else the version in the package. */
2
+ export function resolveAppVersion(packageVersion, environment = process.env) {
3
+ return environment.APP_VERSION?.trim() || packageVersion;
4
+ }
5
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,6HAA6H;AAC7H,MAAM,UAAU,iBAAiB,CAC/B,cAAsB,EACtB,cAAoD,OAAO,CAAC,GAAG;IAE/D,OAAO,WAAW,CAAC,WAAW,EAAE,IAAI,EAAE,IAAI,cAAc,CAAA;AAC1D,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
- {
2
- "name": "@better-forge/runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@better-forge/runtime",
3
+ "version": "0.1.0",
4
+ "description": "Application bootstrap: Fastify defaults, health, shutdown, update check, app lock and SSE.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/BElluu/Better-Forge.git",
9
+ "directory": "packages/runtime"
10
+ },
11
+ "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/runtime#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/BElluu/Better-Forge/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "!src/**/*.test.*"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
+ },
37
+ "peerDependencies": {
38
+ "@fastify/cookie": "^11.1.1",
39
+ "@fastify/cors": "^10.0.2",
40
+ "@fastify/jwt": "^10.1.0",
41
+ "@fastify/multipart": "^9.0.3",
42
+ "@fastify/rate-limit": "^10.3.0",
43
+ "@fastify/static": "^10.1.3",
44
+ "drizzle-orm": "^0.45.2",
45
+ "fastify": "^5.3.2"
46
+ },
47
+ "devDependencies": {
48
+ "@fastify/cookie": "^11.1.1",
49
+ "@fastify/cors": "^10.0.2",
50
+ "@fastify/jwt": "^10.1.0",
51
+ "@fastify/multipart": "^9.0.3",
52
+ "@fastify/rate-limit": "^10.3.0",
53
+ "@fastify/static": "^10.1.3",
54
+ "@types/node": "^26.6.2",
55
+ "drizzle-orm": "^0.45.2",
56
+ "fastify": "^5.3.2",
57
+ "typescript": "^5.7.3"
58
+ },
59
+ "dependencies": {
60
+ "@better-forge/db": "0.1.0"
61
+ },
62
+ "engines": {
63
+ "node": ">=26.10.0"
64
+ }
65
+ }
package/src/appLock.ts ADDED
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { currentProcessOwner, isOwnerAlive, type ProcessOwner } from './processLiveness.js'
4
+
5
+ const FRESH_MS = 30_000
6
+
7
+ /**
8
+ * A stalled heartbeat (event loop blocked by a long synchronous task) still counts as running
9
+ * while the recorded pid is alive — but only for this long, so a reused pid cannot block forever.
10
+ */
11
+ const LIVE_OWNER_MS = 10 * 60_000
12
+
13
+ /**
14
+ * The marker file that tells a restore or a second start that the application is running. `name` is how the
15
+ * application is called in the refusal, `fileName` the marker inside `dir()` (evaluated on each use).
16
+ */
17
+ export function createAppLock(options: { name: string; fileName: string; dir: () => string }) {
18
+ const RUNNING_MESSAGE = `${options.name} is still running. Stop the application before restoring.`
19
+ const lockPath = (): string => path.join(options.dir(), options.fileName)
20
+
21
+ function readOwner(file: string): Partial<ProcessOwner> {
22
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<ProcessOwner> }
23
+ catch { return {} }
24
+ }
25
+
26
+ /** Removes a marker whose owner is gone; throws while the app still looks alive. */
27
+ function clearStaleMarker(options: { checkOwner: boolean }): void {
28
+ const file = lockPath()
29
+ if (!fs.existsSync(file)) return
30
+ const age = Date.now() - fs.statSync(file).mtimeMs
31
+ const ownerAlive = isOwnerAlive(readOwner(file))
32
+ // A killed process (Ctrl+C on Windows, tsx watch restart) leaves a fresh marker; startup trusts a dead pid over the heartbeat.
33
+ if (age < FRESH_MS && (options.checkOwner || ownerAlive)) throw new Error(RUNNING_MESSAGE)
34
+ if (options.checkOwner && age < LIVE_OWNER_MS && ownerAlive) throw new Error(RUNNING_MESSAGE)
35
+ fs.rmSync(file, { force: true })
36
+ }
37
+
38
+ /** Used by restore: also refuses while the recorded app process is alive but its heartbeat has stalled. */
39
+ function assertAppStopped(): void {
40
+ clearStaleMarker({ checkOwner: true })
41
+ }
42
+
43
+ /** Startup uses the heartbeat only, so a restart after a crash is never blocked by an unrelated process that reused the pid. */
44
+ function acquireAppLock(): () => void {
45
+ clearStaleMarker({ checkOwner: false })
46
+ fs.mkdirSync(options.dir(), { recursive: true })
47
+ const file = lockPath()
48
+ fs.writeFileSync(file, JSON.stringify({ ...currentProcessOwner(), startedAt: Date.now() }), { flag: 'wx', mode: 0o600 })
49
+ const heartbeat = setInterval(() => {
50
+ try { fs.utimesSync(file, new Date(), new Date()) } catch { /* shutdown race */ }
51
+ }, 5_000)
52
+ heartbeat.unref()
53
+ let released = false
54
+ return () => {
55
+ if (released) return
56
+ released = true
57
+ clearInterval(heartbeat)
58
+ fs.rmSync(file, { force: true })
59
+ }
60
+ }
61
+
62
+ return { assertAppStopped, acquireAppLock }
63
+ }
@@ -0,0 +1,236 @@
1
+ import fs from 'node:fs'
2
+ import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'
3
+ import cors from '@fastify/cors'
4
+ import cookie from '@fastify/cookie'
5
+ import jwt from '@fastify/jwt'
6
+ import multipart from '@fastify/multipart'
7
+ import rateLimit from '@fastify/rate-limit'
8
+ import { rateLimitKey } from './rateLimitKey.js'
9
+ import staticFiles from '@fastify/static'
10
+ import { errorHandler } from './errorHandler.js'
11
+ import { redactUrl } from './redactUrl.js'
12
+ import { resolveTrustProxy } from './proxy.js'
13
+ import { createRuntimeShutdown } from './shutdown.js'
14
+
15
+ const DEFAULT_JWT_SECRET = 'dev-secret-change-me'
16
+ const EXAMPLE_SECRETS = new Set([
17
+ 'change-me-to-a-long-random-string',
18
+ 'change-me-to-a-64-char-hex-string',
19
+ ])
20
+
21
+ /** How long a signed-in session token lives. */
22
+ export const JWT_EXPIRES_IN = '12h'
23
+
24
+ /** The secret sessions are signed with: from `JWT_SECRET`, which production insists on being a real one. */
25
+ export function resolveJwtSecret(env: NodeJS.ProcessEnv = process.env): string {
26
+ const secret = env['JWT_SECRET']
27
+ if (env['NODE_ENV'] !== 'production') {
28
+ if (!secret) console.warn('JWT_SECRET not set - using insecure default (development only)')
29
+ return secret ?? DEFAULT_JWT_SECRET
30
+ }
31
+ if (!secret || secret.length < 32 || EXAMPLE_SECRETS.has(secret)) {
32
+ throw new Error('JWT_SECRET must be set to a non-default value of at least 32 characters in production')
33
+ }
34
+ return secret
35
+ }
36
+
37
+ export interface BetterAppOptions {
38
+ /** Request URLs are logged with the value of these query parameters replaced (capability tokens in links). */
39
+ redactQueryParams?: readonly string[]
40
+ /** Request paths whose next segment is a capability token, for example `/links/` (the token is not logged). */
41
+ redactPathTokenPrefixes?: readonly string[]
42
+ /** Checks run before anything is created (for example that the encryption key is usable); throw to refuse to start. */
43
+ validate?: () => void
44
+ /** Where uploaded files are kept and served from (`/uploads/`). */
45
+ uploads: { dir: string; maxFileBytes: number }
46
+ /** Whether a response to this request may be shown in a frame of our own origin (anything else is refused). */
47
+ allowSameOriginFraming?: (request: { path: string; query: URLSearchParams }) => boolean
48
+ /** The database is opened only once the application has been set up. */
49
+ database: {
50
+ isReady: () => boolean
51
+ open: () => void
52
+ migrate: () => void
53
+ close: () => void
54
+ }
55
+ /** Registers every route of the application. */
56
+ routes: (app: FastifyInstance) => Promise<void>
57
+ /** Registers the built front-ends; called in production only, after the routes. */
58
+ frontends?: (app: FastifyInstance) => Promise<void>
59
+ /** Takes the lock that keeps a second instance away from the data directory; returns the release. */
60
+ acquireLock: () => () => void
61
+ /** Work that runs only while the application is set up. */
62
+ backgroundServices?: {
63
+ start: () => void
64
+ stop: () => void
65
+ /** Resolves once the work `stop` left running has finished; the shutdown waits for it until its deadline. */
66
+ drain?: () => Promise<void>
67
+ }
68
+ /** Called when the process is asked to stop, before the server drains (close event streams, say). */
69
+ stopIntake?: () => void
70
+ /** How long a stop waits for the server and the background work to drain before it exits anyway. Default 5 seconds. */
71
+ shutdownDeadlineMs?: number
72
+ /** Name used in the line logged when the server is up. */
73
+ logName?: string
74
+ port?: number
75
+ }
76
+
77
+ /**
78
+ * Builds the HTTP application the Better products share: logging that hides credentials, proxy trust, CORS and a
79
+ * cross-site guard, cookies, JWT sessions, rate limiting, uploads, security headers, the database once set up, and the
80
+ * application's routes. `run` then takes the lock, listens, and wires the shutdown that closes everything in order.
81
+ */
82
+ export async function createBetterApp(options: BetterAppOptions) {
83
+ const redaction = { queryParams: options.redactQueryParams, pathTokenPrefixes: options.redactPathTokenPrefixes }
84
+
85
+ const app = Fastify({
86
+ logger: {
87
+ level: 'info',
88
+ redact: {
89
+ paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
90
+ censor: '[redacted]',
91
+ },
92
+ serializers: {
93
+ req: (req) => ({
94
+ method: req.method,
95
+ url: redactUrl(req.url, redaction),
96
+ host: req.host,
97
+ remoteAddress: req.ip,
98
+ ...(req.socket?.remotePort !== undefined ? { remotePort: req.socket.remotePort } : {}),
99
+ }),
100
+ },
101
+ },
102
+ trustProxy: resolveTrustProxy(),
103
+ })
104
+
105
+ app.setErrorHandler(errorHandler)
106
+
107
+ // CORS
108
+ const allowedOrigins = process.env['ALLOWED_ORIGINS']?.split(',').map((o) => o.trim())
109
+ await app.register(cors, {
110
+ origin: allowedOrigins
111
+ ?? (process.env['NODE_ENV'] === 'production' ? false : true),
112
+ credentials: true,
113
+ })
114
+
115
+ await app.register(cookie)
116
+
117
+ // Reject unsafe cross-site browser requests before they reach auth or setup routes.
118
+ app.addHook('onRequest', async (req, reply) => {
119
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return
120
+ if (process.env['NODE_ENV'] !== 'production') return
121
+ if (req.headers['sec-fetch-site'] === 'cross-site') {
122
+ return reply.code(403).send({ error: 'Cross-site request rejected' })
123
+ }
124
+ const origin = req.headers.origin
125
+ if (!origin) return
126
+ const ownOrigin = `${req.protocol}://${req.headers.host}`
127
+ if (origin !== ownOrigin && !allowedOrigins?.includes(origin)) {
128
+ return reply.code(403).send({ error: 'Origin not allowed' })
129
+ }
130
+ })
131
+
132
+ // JWT
133
+ const jwtSecret = resolveJwtSecret()
134
+ options.validate?.()
135
+ await app.register(jwt, {
136
+ secret: jwtSecret,
137
+ sign: { expiresIn: JWT_EXPIRES_IN },
138
+ })
139
+
140
+ // Rate limiting (applied per-route where needed); IPv6 clients are counted per /64
141
+ await app.register(rateLimit, { global: false, keyGenerator: (req) => rateLimitKey(req.ip) })
142
+
143
+ // Multipart (file uploads)
144
+ await app.register(multipart, {
145
+ limits: { fileSize: options.uploads.maxFileBytes },
146
+ })
147
+
148
+ // Serve uploaded files
149
+ if (!fs.existsSync(options.uploads.dir)) {
150
+ fs.mkdirSync(options.uploads.dir, { recursive: true })
151
+ }
152
+ await app.register(staticFiles, {
153
+ root: options.uploads.dir,
154
+ prefix: '/uploads/',
155
+ decorateReply: false,
156
+ })
157
+
158
+ // Security headers
159
+ app.addHook('onSend', (req: FastifyRequest, reply, _payload, done) => {
160
+ const [requestPath = '', query = ''] = req.url.split('?', 2)
161
+ const framed = options.allowSameOriginFraming?.({ path: requestPath, query: new URLSearchParams(query) }) ?? false
162
+ reply.header('X-Content-Type-Options', 'nosniff')
163
+ reply.header('X-Frame-Options', framed ? 'SAMEORIGIN' : 'DENY')
164
+ reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
165
+ reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
166
+ reply.header('Cross-Origin-Opener-Policy', 'same-origin')
167
+ reply.header('Content-Security-Policy', [
168
+ "default-src 'self'",
169
+ "base-uri 'self'",
170
+ "object-src 'none'",
171
+ framed ? "frame-ancestors 'self'" : "frame-ancestors 'none'",
172
+ "form-action 'self'",
173
+ "script-src 'self'",
174
+ "style-src 'self' 'unsafe-inline'",
175
+ "font-src 'self'",
176
+ "img-src 'self' data: blob:",
177
+ "connect-src 'self'",
178
+ ].join('; '))
179
+ if (process.env['NODE_ENV'] === 'production') {
180
+ reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
181
+ }
182
+ done()
183
+ })
184
+
185
+ // Initialize DB only when setup has been completed
186
+ if (options.database.isReady()) {
187
+ options.database.open()
188
+ options.database.migrate()
189
+ }
190
+
191
+ await options.routes(app)
192
+
193
+ // Serve built frontend apps in production
194
+ if (process.env['NODE_ENV'] === 'production') await options.frontends?.(app)
195
+
196
+ /** Takes the lock, listens, and makes the process stop cleanly on SIGTERM and SIGINT. */
197
+ async function run(): Promise<void> {
198
+ const port = options.port ?? Number(process.env['PORT'] ?? 3000)
199
+ const releaseLock = options.acquireLock()
200
+ const runtime = createRuntimeShutdown({
201
+ stopIntake: () => {
202
+ options.stopIntake?.()
203
+ options.backgroundServices?.stop()
204
+ },
205
+ closeServer: () => app.close(),
206
+ ...(options.backgroundServices?.drain ? { drain: options.backgroundServices.drain } : {}),
207
+ ...(options.shutdownDeadlineMs !== undefined ? { forceExitMs: options.shutdownDeadlineMs } : {}),
208
+ releaseResources: () => {
209
+ options.database.close()
210
+ releaseLock()
211
+ },
212
+ exit: (code) => process.exit(code),
213
+ })
214
+ // onClose runs after the HTTP server has drained, so the DB is no longer in use.
215
+ app.addHook('onClose', async () => {
216
+ runtime.cleanup()
217
+ })
218
+ try {
219
+ await app.listen({ port, host: '0.0.0.0' })
220
+ } catch (error) {
221
+ runtime.cleanup()
222
+ throw error
223
+ }
224
+ console.log(`✓ ${options.logName ?? 'API'} running on http://localhost:${port}`)
225
+
226
+ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
227
+ process.once(signal, runtime.shutdown)
228
+ }
229
+
230
+ if (options.database.isReady()) options.backgroundServices?.start()
231
+ }
232
+
233
+ return { app, run }
234
+ }
235
+
236
+ export type BetterApp = Awaited<ReturnType<typeof createBetterApp>>
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ type Env = NodeJS.ProcessEnv
5
+
6
+ /** Where the application keeps its data: `DATA_DIR`, else `./data` under the working directory. */
7
+ export function dataDir(env: Env = process.env): string {
8
+ return path.resolve(env['DATA_DIR'] ?? path.join(process.cwd(), 'data'))
9
+ }
10
+
11
+ /** The SQLite file: `DATABASE_PATH`, else `db.sqlite` in the data directory. */
12
+ export function databasePath(env: Env = process.env): string {
13
+ return path.resolve(env['DATABASE_PATH'] ?? path.join(dataDir(env), 'db.sqlite'))
14
+ }
15
+
16
+ /** The file that records the first-run setup was completed: `SETUP_CONFIG_PATH`, else `setup.json` in the data directory. */
17
+ export function setupConfigPath(env: Env = process.env): string {
18
+ return path.resolve(env['SETUP_CONFIG_PATH'] ?? path.join(dataDir(env), 'setup.json'))
19
+ }
20
+
21
+ /** Where backups are written: `BACKUP_DIR`, else `backups` in the data directory. */
22
+ export function backupDir(env: Env = process.env): string {
23
+ return path.resolve(env['BACKUP_DIR'] ?? path.join(dataDir(env), 'backups'))
24
+ }
25
+
26
+ interface SetupConfig {
27
+ setupComplete: boolean
28
+ dbType: 'sqlite'
29
+ }
30
+
31
+ export function isSetupComplete(env: Env = process.env): boolean {
32
+ try {
33
+ const file = setupConfigPath(env)
34
+ if (!fs.existsSync(file)) return false
35
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')) as SetupConfig
36
+ return cfg.setupComplete === true
37
+ } catch {
38
+ // A marker that exists but cannot be read is not a fresh install: refuse setup rather than reopen it.
39
+ return true
40
+ }
41
+ }
42
+
43
+ export function writeSetupComplete(dbType: 'sqlite' = 'sqlite', env: Env = process.env): void {
44
+ const file = setupConfigPath(env)
45
+ const dir = path.dirname(file)
46
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
47
+ fs.writeFileSync(file, JSON.stringify({ setupComplete: true, dbType }, null, 2))
48
+ }
@@ -0,0 +1,15 @@
1
+ import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
2
+
3
+ /**
4
+ * Unexpected failures are logged in full but never echoed to the client: driver errors carry the
5
+ * SQL and its parameters, which can include encrypted secrets. Client errors (4xx) keep their
6
+ * message in the `{ error }` shape the admin and status clients read.
7
+ */
8
+ export function errorHandler(error: FastifyError, req: FastifyRequest, reply: FastifyReply) {
9
+ const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500
10
+ if (statusCode >= 500) {
11
+ req.log.error({ err: error }, 'Unhandled request error')
12
+ return reply.code(statusCode).send({ error: 'Internal Server Error' })
13
+ }
14
+ return reply.code(statusCode).send({ error: error.message })
15
+ }
package/src/health.ts ADDED
@@ -0,0 +1,109 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import { sql } from 'drizzle-orm'
3
+ import type { SqliteDatabase } from '@better-forge/db'
4
+ import type { LatestRelease } from './updateCheck.js'
5
+
6
+ /** `GET /health` (the process is up) and `GET /ready` (set up and the database answers), for load balancers and probes. */
7
+ export function createHealthRoutes(options: { db: SqliteDatabase; isSetupComplete: () => boolean }) {
8
+ return async function healthRoutes(app: FastifyInstance) {
9
+ app.get('/health', async () => ({ status: 'ok' }))
10
+
11
+ app.get('/ready', async (_req, reply) => {
12
+ if (!options.isSetupComplete()) {
13
+ return reply.code(503).send({ status: 'not_ready' })
14
+ }
15
+ try {
16
+ await options.db.run(sql`SELECT 1`)
17
+ return { status: 'ready' }
18
+ } catch {
19
+ return reply.code(503).send({ status: 'not_ready' })
20
+ }
21
+ })
22
+ }
23
+ }
24
+
25
+ export type ComponentStatus = 'ok' | 'attention' | 'error' | 'disabled'
26
+
27
+ /** One part of the system the report describes. Its `status` is what the overall status is decided by. */
28
+ export type HealthSection = { status: ComponentStatus } & Record<string, unknown>
29
+
30
+ export interface HealthContributor {
31
+ /** The key of the section in the report. */
32
+ key: string
33
+ /**
34
+ * When true, a section in the `error` status makes the whole report `degraded`; otherwise it is only shown.
35
+ */
36
+ critical: boolean
37
+ /** Builds the section. Called after the database was probed; `now` is the time the report is made for. */
38
+ collect(context: { now: number; databaseOk: boolean }): Promise<HealthSection> | HealthSection
39
+ }
40
+
41
+ export interface SystemHealthReport {
42
+ status: 'healthy' | 'degraded'
43
+ generatedAt: number
44
+ application: {
45
+ status: 'ok'
46
+ version: string
47
+ uptimeSeconds: number
48
+ /** A newer published release, or null when up to date, unknown or the update check is off. */
49
+ update: LatestRelease | null
50
+ }
51
+ database: {
52
+ status: 'ok' | 'error'
53
+ responseMs: number | null
54
+ }
55
+ /** The sections of the contributors, by key. */
56
+ [section: string]: unknown
57
+ }
58
+
59
+ /**
60
+ * The protected report of how the instance is doing: the process, the database, and one section per contributor the
61
+ * application registers (a scheduler, a queue, backups, ...). Exposes state, not raw errors.
62
+ */
63
+ export function createSystemHealth(options: {
64
+ db: SqliteDatabase
65
+ version: () => string
66
+ update: () => LatestRelease | null
67
+ contributors: readonly HealthContributor[]
68
+ }) {
69
+ async function getSystemHealthReport(now = Date.now()): Promise<SystemHealthReport> {
70
+ const databaseStartedAt = Date.now()
71
+ let database: SystemHealthReport['database'] = { status: 'error', responseMs: null }
72
+ try {
73
+ await options.db.run(sql`SELECT 1`)
74
+ database = { status: 'ok', responseMs: Date.now() - databaseStartedAt }
75
+ } catch {
76
+ // The protected report deliberately exposes state, not raw database errors.
77
+ }
78
+
79
+ const sections: Record<string, HealthSection> = {}
80
+ for (const contributor of options.contributors) {
81
+ sections[contributor.key] = await contributor.collect({ now, databaseOk: database.status === 'ok' })
82
+ }
83
+ const failing = options.contributors.some((contributor) => contributor.critical && sections[contributor.key]!.status === 'error')
84
+
85
+ return {
86
+ status: database.status === 'ok' && !failing ? 'healthy' : 'degraded',
87
+ generatedAt: now,
88
+ application: { status: 'ok', version: options.version(), uptimeSeconds: Math.floor(process.uptime()), update: options.update() },
89
+ database,
90
+ ...sections,
91
+ }
92
+ }
93
+
94
+ /** Routes: the report at `/` and, at `/update`, only the running version and a newer release, if any. */
95
+ async function systemHealthRoutes(app: FastifyInstance) {
96
+ app.get('/', async (_request, reply) => {
97
+ reply.header('Cache-Control', 'no-store')
98
+ return getSystemHealthReport()
99
+ })
100
+
101
+ /** Cheap enough for a dashboard to ask on every visit. */
102
+ app.get('/update', async (_request, reply) => {
103
+ reply.header('Cache-Control', 'no-store')
104
+ return { version: options.version(), update: options.update() }
105
+ })
106
+ }
107
+
108
+ return { getSystemHealthReport, systemHealthRoutes }
109
+ }