@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
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { backupDir, dataDir, databasePath, isSetupComplete, setupConfigPath, writeSetupComplete } from './dataPaths.js'
2
+ export { resolveTrustProxy, type TrustProxy } from './proxy.js'
3
+ export { rateLimitKey } from './rateLimitKey.js'
4
+ export { createPublicUrlResolver } from './publicUrl.js'
5
+ export { errorHandler } from './errorHandler.js'
6
+ export { createAppLock } from './appLock.js'
7
+ export { isOwnerAlive, isProcessAlive, currentProcessOwner, PROCESS_STARTED_AT, type ProcessOwner } from './processLiveness.js'
8
+ export { createRuntimeShutdown, type RuntimeShutdown, type ShutdownSteps } from './shutdown.js'
9
+ export { createSse, type ClientInfo, type EventStreamOptions, type SseFilter, type SseLimits } from './sse.js'
10
+ export { createUpdateCheck, isNewerVersion, updateCheckEnabled, type LatestRelease } from './updateCheck.js'
11
+ export { createHealthRoutes, createSystemHealth, type ComponentStatus, type HealthContributor, type HealthSection, type SystemHealthReport } from './health.js'
12
+ export { resolveAppVersion } from './version.js'
13
+ export { createBetterApp, JWT_EXPIRES_IN, resolveJwtSecret, type BetterApp, type BetterAppOptions } from './createBetterApp.js'
14
+ export { redactUrl, type UrlRedaction } from './redactUrl.js'
@@ -0,0 +1,31 @@
1
+ /** Wall-clock start of this process; identical in the main thread and in worker threads. */
2
+ export const PROCESS_STARTED_AT = Math.round(Date.now() - process.uptime() * 1000)
3
+
4
+ /** Signal 0 checks existence only; EPERM still means the process exists under another user. */
5
+ export function isProcessAlive(pid: number): boolean {
6
+ if (!Number.isInteger(pid) || pid <= 0) return false
7
+ try {
8
+ process.kill(pid, 0)
9
+ return true
10
+ } catch (error) {
11
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
12
+ }
13
+ }
14
+
15
+ export interface ProcessOwner { pid: number; processStartedAt: number }
16
+
17
+ export function currentProcessOwner(): ProcessOwner {
18
+ return { pid: process.pid, processStartedAt: PROCESS_STARTED_AT }
19
+ }
20
+
21
+ /**
22
+ * Whether the recorded owner may still be running. The start time distinguishes a crashed
23
+ * earlier incarnation that reused our pid (containers restart as the same pid) from us.
24
+ */
25
+ export function isOwnerAlive(owner: Partial<ProcessOwner>): boolean {
26
+ if (typeof owner.pid !== 'number') return false
27
+ if (owner.pid === process.pid) {
28
+ return typeof owner.processStartedAt === 'number' && Math.abs(owner.processStartedAt - PROCESS_STARTED_AT) < 1_000
29
+ }
30
+ return isProcessAlive(owner.pid)
31
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { FastifyServerOptions } from 'fastify'
2
+
3
+ export type TrustProxy = NonNullable<FastifyServerOptions['trustProxy']>
4
+
5
+ export function resolveTrustProxy(raw = process.env['TRUST_PROXY']): TrustProxy {
6
+ const value = raw?.trim()
7
+ if (!value || value.toLowerCase() === 'false') return false
8
+ if (value.toLowerCase() === 'true') return true
9
+ if (/^\d+$/.test(value)) {
10
+ // Hop-count-only trust can't validate the immediate peer, so @fastify/proxy-addr
11
+ // no longer supports it (it always fails closed). Treat a numeric value as "trust a
12
+ // proxy on this host or on a private network": loopback covers a proxy on the same
13
+ // host, uniquelocal (10/8, 172.16/12, 192.168/16, fc00::/7) covers one reaching the
14
+ // app over a Docker bridge network. Use an explicit address/CIDR list to narrow it.
15
+ return ['loopback', 'uniquelocal']
16
+ }
17
+ const addresses = value.split(',').map((address) => address.trim()).filter(Boolean)
18
+ if (addresses.length === 0) return false
19
+ return addresses.length === 1 ? addresses[0]! : addresses
20
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The absolute base URL the application is reached at, from the `PUBLIC_URL` environment variable.
3
+ *
4
+ * It is deployment configuration, not an admin setting: links in emails and redirects are built from it, so changing
5
+ * it must require access to the server — an account in the admin console alone must not be able to point those links
6
+ * at another domain.
7
+ *
8
+ * The returned function reports an invalid value once per distinct value, then keeps answering ''.
9
+ */
10
+ export function createPublicUrlResolver() {
11
+ let warnedAbout: string | null = null
12
+
13
+ return function resolvePublicUrl(env: NodeJS.ProcessEnv = process.env): string {
14
+ const raw = (env['PUBLIC_URL'] ?? '').trim().replace(/\/+$/, '')
15
+ if (!raw) return ''
16
+ try {
17
+ const url = new URL(raw)
18
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && !url.search && !url.hash) return raw
19
+ } catch { /* reported below */ }
20
+ if (warnedAbout !== raw) {
21
+ warnedAbout = raw
22
+ console.warn(`PUBLIC_URL "${raw}" is not an absolute http(s) URL without query or fragment — ignoring it`)
23
+ }
24
+ return ''
25
+ }
26
+ }
@@ -0,0 +1,20 @@
1
+ import { isIPv6 } from 'node:net'
2
+
3
+ /**
4
+ * The key a client is rate limited under. An IPv4 address is its own key; an IPv6 address is
5
+ * reduced to its /64, because one subscriber is routinely handed a whole /64 and could otherwise
6
+ * rotate through billions of addresses to dodge a per-address limit.
7
+ */
8
+ export function rateLimitKey(ip: string): string {
9
+ const address = ip.split('%')[0] ?? ip
10
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)
11
+ if (mapped) return mapped[1]!
12
+ if (!isIPv6(address)) return address
13
+
14
+ const [head = '', tail] = address.split('::')
15
+ const front = head ? head.split(':') : []
16
+ const back = tail ? tail.split(':') : []
17
+ const groups = tail === undefined ? front : [...front, ...Array<string>(8 - front.length - back.length).fill('0'), ...back]
18
+ const prefix = groups.slice(0, 4).map((group) => parseInt(group, 16).toString(16))
19
+ return `${prefix.join(':')}::/64`
20
+ }
@@ -0,0 +1,49 @@
1
+ export interface UrlRedaction {
2
+ /** Query parameters whose value is replaced. Names are compared after decoding, ignoring case. */
3
+ queryParams?: readonly string[] | undefined
4
+ /** Paths whose next segment is a capability token (`/t/` hides `abc` in `/t/abc?x=1`), matched after percent-decoding as the router does. */
5
+ pathTokenPrefixes?: readonly string[] | undefined
6
+ }
7
+
8
+ const REDACTED = '[redacted]'
9
+
10
+ function decodeName(name: string): string {
11
+ try { return decodeURIComponent(name.replace(/\+/g, ' ')) } catch { return name }
12
+ }
13
+
14
+ /**
15
+ * The router decodes the path before it matches a route (`/%68ook/` reaches `/hook/`), but keeps an encoded slash.
16
+ * Each segment is decoded on its own, so one malformed sequence leaves only that segment as it was sent.
17
+ */
18
+ function decodePath(path: string): string {
19
+ return path.split('/').map((segment) => {
20
+ try { return decodeURI(segment) } catch { return segment }
21
+ }).join('/')
22
+ }
23
+
24
+ /** The URL as it may be logged: capability tokens in the path and the values of secret query parameters are hidden. */
25
+ export function redactUrl(url: string, rules: UrlRedaction): string {
26
+ const queryAt = url.indexOf('?')
27
+ let path = queryAt === -1 ? url : url.slice(0, queryAt)
28
+ let query = queryAt === -1 ? '' : url.slice(queryAt + 1)
29
+
30
+ const decoded = decodePath(path)
31
+ for (const prefix of rules.pathTokenPrefixes ?? []) {
32
+ // Everything from the token on is dropped: the rest of a path is not decoded text a log should repeat.
33
+ if (decoded.startsWith(prefix) && decoded.length > prefix.length) {
34
+ path = `${prefix}${REDACTED}`
35
+ break
36
+ }
37
+ }
38
+
39
+ const names = (rules.queryParams ?? []).map((name) => name.toLowerCase())
40
+ if (names.length > 0 && query) {
41
+ query = query.split('&').map((pair) => {
42
+ const equals = pair.indexOf('=')
43
+ if (equals === -1) return pair
44
+ return names.includes(decodeName(pair.slice(0, equals)).toLowerCase()) ? `${pair.slice(0, equals + 1)}${REDACTED}` : pair
45
+ }).join('&')
46
+ }
47
+
48
+ return queryAt === -1 ? path : `${path}?${query}`
49
+ }
@@ -0,0 +1,62 @@
1
+ export interface ShutdownSteps {
2
+ /** Stops producing new work and ends long-lived streams (SSE) so the server can drain. */
3
+ stopIntake: () => void
4
+ /** Stops accepting connections and resolves once in-flight requests have finished. */
5
+ closeServer: () => Promise<void>
6
+ /** Resolves once background work that started before the stop (a check about to record its result, say) has finished. */
7
+ drain?: () => Promise<void>
8
+ /** Closes the DB and releases the app lock — only safe once no request or background work can still use them. */
9
+ releaseResources: () => void
10
+ exit: (code: number) => void
11
+ forceExitMs?: number
12
+ }
13
+
14
+ export interface RuntimeShutdown {
15
+ /** Immediate full teardown, for paths where the server never started or has already closed. */
16
+ cleanup: () => void
17
+ /** Signal handler: drain the server and the background work first, then release shared resources and exit. */
18
+ shutdown: () => void
19
+ }
20
+
21
+ export function createRuntimeShutdown(steps: ShutdownSteps): RuntimeShutdown {
22
+ let intakeStopped = false
23
+ let released = false
24
+ let shuttingDown = false
25
+ let forceExit: ReturnType<typeof setTimeout> | undefined
26
+ const stopIntake = () => {
27
+ if (intakeStopped) return
28
+ intakeStopped = true
29
+ steps.stopIntake()
30
+ }
31
+ const release = () => {
32
+ if (released) return
33
+ released = true
34
+ steps.releaseResources()
35
+ }
36
+ const cleanup = () => {
37
+ stopIntake()
38
+ // A signal-driven shutdown releases after the drain; the server's close hook fires before it.
39
+ if (!shuttingDown) release()
40
+ }
41
+ const finish = (code: number) => {
42
+ clearTimeout(forceExit)
43
+ release()
44
+ steps.exit(code)
45
+ }
46
+
47
+ return {
48
+ cleanup,
49
+ shutdown: () => {
50
+ if (shuttingDown) return
51
+ shuttingDown = true
52
+ forceExit = setTimeout(() => {
53
+ release()
54
+ steps.exit(1)
55
+ }, steps.forceExitMs ?? 5_000)
56
+ forceExit.unref()
57
+ stopIntake()
58
+ // The forced exit above is the deadline of the drain: work still running by then is left to the next start.
59
+ steps.closeServer().then(() => steps.drain?.()).then(() => finish(0), () => finish(1))
60
+ },
61
+ }
62
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,154 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify'
2
+ import { rateLimitKey } from './rateLimitKey.js'
3
+
4
+ type SseClient = FastifyReply
5
+
6
+ /**
7
+ * Shapes what one client receives: return the data to send (possibly reduced) or `null` to skip the
8
+ * event. Clients without a filter receive everything as broadcast.
9
+ */
10
+ export type SseFilter = (event: string, data: unknown) => { data: unknown } | null
11
+
12
+ export interface ClientInfo {
13
+ filter?: SseFilter | undefined
14
+ /** Set for signed-in streams, so revoking the session can end them. */
15
+ session?: { sessionId: string; userId: number } | undefined
16
+ ip?: string | undefined
17
+ }
18
+
19
+ export interface SseLimits {
20
+ /** Open anonymous streams in total; further ones are refused with 429. Signed-in streams are not counted. */
21
+ maxClients: number
22
+ /** Open anonymous streams from one address (an IPv6 /64 counts as one); further ones are refused with 429. */
23
+ maxClientsPerIp: number
24
+ /** A client with more unsent bytes than this is not reading and gets disconnected. */
25
+ maxBufferedBytes: number
26
+ }
27
+
28
+ const DEFAULT_LIMITS: SseLimits = { maxClients: 1000, maxClientsPerIp: 10, maxBufferedBytes: 1024 * 1024 }
29
+
30
+ export interface EventStreamOptions {
31
+ filter?: SseFilter
32
+ session?: { sessionId: string; userId: number } | undefined
33
+ /** Re-checked with every keep-alive ping; the stream ends once it resolves to false. */
34
+ stillAllowed?: () => Promise<boolean>
35
+ pingIntervalMs?: number
36
+ }
37
+
38
+ /** Live event streams (server-sent events) to browsers, with per-client filters and session-bound streams. All state is in the returned closure. */
39
+ export function createSse(limitOptions: Partial<SseLimits> = {}) {
40
+ const limits = { ...DEFAULT_LIMITS, ...limitOptions }
41
+ const clients = new Map<SseClient, ClientInfo>()
42
+
43
+ const listeners = new Set<(event: string) => void>()
44
+
45
+ function end(client: SseClient) {
46
+ try { client.raw.end() } catch { /* ignore shutdown races */ }
47
+ try { client.raw.destroy() } catch { /* ignore shutdown races */ }
48
+ clients.delete(client)
49
+ }
50
+
51
+ /** Writes to one client and drops it when it cannot keep up, so a stalled reader cannot grow the heap. */
52
+ function send(client: SseClient, chunk: string) {
53
+ try {
54
+ client.raw.write(chunk)
55
+ if (client.raw.writableLength > limits.maxBufferedBytes) end(client)
56
+ } catch {
57
+ clients.delete(client)
58
+ }
59
+ }
60
+
61
+ const sseService = {
62
+ add(client: SseClient, info: ClientInfo = {}) {
63
+ clients.set(client, info)
64
+ },
65
+
66
+ remove(client: SseClient) {
67
+ clients.delete(client)
68
+ },
69
+
70
+ /** Server-side hook for anything that must react to the same events clients receive. */
71
+ onBroadcast(listener: (event: string) => void): () => void {
72
+ listeners.add(listener)
73
+ return () => { listeners.delete(listener) }
74
+ },
75
+
76
+ broadcast(event: string, data: unknown) {
77
+ for (const listener of listeners) listener(event)
78
+ const format = (value: unknown) => `event: ${event}\ndata: ${JSON.stringify(value)}\n\n`
79
+ const payload = format(data)
80
+ for (const [client, { filter }] of clients) {
81
+ const shaped = filter ? filter(event, data) : { data }
82
+ if (!shaped) continue
83
+ send(client, shaped.data === data ? payload : format(shaped.data))
84
+ }
85
+ },
86
+
87
+ /** Ends the signed-in streams whose session matches, e.g. after the session was revoked. */
88
+ disconnectSessions(match: (session: { sessionId: string; userId: number }) => boolean) {
89
+ for (const [client, { session }] of clients) {
90
+ if (session && match(session)) end(client)
91
+ }
92
+ },
93
+
94
+ /** Ends every stream opened without signing in, e.g. once access is restricted to signed-in users. */
95
+ disconnectAnonymous() {
96
+ for (const [client, { session }] of clients) {
97
+ if (!session) end(client)
98
+ }
99
+ },
100
+
101
+ closeAll() {
102
+ for (const client of clients.keys()) end(client)
103
+ },
104
+
105
+ clientCount() {
106
+ return clients.size
107
+ },
108
+ }
109
+
110
+ /** Holds the request open as an event stream until the client disconnects or loses access. */
111
+ async function serveEventStream(req: FastifyRequest, reply: FastifyReply, options: EventStreamOptions = {}): Promise<void> {
112
+ // Only anonymous streams are limited: a signed-in one is accountable, and counting it would let
113
+ // visitors fill the budget the admin stream needs.
114
+ const ip = rateLimitKey(req.ip)
115
+ if (!options.session) {
116
+ let total = 0
117
+ let fromIp = 0
118
+ for (const client of clients.values()) {
119
+ if (client.session) continue
120
+ total++
121
+ if (client.ip === ip) fromIp++
122
+ }
123
+ if (total >= limits.maxClients || fromIp >= limits.maxClientsPerIp) {
124
+ return reply.code(429).send({ error: 'Too many open event streams' })
125
+ }
126
+ }
127
+
128
+ reply.raw.setHeader('Content-Type', 'text/event-stream')
129
+ reply.raw.setHeader('Cache-Control', 'no-cache')
130
+ reply.raw.setHeader('Connection', 'keep-alive')
131
+ reply.raw.setHeader('X-Accel-Buffering', 'no')
132
+ reply.raw.flushHeaders()
133
+
134
+ sseService.add(reply, { filter: options.filter, session: options.session, ip })
135
+ reply.raw.write('event: ping\ndata: {}\n\n')
136
+
137
+ const pingInterval = setInterval(() => {
138
+ void (async () => {
139
+ if (options.stillAllowed && !(await options.stillAllowed().catch(() => false))) {
140
+ clearInterval(pingInterval)
141
+ end(reply)
142
+ return
143
+ }
144
+ send(reply, `event: ping\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`)
145
+ if (!clients.has(reply)) clearInterval(pingInterval)
146
+ })()
147
+ }, options.pingIntervalMs ?? 30_000)
148
+
149
+ req.raw.on('close', () => { clearInterval(pingInterval); sseService.remove(reply) })
150
+ await new Promise<void>((resolve) => { req.raw.on('close', resolve) })
151
+ }
152
+
153
+ return { sseService, serveEventStream }
154
+ }
@@ -0,0 +1,107 @@
1
+
2
+
3
+ const REFRESH_MS = 6 * 60 * 60 * 1000
4
+
5
+ const RETRY_MS = 5 * 60 * 1000
6
+
7
+ const TIMEOUT_MS = 5_000
8
+
9
+ export interface LatestRelease {
10
+ version: string
11
+ url: string
12
+ }
13
+
14
+ /** `UPDATE_CHECK=false` keeps the instance from contacting GitHub (air-gapped or privacy-minded installs). */
15
+ export function updateCheckEnabled(environment: { UPDATE_CHECK?: string | undefined } = process.env): boolean {
16
+ return environment.UPDATE_CHECK?.trim().toLowerCase() !== 'false'
17
+ }
18
+
19
+ function parseVersion(value: string): [number, number, number] | null {
20
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(value.trim())
21
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
22
+ }
23
+
24
+ /** True when `candidate` is a higher x.y.z than `current`; false when either is not a plain release version. */
25
+ export function isNewerVersion(candidate: string, current: string): boolean {
26
+ const a = parseVersion(candidate)
27
+ const b = parseVersion(current)
28
+ if (!a || !b) return false
29
+ for (let i = 0; i < 3; i++) {
30
+ if (a[i]! !== b[i]!) return a[i]! > b[i]!
31
+ }
32
+ return false
33
+ }
34
+
35
+ /**
36
+ * Asks GitHub which release of `repository` ("owner/name") is newest and remembers it, to point out an update to
37
+ * whoever runs an older `version()`. All state is in the returned closure.
38
+ */
39
+ export function createUpdateCheck(options: { name: string; repository: string; version: () => string }) {
40
+ const RELEASES_API = `https://api.github.com/repos/${options.repository}/releases/latest`
41
+ const resolveAppVersion = options.version
42
+
43
+ let latest: LatestRelease | null = null
44
+
45
+ let timer: NodeJS.Timeout | undefined
46
+
47
+ /** Bumped by every start and stop, so a check still in flight when the service stops cannot schedule another. */
48
+ let generation = 0
49
+
50
+ /** The newest published release, null when there is none a user should be pointed at; throws when GitHub cannot be asked. */
51
+ async function fetchLatestRelease(): Promise<LatestRelease | null> {
52
+ const response = await fetch(RELEASES_API, {
53
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': `${options.name}/${resolveAppVersion()}` },
54
+ signal: AbortSignal.timeout(TIMEOUT_MS),
55
+ })
56
+ if (!response.ok) throw new Error(`GitHub answered ${response.status}`)
57
+ const body: unknown = await response.json()
58
+ if (typeof body !== 'object' || body === null) return null
59
+ const { tag_name: tag, html_url: url, draft, prerelease } = body as Record<string, unknown>
60
+ if (typeof tag !== 'string' || typeof url !== 'string' || draft === true || prerelease === true) return null
61
+ if (!parseVersion(tag) || !url.startsWith(`https://github.com/${options.repository}/`)) return null
62
+ return { version: tag.replace(/^v/, ''), url }
63
+ }
64
+
65
+ /** Asks GitHub once and remembers the answer; true when it could be asked. Never throws. */
66
+ async function checkForUpdate(): Promise<boolean> {
67
+ try {
68
+ latest = await fetchLatestRelease()
69
+ return true
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ /** Checks now, in the background, then every few hours (sooner after a failure). A no-op when `UPDATE_CHECK=false`. */
76
+ function startUpdateCheck(): void {
77
+ stopUpdateCheck()
78
+ if (!updateCheckEnabled()) return
79
+ const mine = generation
80
+ const run = async () => {
81
+ const asked = await checkForUpdate()
82
+ if (mine !== generation) return
83
+ timer = setTimeout(() => void run(), asked ? REFRESH_MS : RETRY_MS)
84
+ timer.unref()
85
+ }
86
+ void run()
87
+ }
88
+
89
+ function stopUpdateCheck(): void {
90
+ generation++
91
+ clearTimeout(timer)
92
+ timer = undefined
93
+ }
94
+
95
+ /** The newest release when it is newer than the running version, else null. Reads what the background check found. */
96
+ function getAvailableUpdate(): LatestRelease | null {
97
+ if (!updateCheckEnabled() || !latest) return null
98
+ return isNewerVersion(latest.version, resolveAppVersion()) ? latest : null
99
+ }
100
+
101
+ function resetUpdateCheck(): void {
102
+ stopUpdateCheck()
103
+ latest = null
104
+ }
105
+
106
+ return { checkForUpdate, startUpdateCheck, stopUpdateCheck, getAvailableUpdate, resetUpdateCheck }
107
+ }
package/src/version.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** The running version: the `APP_VERSION` environment variable (set by the image build), else the version in the package. */
2
+ export function resolveAppVersion(
3
+ packageVersion: string,
4
+ environment: { APP_VERSION?: string | undefined } = process.env,
5
+ ): string {
6
+ return environment.APP_VERSION?.trim() || packageVersion
7
+ }