@better-forge/runtime 0.1.0 → 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 -21
- package/README.md +9 -9
- package/package.json +65 -65
- package/src/appLock.ts +63 -63
- package/src/createBetterApp.ts +236 -236
- package/src/dataPaths.ts +48 -48
- package/src/errorHandler.ts +15 -15
- package/src/health.ts +109 -109
- package/src/index.ts +14 -14
- package/src/processLiveness.ts +31 -31
- package/src/proxy.ts +20 -20
- package/src/publicUrl.ts +26 -26
- package/src/rateLimitKey.ts +20 -20
- package/src/redactUrl.ts +49 -49
- package/src/shutdown.ts +62 -62
- package/src/sse.ts +154 -154
- package/src/updateCheck.ts +107 -107
- package/src/version.ts +7 -7
package/src/shutdown.ts
CHANGED
|
@@ -1,62 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,154 +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
|
-
}
|
|
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
|
+
}
|
package/src/updateCheck.ts
CHANGED
|
@@ -1,107 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,7 +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
|
-
}
|
|
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
|
+
}
|