flightdeck 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ import { execSync, spawn } from "node:child_process"
2
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs"
3
+ import type { Server } from "node:http"
4
+ import { createServer } from "node:http"
5
+ import { homedir } from "node:os"
6
+ import { resolve } from "node:path"
7
+
8
+ import { Future } from "atom.io/internal"
9
+ import { fromEntries, toEntries } from "atom.io/json"
10
+ import { ChildSocket } from "atom.io/realtime-server"
11
+
12
+ export type FlightDeckOptions<S extends string = string> = {
13
+ secret: string
14
+ packageName: string
15
+ services: { [service in S]: { run: string[]; waitFor: boolean } }
16
+ downloadPackageToUpdatesCmd: string[]
17
+ flightdeckRootDir?: string | undefined
18
+ }
19
+
20
+ const PORT = process.env.PORT ?? 8080
21
+ const ORIGIN = `http://localhost:${PORT}`
22
+ export class FlightDeck<S extends string = string> {
23
+ protected safety = 0
24
+
25
+ protected webhookServer: Server
26
+ protected services: {
27
+ [service in S]: ChildSocket<
28
+ { updatesReady: [] },
29
+ { readyToUpdate: []; alive: [] }
30
+ > | null
31
+ }
32
+ protected serviceIdx: { readonly [service in S]: number }
33
+ public defaultServicesReadyToUpdate: { readonly [service in S]: boolean }
34
+ public servicesReadyToUpdate: { [service in S]: boolean }
35
+ public servicesShouldRestart: boolean
36
+
37
+ protected logger: Pick<Console, `error` | `info` | `warn`>
38
+ protected serviceLoggers: {
39
+ readonly [service in S]: Pick<Console, `error` | `info` | `warn`>
40
+ }
41
+
42
+ public servicesLive: Future<void>[]
43
+ public servicesDead: Future<void>[]
44
+ public live = new Future(() => {})
45
+ public dead = new Future(() => {})
46
+
47
+ protected restartTimes: number[] = []
48
+
49
+ public readonly currentServiceDir: string
50
+ public readonly updateServiceDir: string
51
+ public readonly backupServiceDir: string
52
+
53
+ public constructor(public readonly options: FlightDeckOptions<S>) {
54
+ const { secret, flightdeckRootDir = resolve(homedir(), `services`) } =
55
+ options
56
+
57
+ const servicesEntries = toEntries(options.services)
58
+ this.services = fromEntries(
59
+ servicesEntries.map(([serviceName]) => [serviceName, null]),
60
+ )
61
+ this.serviceIdx = fromEntries(
62
+ servicesEntries.map(([serviceName], idx) => [serviceName, idx]),
63
+ )
64
+ this.defaultServicesReadyToUpdate = fromEntries(
65
+ servicesEntries.map(([serviceName, { waitFor }]) => [
66
+ serviceName,
67
+ !waitFor,
68
+ ]),
69
+ )
70
+ this.servicesReadyToUpdate = { ...this.defaultServicesReadyToUpdate }
71
+ this.servicesShouldRestart = true
72
+
73
+ this.logger = {
74
+ info: (...args: any[]) => {
75
+ console.log(`${this.options.packageName}:`, ...args)
76
+ },
77
+ warn: (...args: any[]) => {
78
+ console.warn(`${this.options.packageName}:`, ...args)
79
+ },
80
+ error: (...args: any[]) => {
81
+ console.error(`${this.options.packageName}:`, ...args)
82
+ },
83
+ }
84
+ this.serviceLoggers = fromEntries(
85
+ servicesEntries.map(([serviceName]) => [
86
+ serviceName,
87
+ {
88
+ info: (...args: any[]) => {
89
+ console.log(`${this.options.packageName}::${serviceName}:`, ...args)
90
+ },
91
+ warn: (...args: any[]) => {
92
+ console.warn(`${this.options.packageName}::${serviceName}:`, ...args)
93
+ },
94
+ error: (...args: any[]) => {
95
+ console.error(
96
+ `${this.options.packageName}::${serviceName}:`,
97
+ ...args,
98
+ )
99
+ },
100
+ },
101
+ ]),
102
+ )
103
+
104
+ this.servicesLive = servicesEntries.map(() => new Future(() => {}))
105
+ this.servicesDead = servicesEntries.map(() => new Future(() => {}))
106
+ this.live.use(Promise.all(this.servicesLive))
107
+ this.dead.use(Promise.all(this.servicesDead))
108
+
109
+ this.currentServiceDir = resolve(
110
+ flightdeckRootDir,
111
+ options.packageName,
112
+ `current`,
113
+ )
114
+ this.backupServiceDir = resolve(
115
+ flightdeckRootDir,
116
+ options.packageName,
117
+ `backup`,
118
+ )
119
+ this.updateServiceDir = resolve(
120
+ flightdeckRootDir,
121
+ options.packageName,
122
+ `update`,
123
+ )
124
+
125
+ createServer((req, res) => {
126
+ let data: Uint8Array[] = []
127
+ req
128
+ .on(`data`, (chunk) => {
129
+ data.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk))
130
+ })
131
+ .on(`end`, () => {
132
+ const authHeader = req.headers.authorization
133
+ try {
134
+ if (typeof req.url === `undefined`) throw 400
135
+ if (authHeader !== `Bearer ${secret}`) throw 401
136
+ const url = new URL(req.url, ORIGIN)
137
+ this.logger.info(req.method, url.pathname)
138
+ switch (req.method) {
139
+ case `POST`:
140
+ {
141
+ switch (url.pathname) {
142
+ case `/`:
143
+ {
144
+ res.writeHead(200)
145
+ res.end()
146
+ this.getLatestRelease()
147
+ if (
148
+ toEntries(this.servicesReadyToUpdate).every(
149
+ ([, isReady]) => isReady,
150
+ )
151
+ ) {
152
+ this.logger.info(`All services are ready to update!`)
153
+ this.stopAllServices()
154
+ return
155
+ }
156
+ for (const entry of toEntries(this.services)) {
157
+ const [serviceName, service] = entry
158
+ if (service) {
159
+ if (this.options.services[serviceName].waitFor) {
160
+ service.emit(`updatesReady`)
161
+ }
162
+ } else {
163
+ this.startService(serviceName)
164
+ }
165
+ }
166
+ }
167
+ break
168
+
169
+ default:
170
+ throw 404
171
+ }
172
+ }
173
+ break
174
+
175
+ default:
176
+ throw 405
177
+ }
178
+ } catch (thrown) {
179
+ this.logger.error(thrown, req.url)
180
+ if (typeof thrown === `number`) {
181
+ res.writeHead(thrown)
182
+ res.end()
183
+ }
184
+ } finally {
185
+ data = []
186
+ }
187
+ })
188
+ }).listen(PORT, () => {
189
+ this.logger.info(`Server started on port ${PORT}`)
190
+ })
191
+
192
+ this.startAllServices()
193
+ }
194
+
195
+ protected startAllServices(): void {
196
+ this.logger.info(`Starting all services...`)
197
+ for (const [serviceName] of toEntries(this.services)) {
198
+ this.startService(serviceName)
199
+ }
200
+ }
201
+
202
+ protected startService(serviceName: S): void {
203
+ this.logger.info(
204
+ `Starting service ${this.options.packageName}::${serviceName}, try ${this.safety}/2...`,
205
+ )
206
+ if (this.safety >= 2) {
207
+ throw new Error(`Out of tries...`)
208
+ }
209
+ this.safety++
210
+ if (!existsSync(this.currentServiceDir)) {
211
+ this.logger.info(
212
+ `Tried to start service but failed: could not find ${this.currentServiceDir}`,
213
+ )
214
+ this.getLatestRelease()
215
+ this.applyUpdate()
216
+ this.startService(serviceName)
217
+
218
+ return
219
+ }
220
+
221
+ const [executable, ...args] = this.options.services[serviceName].run
222
+ const program = executable.startsWith(`./`)
223
+ ? resolve(this.currentServiceDir, executable)
224
+ : executable
225
+ const serviceProcess = spawn(program, args, {
226
+ cwd: this.currentServiceDir,
227
+ env: import.meta.env,
228
+ })
229
+ this.services[serviceName] = new ChildSocket(
230
+ serviceProcess,
231
+ `${this.options.packageName}::${serviceName}`,
232
+ console,
233
+ )
234
+ this.services[serviceName].onAny((...messages) => {
235
+ this.logger.info(`💬`, ...messages)
236
+ })
237
+ this.services[serviceName].on(`readyToUpdate`, () => {
238
+ this.serviceLoggers[serviceName].info(`Ready to update.`)
239
+ this.servicesReadyToUpdate[serviceName] = true
240
+ if (
241
+ toEntries(this.servicesReadyToUpdate).every(([, isReady]) => isReady)
242
+ ) {
243
+ this.logger.info(`All services are ready to update.`)
244
+ this.stopAllServices()
245
+ }
246
+ })
247
+ this.services[serviceName].on(`alive`, () => {
248
+ this.servicesLive[this.serviceIdx[serviceName]].use(Promise.resolve())
249
+ this.servicesDead[this.serviceIdx[serviceName]] = new Future(() => {})
250
+ if (this.dead.done) {
251
+ this.dead = new Future(() => {})
252
+ }
253
+ this.dead.use(Promise.all(this.servicesDead))
254
+ })
255
+ this.services[serviceName].process.on(`close`, (exitCode) => {
256
+ this.serviceLoggers[serviceName].info(`Exited with code ${exitCode}`)
257
+ this.services[serviceName] = null
258
+ if (!this.servicesShouldRestart) {
259
+ this.serviceLoggers[serviceName].info(`Will not be restarted.`)
260
+ return
261
+ }
262
+ const updatesAreReady = existsSync(this.updateServiceDir)
263
+ if (updatesAreReady) {
264
+ this.serviceLoggers[serviceName].info(`Updating before startup...`)
265
+ this.restartTimes = []
266
+ this.applyUpdate()
267
+ this.startService(serviceName)
268
+ } else {
269
+ const now = Date.now()
270
+ const fiveMinutesAgo = now - 5 * 60 * 1000
271
+ this.restartTimes = this.restartTimes.filter(
272
+ (time) => time > fiveMinutesAgo,
273
+ )
274
+ this.restartTimes.push(now)
275
+
276
+ if (this.restartTimes.length < 5) {
277
+ this.serviceLoggers[serviceName].info(`Crashed. Restarting...`)
278
+ this.startService(serviceName)
279
+ } else {
280
+ this.serviceLoggers[serviceName].info(
281
+ `Crashed 5 times in 5 minutes. Not restarting.`,
282
+ )
283
+ }
284
+ }
285
+ })
286
+ this.safety = 0
287
+ }
288
+
289
+ protected applyUpdate(): void {
290
+ this.logger.info(`Applying update...`)
291
+ if (existsSync(this.updateServiceDir)) {
292
+ const runningServices = toEntries(this.services).filter(
293
+ ([, service]) => service,
294
+ )
295
+ if (runningServices.length > 0) {
296
+ this.logger.error(
297
+ `Tried to apply update but failed. The following services are currently running: [${runningServices.map(([serviceName]) => serviceName).join(`, `)}]`,
298
+ )
299
+ return
300
+ }
301
+
302
+ if (existsSync(this.currentServiceDir)) {
303
+ if (!existsSync(this.backupServiceDir)) {
304
+ mkdirSync(this.backupServiceDir, { recursive: true })
305
+ } else {
306
+ rmSync(this.backupServiceDir, { recursive: true })
307
+ }
308
+ renameSync(this.currentServiceDir, this.backupServiceDir)
309
+ }
310
+
311
+ renameSync(this.updateServiceDir, this.currentServiceDir)
312
+ this.restartTimes = []
313
+ this.servicesReadyToUpdate = { ...this.defaultServicesReadyToUpdate }
314
+ } else {
315
+ this.logger.error(
316
+ `Tried to apply update but failed: could not find update directory ${this.updateServiceDir}`,
317
+ )
318
+ }
319
+ }
320
+
321
+ protected getLatestRelease(): void {
322
+ this.logger.info(`Getting latest release...`)
323
+
324
+ try {
325
+ execSync(this.options.downloadPackageToUpdatesCmd.join(` `))
326
+ } catch (thrown) {
327
+ if (thrown instanceof Error) {
328
+ this.logger.error(`Failed to get the latest release: ${thrown.message}`)
329
+ }
330
+ return
331
+ }
332
+ }
333
+
334
+ public stopAllServices(): void {
335
+ this.logger.info(`Stopping all services...`)
336
+ for (const [serviceName] of toEntries(this.services)) {
337
+ this.stopService(serviceName)
338
+ }
339
+ }
340
+
341
+ public stopService(serviceName: S): void {
342
+ if (this.services[serviceName]) {
343
+ this.serviceLoggers[serviceName].info(`Stopping service...`)
344
+ this.services[serviceName].process.kill()
345
+ this.services[serviceName] = null
346
+ this.servicesDead[this.serviceIdx[serviceName]].use(Promise.resolve())
347
+ this.servicesLive[this.serviceIdx[serviceName]] = new Future(() => {})
348
+ if (this.live.done) {
349
+ this.live = new Future(() => {})
350
+ }
351
+ this.live.use(Promise.all(this.servicesLive))
352
+ } else {
353
+ this.serviceLoggers[serviceName].error(
354
+ `Tried to stop service, but it wasn't running.`,
355
+ )
356
+ }
357
+ }
358
+
359
+ public shutdown(): void {
360
+ this.servicesShouldRestart = false
361
+ this.stopAllServices()
362
+ }
363
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cli, required } from "comline"
4
+ import { z } from "zod"
5
+
6
+ import * as Klaxon from "./klaxon.lib"
7
+
8
+ const changesetsPublishedPackagesSchema: z.ZodSchema<Klaxon.ScrambleOptions> =
9
+ z.object({
10
+ packageConfig: z.record(z.string(), z.object({ endpoint: z.string() })),
11
+ secretsConfig: z.record(z.string(), z.string()),
12
+ publishedPackages: z.array(
13
+ z.object({
14
+ name: z.string(),
15
+ version: z.string(),
16
+ }),
17
+ ),
18
+ })
19
+
20
+ const klaxon = cli({
21
+ cliName: `klaxon`,
22
+ routes: required({
23
+ scramble: null,
24
+ }),
25
+ routeOptions: {
26
+ scramble: {
27
+ options: {
28
+ packageConfig: {
29
+ description: `Maps the names of your packages to the endpoints that klaxon will POST to.`,
30
+ example: `--packageConfig="{\\"my-app\\":{\\"endpoint\\":\\"https://my-app.com\\"}}"`,
31
+ flag: `c`,
32
+ parse: JSON.parse,
33
+ required: true,
34
+ },
35
+ secretsConfig: {
36
+ description: `Maps the names of your packages to the secrets that klaxon will use to authenticate with their respective endpoints.`,
37
+ example: `--secretsConfig="{\\"my-app\\":\\"XXXX-XXXX-XXXX\\"}"`,
38
+ flag: `s`,
39
+ parse: JSON.parse,
40
+ required: true,
41
+ },
42
+ publishedPackages: {
43
+ description: `The output of the "Publish" step in Changesets.`,
44
+ example: `--publishedPackages="[{\\"name\\":\\"my-app\\",\\"version\\":\\"0.0.0\\"}]"`,
45
+ flag: `p`,
46
+ parse: JSON.parse,
47
+ required: true,
48
+ },
49
+ },
50
+ optionsSchema: changesetsPublishedPackagesSchema,
51
+ },
52
+ },
53
+ })
54
+
55
+ const { inputs } = klaxon(process.argv)
56
+ await Klaxon.scramble(inputs.opts).then((scrambleResult) => {
57
+ console.log(scrambleResult)
58
+ })
@@ -0,0 +1,78 @@
1
+ export type AlertOptions = {
2
+ secret: string
3
+ endpoint: string
4
+ }
5
+
6
+ export async function alert({
7
+ secret,
8
+ endpoint,
9
+ }: AlertOptions): Promise<Response> {
10
+ const response = await fetch(endpoint, {
11
+ method: `POST`,
12
+ headers: {
13
+ "Content-Type": `application/json`,
14
+ Authorization: `Bearer ${secret}`,
15
+ },
16
+ })
17
+
18
+ return response
19
+ }
20
+
21
+ /**
22
+ * @see https://github.com/changesets/action/blob/main/src/run.ts
23
+ */
24
+ export type ChangesetsPublishedPackage = {
25
+ name: string
26
+ version: string
27
+ }
28
+
29
+ /**
30
+ * @see https://github.com/changesets/action/blob/main/src/run.ts
31
+ */
32
+ export type ChangesetsPublishResult =
33
+ | {
34
+ published: true
35
+ publishedPackages: ChangesetsPublishedPackage[]
36
+ }
37
+ | { published: false }
38
+
39
+ export type PackageConfig<K extends string> = {
40
+ [key in K]: { endpoint: string }
41
+ }
42
+ export type SecretsConfig<K extends string> = {
43
+ [key in K]: string
44
+ }
45
+
46
+ export type ScrambleOptions<K extends string = string> = {
47
+ packageConfig: PackageConfig<K>
48
+ secretsConfig: SecretsConfig<K>
49
+ publishedPackages: ChangesetsPublishedPackage[]
50
+ }
51
+
52
+ export type ScrambleResult<K extends string = string> = {
53
+ [key in K]: Response
54
+ }
55
+
56
+ export async function scramble<K extends string = string>({
57
+ packageConfig,
58
+ secretsConfig,
59
+ publishedPackages,
60
+ }: ScrambleOptions<K>): Promise<ScrambleResult<K>> {
61
+ const alertResults: Promise<readonly [K, Response]>[] = []
62
+ for (const publishedPackage of publishedPackages) {
63
+ if (publishedPackage.name in packageConfig) {
64
+ const name = publishedPackage.name as K
65
+ const { endpoint } = packageConfig[name]
66
+ const secret = secretsConfig[name]
67
+ const alertResultPromise = alert({ secret, endpoint }).then(
68
+ (alertResult) => [name, alertResult] as const,
69
+ )
70
+ alertResults.push(alertResultPromise)
71
+ }
72
+ }
73
+ const alertResultsResolved = await Promise.all(alertResults)
74
+ const scrambleResult = Object.fromEntries(
75
+ alertResultsResolved,
76
+ ) as ScrambleResult<K>
77
+ return scrambleResult
78
+ }
package/src/lib.ts CHANGED
@@ -1 +1,4 @@
1
- export * from "./flightdeck"
1
+ export * from "./flightdeck.lib"
2
+ import * as Klaxon from "./klaxon.lib"
3
+
4
+ export { Klaxon }
package/dist/bin.js DELETED
@@ -1,102 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/bin.ts
4
- import * as path from "node:path";
5
- import {cli, optional, parseArrayOption} from "comline";
6
- import {FlightDeck} from "flightdeck";
7
- import {z} from "zod";
8
- var FLIGHTDECK_MANUAL = {
9
- optionsSchema: z.object({
10
- secret: z.string(),
11
- repo: z.string(),
12
- app: z.string(),
13
- runCmd: z.array(z.string()),
14
- serviceDir: z.string(),
15
- updateCmd: z.array(z.string())
16
- }),
17
- options: {
18
- secret: {
19
- flag: `s`,
20
- required: true,
21
- description: `Secret used to authenticate with the service.`,
22
- example: `--secret=\"secret\"`
23
- },
24
- repo: {
25
- flag: `r`,
26
- required: true,
27
- description: `Name of the repository.`,
28
- example: `--repo=\"sample/repo\"`
29
- },
30
- app: {
31
- flag: `a`,
32
- required: true,
33
- description: `Name of the application.`,
34
- example: `--app=\"my-app\"`
35
- },
36
- runCmd: {
37
- flag: `r`,
38
- required: true,
39
- description: `Command to run the application.`,
40
- example: `--runCmd=\"./app\"`,
41
- parse: parseArrayOption
42
- },
43
- serviceDir: {
44
- flag: `d`,
45
- required: true,
46
- description: `Directory where the service is stored.`,
47
- example: `--serviceDir=\"./services/sample/repo/my-app/current\"`
48
- },
49
- updateCmd: {
50
- flag: `u`,
51
- required: true,
52
- description: `Command to update the service.`,
53
- example: `--updateCmd=\"./app\"`,
54
- parse: parseArrayOption
55
- }
56
- }
57
- };
58
- var SCHEMA_MANUAL = {
59
- optionsSchema: z.object({
60
- outdir: z.string().optional()
61
- }),
62
- options: {
63
- outdir: {
64
- flag: `o`,
65
- required: false,
66
- description: `Directory to write the schema to.`,
67
- example: `--outdir=./dist`
68
- }
69
- }
70
- };
71
- var parse = cli({
72
- cliName: `flightdeck`,
73
- routes: optional({ schema: null, $configPath: null }),
74
- routeOptions: {
75
- "": FLIGHTDECK_MANUAL,
76
- $configPath: FLIGHTDECK_MANUAL,
77
- schema: SCHEMA_MANUAL
78
- },
79
- discoverConfigPath: (args) => {
80
- if (args[0] === `schema`) {
81
- return;
82
- }
83
- const configPath = args[0] ?? path.join(process.cwd(), `flightdeck.config.json`);
84
- return configPath;
85
- }
86
- }, console);
87
- var { inputs, writeJsonSchema } = parse(process.argv);
88
- switch (inputs.case) {
89
- case `schema`:
90
- {
91
- const { outdir } = inputs.opts;
92
- writeJsonSchema(outdir ?? `.`);
93
- }
94
- break;
95
- default: {
96
- const flightDeck = new FlightDeck(inputs.opts);
97
- process.on(`close`, async () => {
98
- flightDeck.stopService();
99
- await flightDeck.dead;
100
- });
101
- }
102
- }