@shipfox/api-server 2.0.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 (47) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +35 -0
  5. package/LICENSE +21 -0
  6. package/README.md +59 -0
  7. package/dist/config.d.ts +9 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +43 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/e2e.d.ts +10 -0
  12. package/dist/e2e.d.ts.map +1 -0
  13. package/dist/e2e.js +42 -0
  14. package/dist/e2e.js.map +1 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +4 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/instrumentation.d.ts +2 -0
  20. package/dist/instrumentation.d.ts.map +1 -0
  21. package/dist/instrumentation.js +13 -0
  22. package/dist/instrumentation.js.map +1 -0
  23. package/dist/modules.d.ts +3 -0
  24. package/dist/modules.d.ts.map +1 -0
  25. package/dist/modules.js +76 -0
  26. package/dist/modules.js.map +1 -0
  27. package/dist/server.d.ts +16 -0
  28. package/dist/server.d.ts.map +1 -0
  29. package/dist/server.js +215 -0
  30. package/dist/server.js.map +1 -0
  31. package/dist/tsconfig.test.tsbuildinfo +1 -0
  32. package/package.json +80 -0
  33. package/src/config.test.ts +33 -0
  34. package/src/config.ts +54 -0
  35. package/src/e2e.test.ts +101 -0
  36. package/src/e2e.ts +61 -0
  37. package/src/index.ts +3 -0
  38. package/src/instrumentation.ts +8 -0
  39. package/src/modules.test.ts +137 -0
  40. package/src/modules.ts +90 -0
  41. package/src/server.test.ts +539 -0
  42. package/src/server.ts +242 -0
  43. package/tsconfig.build.json +9 -0
  44. package/tsconfig.build.tsbuildinfo +1 -0
  45. package/tsconfig.json +4 -0
  46. package/tsconfig.test.json +8 -0
  47. package/vitest.config.ts +3 -0
package/src/server.ts ADDED
@@ -0,0 +1,242 @@
1
+ import {captureException, closeErrorMonitoring} from '@shipfox/node-error-monitoring';
2
+ import {closeApp, createApp, listen} from '@shipfox/node-fastify';
3
+ import {
4
+ initializeModules,
5
+ type ModuleWorker,
6
+ type ModuleWorkersHandle,
7
+ registerModuleMetrics,
8
+ resetPublishers,
9
+ resetSubscribers,
10
+ runModuleStartupTasks,
11
+ type ShipfoxModule,
12
+ startModuleWorkers,
13
+ } from '@shipfox/node-module';
14
+ import {logger, shutdownServiceMetrics, startServiceMetrics} from '@shipfox/node-opentelemetry';
15
+ import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
16
+ import {config, parseApiTrustProxy} from './config.js';
17
+ import {createE2eAdminAuthMethod, createE2eRouteGroup} from './e2e.js';
18
+
19
+ const WORKER_FAILURE_HTTP_SHUTDOWN_TIMEOUT_MS = 10_000;
20
+ const ERROR_MONITORING_SHUTDOWN_TIMEOUT_MS = 2_000;
21
+
22
+ let hasActiveServer = false;
23
+
24
+ export interface CreateServerOptions {
25
+ modules: ShipfoxModule[];
26
+ onWorkerFailure?: (error: unknown, worker: ModuleWorker) => void | Promise<void>;
27
+ }
28
+
29
+ export interface ServerHandle {
30
+ start(): Promise<string>;
31
+ stop(): Promise<void>;
32
+ }
33
+
34
+ export interface RunServerOptions {
35
+ modules: ShipfoxModule[];
36
+ onStartupFailure?: (error: unknown) => void | Promise<void>;
37
+ }
38
+
39
+ export async function createServer(options: CreateServerOptions): Promise<ServerHandle> {
40
+ if (hasActiveServer) {
41
+ throw new Error('Cannot create a second API server before the existing server stops');
42
+ }
43
+ hasActiveServer = true;
44
+
45
+ try {
46
+ startServiceMetrics({serviceName: 'api'});
47
+ createPostgresClient();
48
+
49
+ const {auth, routes, e2eRoutes, workers} = await initializeModules({modules: options.modules});
50
+ registerModuleMetrics({modules: options.modules});
51
+ await runModuleStartupTasks({modules: options.modules});
52
+
53
+ const e2eAuth = config.E2E_ENABLED ? [createE2eAdminAuthMethod(config)] : [];
54
+ const mountedE2eRoutes = createE2eRouteGroup(e2eRoutes, config);
55
+
56
+ logger().info('Creating HTTP server');
57
+ await createApp({
58
+ auth: [...auth, ...e2eAuth],
59
+ routes: [...routes, ...mountedE2eRoutes],
60
+ fastifyOptions: {trustProxy: parseApiTrustProxy(config.API_TRUST_PROXY)},
61
+ });
62
+
63
+ let workersHandle: ModuleWorkersHandle | undefined;
64
+ let startPromise: Promise<string> | undefined;
65
+ let stopPromise: Promise<void> | undefined;
66
+ let stopped = false;
67
+
68
+ const start = (): Promise<string> => {
69
+ if (stopPromise || stopped)
70
+ return Promise.reject(new Error('Cannot start a stopped API server'));
71
+
72
+ startPromise ??= (async () => {
73
+ logger().info('Starting module workers');
74
+ workersHandle = await startModuleWorkers({
75
+ workers,
76
+ onWorkerFailure: (error, worker) =>
77
+ handleModuleWorkerFailure(error, worker, options.onWorkerFailure),
78
+ });
79
+
80
+ if (stopPromise) throw new Error('API server stopped during startup');
81
+
82
+ logger().info('Starting HTTP server');
83
+ const address =
84
+ config.API_PORT === undefined ? await listen() : await listen({port: config.API_PORT});
85
+ logger().info({address}, 'HTTP server listening');
86
+ return address;
87
+ })();
88
+ return startPromise;
89
+ };
90
+
91
+ const stop = (): Promise<void> => {
92
+ if (stopped) return Promise.resolve();
93
+
94
+ stopPromise ??= (async () => {
95
+ try {
96
+ await startPromise?.catch(() => undefined);
97
+ const cleanupErrors = await runCleanupSteps([
98
+ () => closeApp(),
99
+ () => workersHandle?.stop(),
100
+ () => shutdownServiceMetrics(),
101
+ () => closePostgresClient(),
102
+ () => resetPublishers(),
103
+ () => resetSubscribers(),
104
+ () => closeErrorMonitoring(ERROR_MONITORING_SHUTDOWN_TIMEOUT_MS),
105
+ ]);
106
+ throwCleanupErrors(cleanupErrors, 'Failed to stop API server');
107
+ stopped = true;
108
+ hasActiveServer = false;
109
+ } finally {
110
+ stopPromise = undefined;
111
+ }
112
+ })();
113
+ return stopPromise;
114
+ };
115
+
116
+ return {start, stop};
117
+ } catch (error) {
118
+ const cleanupErrors = await runCleanupSteps([
119
+ () => shutdownServiceMetrics(),
120
+ () => closePostgresClient(),
121
+ () => resetPublishers(),
122
+ () => resetSubscribers(),
123
+ ]);
124
+ for (const cleanupError of cleanupErrors) {
125
+ logger().error({err: cleanupError, bootError: error}, 'Failed to clean up API server boot');
126
+ }
127
+ hasActiveServer = false;
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ export async function runServer(options: RunServerOptions): Promise<ServerHandle> {
133
+ let handle: ServerHandle;
134
+ try {
135
+ handle = await createServer({
136
+ modules: options.modules,
137
+ onWorkerFailure: () => process.exit(1),
138
+ });
139
+ } catch (error) {
140
+ await reportStartupFailure(error, options.onStartupFailure);
141
+ throw error;
142
+ }
143
+
144
+ try {
145
+ await handle.start();
146
+ } catch (error) {
147
+ await reportStartupFailure(error, options.onStartupFailure);
148
+ try {
149
+ await handle.stop();
150
+ } catch (cleanupError) {
151
+ logger().error(
152
+ {err: cleanupError, startError: error},
153
+ 'Failed to clean up API server startup',
154
+ );
155
+ }
156
+ throw error;
157
+ }
158
+
159
+ const stopAndExit = () => void handle.stop().finally(() => process.exit(0));
160
+ process.once('SIGTERM', stopAndExit);
161
+ process.once('SIGINT', stopAndExit);
162
+
163
+ return handle;
164
+ }
165
+
166
+ async function reportStartupFailure(
167
+ error: unknown,
168
+ onStartupFailure: RunServerOptions['onStartupFailure'],
169
+ ): Promise<void> {
170
+ try {
171
+ await onStartupFailure?.(error);
172
+ } catch (reportingError) {
173
+ logger().error(
174
+ {err: reportingError, startError: error},
175
+ 'Failed to report API server startup error',
176
+ );
177
+ }
178
+ }
179
+
180
+ type CleanupStep = () => void | Promise<void>;
181
+
182
+ async function runCleanupSteps(steps: CleanupStep[]): Promise<unknown[]> {
183
+ const cleanupErrors: unknown[] = [];
184
+ for (const step of steps) {
185
+ try {
186
+ await step();
187
+ } catch (error) {
188
+ cleanupErrors.push(error);
189
+ }
190
+ }
191
+ return cleanupErrors;
192
+ }
193
+
194
+ function throwCleanupErrors(cleanupErrors: unknown[], message: string): void {
195
+ if (cleanupErrors.length === 0) return;
196
+ if (cleanupErrors.length === 1) throw cleanupErrors[0];
197
+ throw new AggregateError(cleanupErrors, message);
198
+ }
199
+
200
+ async function handleModuleWorkerFailure(
201
+ error: unknown,
202
+ worker: ModuleWorker,
203
+ onWorkerFailure: CreateServerOptions['onWorkerFailure'],
204
+ ): Promise<void> {
205
+ logger().error({err: error, taskQueue: worker.taskQueue}, 'Module worker stopped unexpectedly');
206
+ captureException(error);
207
+
208
+ try {
209
+ await closeHttpServerAfterWorkerFailure();
210
+ await closeErrorMonitoring(ERROR_MONITORING_SHUTDOWN_TIMEOUT_MS);
211
+ } finally {
212
+ await onWorkerFailure?.(error, worker);
213
+ }
214
+ }
215
+
216
+ async function closeHttpServerAfterWorkerFailure(): Promise<void> {
217
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
218
+ const timeout = new Promise<'timeout'>((resolve) => {
219
+ timeoutId = setTimeout(() => resolve('timeout'), WORKER_FAILURE_HTTP_SHUTDOWN_TIMEOUT_MS);
220
+ });
221
+ try {
222
+ const result = await Promise.race([closeApp().then(() => 'closed' as const), timeout]).finally(
223
+ () => {
224
+ if (timeoutId) {
225
+ clearTimeout(timeoutId);
226
+ }
227
+ },
228
+ );
229
+ if (result === 'timeout') {
230
+ logger().error(
231
+ {timeoutMs: WORKER_FAILURE_HTTP_SHUTDOWN_TIMEOUT_MS},
232
+ 'Timed out closing HTTP server after worker failure',
233
+ );
234
+ }
235
+ } catch (error) {
236
+ logger().error({err: error}, 'Failed to close HTTP server after worker failure');
237
+ } finally {
238
+ if (timeoutId) {
239
+ clearTimeout(timeoutId);
240
+ }
241
+ }
242
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@shipfox/ts-config/node",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["**/*.test.tsx", "**/*.test.ts"]
9
+ }