@better-forge/runtime 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +8 -2
- package/dist/appLock.d.ts +13 -0
- package/dist/appLock.d.ts.map +1 -0
- package/dist/appLock.js +67 -0
- package/dist/appLock.js.map +1 -0
- package/dist/createBetterApp.d.ts +63 -0
- package/dist/createBetterApp.d.ts.map +1 -0
- package/dist/createBetterApp.js +181 -0
- package/dist/createBetterApp.js.map +1 -0
- package/dist/dataPaths.d.ts +13 -0
- package/dist/dataPaths.d.ts.map +1 -0
- package/dist/dataPaths.js +39 -0
- package/dist/dataPaths.js.map +1 -0
- package/dist/errorHandler.d.ts +8 -0
- package/dist/errorHandler.d.ts.map +1 -0
- package/dist/errorHandler.js +14 -0
- package/dist/errorHandler.js.map +1 -0
- package/dist/health.d.ts +57 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +62 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/processLiveness.d.ts +15 -0
- package/dist/processLiveness.d.ts.map +1 -0
- package/dist/processLiveness.js +30 -0
- package/dist/processLiveness.js.map +1 -0
- package/dist/proxy.d.ts +4 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +20 -0
- package/dist/proxy.js.map +1 -0
- package/dist/publicUrl.d.ts +11 -0
- package/dist/publicUrl.d.ts.map +1 -0
- package/dist/publicUrl.js +29 -0
- package/dist/publicUrl.js.map +1 -0
- package/dist/rateLimitKey.d.ts +7 -0
- package/dist/rateLimitKey.d.ts.map +1 -0
- package/dist/rateLimitKey.js +21 -0
- package/dist/rateLimitKey.js.map +1 -0
- package/dist/redactUrl.d.ts +9 -0
- package/dist/redactUrl.d.ts.map +1 -0
- package/dist/redactUrl.js +48 -0
- package/dist/redactUrl.js.map +1 -0
- package/dist/shutdown.d.ts +20 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +46 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/sse.d.ts +58 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +119 -0
- package/dist/sse.js.map +1 -0
- package/dist/updateCheck.d.ts +26 -0
- package/dist/updateCheck.d.ts.map +1 -0
- package/dist/updateCheck.js +95 -0
- package/dist/updateCheck.js.map +1 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +63 -4
- package/src/appLock.ts +63 -0
- package/src/createBetterApp.ts +236 -0
- package/src/dataPaths.ts +48 -0
- package/src/errorHandler.ts +15 -0
- package/src/health.ts +109 -0
- package/src/index.ts +14 -0
- package/src/processLiveness.ts +31 -0
- package/src/proxy.ts +20 -0
- package/src/publicUrl.ts +26 -0
- package/src/rateLimitKey.ts +20 -0
- package/src/redactUrl.ts +49 -0
- package/src/shutdown.ts +62 -0
- package/src/sse.ts +154 -0
- package/src/updateCheck.ts +107 -0
- 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"}
|
package/dist/version.js
ADDED
|
@@ -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
1
|
{
|
|
2
2
|
"name": "@better-forge/runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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.1"
|
|
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>>
|
package/src/dataPaths.ts
ADDED
|
@@ -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
|
+
}
|