@shipfox/api-server 5.0.0 → 6.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.
package/src/modules.ts CHANGED
@@ -1,115 +1,168 @@
1
1
  import {annotationsModule} from '@shipfox/annotations';
2
- import {agentModule} from '@shipfox/api-agent';
3
- import {authModule} from '@shipfox/api-auth';
2
+ import {annotationsInterModuleContract} from '@shipfox/annotations-dto/inter-module';
3
+ import {createAgentModule} from '@shipfox/api-agent';
4
+ import {agentInterModuleContract} from '@shipfox/api-agent-dto/inter-module';
5
+ import {createAuthModule} from '@shipfox/api-auth';
6
+ import {config as authConfig} from '@shipfox/api-auth/config';
7
+ import {authInterModuleContract} from '@shipfox/api-auth-dto/inter-module';
4
8
  import {createDefinitionsModule} from '@shipfox/api-definitions';
9
+ import {definitionsInterModuleContract} from '@shipfox/api-definitions-dto/inter-module';
5
10
  import {dispatcherModule} from '@shipfox/api-dispatcher';
6
- import {
7
- buildAgentToolCatalogs,
8
- buildAgentToolSelectionCatalogs,
9
- createIntegrationsContext,
10
- createWorkspaceConnectionSnapshotLoader,
11
- getIntegrationConnectionById,
12
- } from '@shipfox/api-integration-core';
13
- import {logsModule} from '@shipfox/api-logs';
11
+ import {emailChallengesModule} from '@shipfox/api-email-challenges';
12
+ import {createIntegrationsContext, type WebhookDeliverySource} from '@shipfox/api-integration-core';
13
+ import {integrationsInterModuleContract} from '@shipfox/api-integration-core-dto';
14
+ import {createLogsModule} from '@shipfox/api-logs';
14
15
  import {createProjectsModule} from '@shipfox/api-projects';
15
- import {runnersModule} from '@shipfox/api-runners';
16
- import {deleteSecrets, getSecret, secretsModule, setSecrets} from '@shipfox/api-secrets';
17
- import {triggersModule} from '@shipfox/api-triggers';
18
- import {
19
- loadRunningLeasedStep,
20
- setAgentToolMaterializationServices,
21
- setSourceControl,
22
- workflowsModule,
23
- } from '@shipfox/api-workflows';
16
+ import {projectsInterModuleContract} from '@shipfox/api-projects-dto';
17
+ import {createRunnersModule} from '@shipfox/api-runners';
18
+ import {runnersInterModuleContract} from '@shipfox/api-runners-dto/inter-module';
19
+ import {createSecretsModule} from '@shipfox/api-secrets';
20
+ import {secretsInterModuleContract} from '@shipfox/api-secrets-dto/inter-module';
21
+ import {createTriggersModule} from '@shipfox/api-triggers';
22
+ import {createWorkflowsModule} from '@shipfox/api-workflows';
23
+ import {workflowsInterModuleContract} from '@shipfox/api-workflows-dto/inter-module';
24
24
  import {workspacesModule} from '@shipfox/api-workspaces';
25
+ import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
26
+ import {durationToSeconds} from '@shipfox/node-jwt';
25
27
  import type {ShipfoxModule} from '@shipfox/node-module';
28
+ import {
29
+ createInMemoryInterModuleTransport,
30
+ registerInterModulePresentations,
31
+ } from '@shipfox/node-module/inter-module';
26
32
 
27
- export async function defaultModules(): Promise<ShipfoxModule[]> {
33
+ export interface DefaultModulesOptions {
34
+ webhookDeliverySource?: WebhookDeliverySource | undefined;
35
+ }
36
+
37
+ export async function defaultModules(
38
+ options: DefaultModulesOptions = {},
39
+ ): Promise<ShipfoxModule[]> {
40
+ const interModuleTransport = createInMemoryInterModuleTransport();
41
+ const workflowsClient = interModuleTransport.createClient(workflowsInterModuleContract);
42
+ const authClient = interModuleTransport.createClient(authInterModuleContract);
43
+ const agentClient = interModuleTransport.createClient(agentInterModuleContract);
44
+ const runnersClient = interModuleTransport.createClient(runnersInterModuleContract);
45
+ const projectsClient = interModuleTransport.createClient(projectsInterModuleContract);
46
+ const definitionsClient = interModuleTransport.createClient(definitionsInterModuleContract);
47
+ const annotationsClient = interModuleTransport.createClient(annotationsInterModuleContract);
48
+ const secretsClient = interModuleTransport.createClient(secretsInterModuleContract);
49
+ const workspacesClient = interModuleTransport.createClient(workspacesInterModuleContract);
50
+ const integrationsClient = interModuleTransport.createClient(integrationsInterModuleContract);
28
51
  const integrations = await createIntegrationsContext({
52
+ workspaces: workspacesClient,
29
53
  secrets: {
30
- deleteSecrets,
54
+ deleteSecrets: async (params) => (await secretsClient.deleteSecrets(params)).deleted,
31
55
  linear: {
32
- getSecret: (params) =>
33
- getSecret({
34
- ...params,
35
- namespace: `system/integrations/linear/${params.namespace}`,
36
- }),
37
- setSecrets: (params) =>
38
- setSecrets({
39
- ...params,
40
- namespace: `system/integrations/linear/${params.namespace}`,
41
- }),
42
- deleteSecrets: (params) =>
43
- deleteSecrets({
44
- ...params,
45
- namespace: `system/integrations/linear/${params.namespace}`,
46
- }),
56
+ getSecret: async (params) =>
57
+ (
58
+ await secretsClient.getSecret({
59
+ ...params,
60
+ namespace: `system/integrations/linear/${params.namespace}`,
61
+ })
62
+ ).value,
63
+ setSecrets: async (params) => {
64
+ const {editedBy, ...secretParams} = params;
65
+ await secretsClient.setSecrets({
66
+ ...secretParams,
67
+ namespace: `system/integrations/linear/${secretParams.namespace}`,
68
+ ...(editedBy === undefined ? {} : {editedBy}),
69
+ });
70
+ },
71
+ deleteSecrets: async (params) =>
72
+ (
73
+ await secretsClient.deleteSecrets({
74
+ ...params,
75
+ namespace: `system/integrations/linear/${params.namespace}`,
76
+ })
77
+ ).deleted,
47
78
  },
48
79
  jira: {
49
- getSecret: (params) =>
50
- getSecret({...params, namespace: `system/integrations/jira/${params.namespace}`}),
51
- setSecrets: (params) =>
52
- setSecrets({...params, namespace: `system/integrations/jira/${params.namespace}`}),
53
- deleteSecrets: (params) =>
54
- deleteSecrets({...params, namespace: `system/integrations/jira/${params.namespace}`}),
80
+ getSecret: async (params) =>
81
+ (
82
+ await secretsClient.getSecret({
83
+ ...params,
84
+ namespace: `system/integrations/jira/${params.namespace}`,
85
+ })
86
+ ).value,
87
+ setSecrets: async (params) => {
88
+ const {editedBy, ...secretParams} = params;
89
+ await secretsClient.setSecrets({
90
+ ...secretParams,
91
+ namespace: `system/integrations/jira/${secretParams.namespace}`,
92
+ ...(editedBy === undefined ? {} : {editedBy}),
93
+ });
94
+ },
95
+ deleteSecrets: async (params) =>
96
+ (
97
+ await secretsClient.deleteSecrets({
98
+ ...params,
99
+ namespace: `system/integrations/jira/${params.namespace}`,
100
+ })
101
+ ).deleted,
55
102
  },
56
103
  slack: {
57
- getSecret: (params) =>
58
- getSecret({
59
- ...params,
60
- namespace: `system/integrations/slack/${params.namespace}`,
61
- }),
62
- setSecrets: (params) =>
63
- setSecrets({
64
- ...params,
65
- namespace: `system/integrations/slack/${params.namespace}`,
66
- }),
67
- deleteSecrets: (params) =>
68
- deleteSecrets({
69
- ...params,
70
- namespace: `system/integrations/slack/${params.namespace}`,
71
- }),
104
+ getSecret: async (params) =>
105
+ (
106
+ await secretsClient.getSecret({
107
+ ...params,
108
+ namespace: `system/integrations/slack/${params.namespace}`,
109
+ })
110
+ ).value,
111
+ setSecrets: async (params) => {
112
+ const {editedBy, ...secretParams} = params;
113
+ await secretsClient.setSecrets({
114
+ ...secretParams,
115
+ namespace: `system/integrations/slack/${secretParams.namespace}`,
116
+ ...(editedBy === undefined ? {} : {editedBy}),
117
+ });
118
+ },
119
+ deleteSecrets: async (params) =>
120
+ (
121
+ await secretsClient.deleteSecrets({
122
+ ...params,
123
+ namespace: `system/integrations/slack/${params.namespace}`,
124
+ })
125
+ ).deleted,
72
126
  },
73
127
  },
74
- agentTools: {loadLeasedAgentStep: loadRunningLeasedStep},
75
- });
76
- const [agentToolSelectionCatalogs, agentToolCatalogs] = await Promise.all([
77
- buildAgentToolSelectionCatalogs(integrations.registry),
78
- buildAgentToolCatalogs(integrations.registry),
79
- ]);
80
- const loadWorkspaceConnectionSnapshot = createWorkspaceConnectionSnapshotLoader(
81
- integrations.registry,
82
- );
83
-
84
- // The checkout-token route resolves intents and mints credentials through the
85
- // source-control service; wire it into the workflows module before serving.
86
- setSourceControl(integrations.sourceControl);
87
- setAgentToolMaterializationServices({
88
- catalogs: agentToolCatalogs,
89
- loadWorkspaceConnectionSnapshot,
90
- getIntegrationConnectionById,
128
+ agentTools: {workflows: workflowsClient},
129
+ webhookDeliverySource: options.webhookDeliverySource,
91
130
  });
92
- const projectsModule = createProjectsModule({sourceControl: integrations.sourceControl});
131
+ const projectsModule = createProjectsModule({integrations: integrationsClient});
93
132
  const definitionsModule = createDefinitionsModule({
94
- sourceControl: integrations.sourceControl,
95
- agentToolSelectionCatalogs,
96
- loadWorkspaceConnectionSnapshot,
97
- getIntegrationConnectionById,
133
+ projects: projectsClient,
134
+ integrations: integrationsClient,
98
135
  });
99
136
 
100
- return [
101
- authModule,
137
+ const modules = [
138
+ emailChallengesModule,
139
+ createAuthModule({workspaces: workspacesClient}),
102
140
  workspacesModule,
103
- secretsModule,
104
- agentModule,
141
+ createSecretsModule(projectsClient),
142
+ createAgentModule({secrets: secretsClient}),
105
143
  integrations.module,
106
144
  projectsModule,
107
145
  definitionsModule,
108
- workflowsModule,
146
+ createWorkflowsModule({
147
+ annotations: annotationsClient,
148
+ agent: agentClient,
149
+ definitions: definitionsClient,
150
+ auth: authClient,
151
+ projects: projectsClient,
152
+ runners: runnersClient,
153
+ secrets: secretsClient,
154
+ integrations: integrationsClient,
155
+ }),
109
156
  annotationsModule,
110
- runnersModule,
111
- logsModule,
112
- triggersModule,
157
+ createRunnersModule({auth: authClient}),
158
+ createLogsModule({
159
+ workflows: workflowsClient,
160
+ jobLeaseTokenTtlSeconds: durationToSeconds(authConfig.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN),
161
+ }),
162
+ createTriggersModule({workflows: workflowsClient}),
113
163
  dispatcherModule,
114
164
  ];
165
+ registerInterModulePresentations({transport: interModuleTransport, modules});
166
+ interModuleTransport.seal();
167
+ return modules;
115
168
  }
@@ -1,4 +1,6 @@
1
1
  import type {
2
+ ModuleService,
3
+ ModuleServicesHandle,
2
4
  ModuleWorker,
3
5
  ModuleWorkersHandle,
4
6
  ShipfoxModule,
@@ -8,6 +10,7 @@ import {createServer, runServer, type ServerHandle} from './server.js';
8
10
 
9
11
  const mocks = vi.hoisted(() => {
10
12
  const workersHandle = {stop: vi.fn()};
13
+ const servicesHandle = {stop: vi.fn()};
11
14
  return {
12
15
  captureException: vi.fn(),
13
16
  closeApp: vi.fn(),
@@ -23,13 +26,13 @@ const mocks = vi.hoisted(() => {
23
26
  logger: {error: vi.fn(), info: vi.fn()},
24
27
  parseApiTrustProxy: vi.fn(),
25
28
  registerModuleMetrics: vi.fn(),
26
- resetPublishers: vi.fn(),
27
- resetSubscribers: vi.fn(),
28
29
  runModuleStartupTasks: vi.fn(),
29
30
  shutdownServiceMetrics: vi.fn(),
31
+ startModuleServices: vi.fn(),
30
32
  startModuleWorkers: vi.fn(),
31
33
  startServiceMetrics: vi.fn(),
32
34
  workersHandle,
35
+ servicesHandle,
33
36
  apiConfig: {
34
37
  API_PORT: undefined as number | undefined,
35
38
  API_TRUST_PROXY: 'false',
@@ -51,9 +54,8 @@ vi.mock('@shipfox/node-module', () => ({
51
54
  aggregateLoginMethods: mocks.aggregateLoginMethods,
52
55
  initializeModules: mocks.initializeModules,
53
56
  registerModuleMetrics: mocks.registerModuleMetrics,
54
- resetPublishers: mocks.resetPublishers,
55
- resetSubscribers: mocks.resetSubscribers,
56
57
  runModuleStartupTasks: mocks.runModuleStartupTasks,
58
+ startModuleServices: mocks.startModuleServices,
57
59
  startModuleWorkers: mocks.startModuleWorkers,
58
60
  }));
59
61
  vi.mock('@shipfox/node-opentelemetry', () => ({
@@ -87,12 +89,28 @@ function worker(taskQueue = 'runtime-queue'): ModuleWorker {
87
89
  };
88
90
  }
89
91
 
92
+ function service(name = 'runtime-service'): ModuleService {
93
+ return {name, shutdownTimeoutMs: 10_000, start: vi.fn()};
94
+ }
95
+
90
96
  function lastStartModuleWorkersOptions(): StartModuleWorkersOptions {
91
97
  const options = mocks.startModuleWorkers.mock.calls.at(-1)?.[0];
92
98
  if (!options) throw new Error('startModuleWorkers was not called');
93
99
  return options as StartModuleWorkersOptions;
94
100
  }
95
101
 
102
+ function lastStartModuleServicesOptions(): {
103
+ services: ModuleService[];
104
+ onServiceFailure?: (error: unknown, service: ModuleService) => void | Promise<void>;
105
+ } {
106
+ const options = mocks.startModuleServices.mock.calls.at(-1)?.[0];
107
+ if (!options) throw new Error('startModuleServices was not called');
108
+ return options as {
109
+ services: ModuleService[];
110
+ onServiceFailure?: (error: unknown, service: ModuleService) => void | Promise<void>;
111
+ };
112
+ }
113
+
96
114
  function resetMocks(): void {
97
115
  vi.useRealTimers();
98
116
  mocks.captureException.mockReset();
@@ -110,13 +128,13 @@ function resetMocks(): void {
110
128
  mocks.logger.info.mockReset();
111
129
  mocks.parseApiTrustProxy.mockReset();
112
130
  mocks.registerModuleMetrics.mockReset();
113
- mocks.resetPublishers.mockReset();
114
- mocks.resetSubscribers.mockReset();
115
131
  mocks.runModuleStartupTasks.mockReset();
116
132
  mocks.shutdownServiceMetrics.mockReset();
133
+ mocks.startModuleServices.mockReset();
117
134
  mocks.startModuleWorkers.mockReset();
118
135
  mocks.startServiceMetrics.mockReset();
119
136
  mocks.workersHandle.stop.mockReset();
137
+ mocks.servicesHandle.stop.mockReset();
120
138
  mocks.apiConfig.API_PORT = undefined;
121
139
  mocks.apiConfig.API_TRUST_PROXY = 'false';
122
140
  mocks.apiConfig.E2E_ENABLED = false;
@@ -132,13 +150,16 @@ function resetMocks(): void {
132
150
  routes: [],
133
151
  e2eRoutes: [],
134
152
  workers: [worker()],
153
+ services: [service()],
135
154
  });
136
155
  mocks.listen.mockResolvedValue('http://127.0.0.1:3000');
137
156
  mocks.parseApiTrustProxy.mockReturnValue(false);
138
157
  mocks.runModuleStartupTasks.mockResolvedValue(undefined);
139
158
  mocks.shutdownServiceMetrics.mockResolvedValue(undefined);
140
159
  mocks.startModuleWorkers.mockResolvedValue(mocks.workersHandle);
160
+ mocks.startModuleServices.mockResolvedValue(mocks.servicesHandle);
141
161
  mocks.workersHandle.stop.mockResolvedValue(undefined);
162
+ mocks.servicesHandle.stop.mockResolvedValue(undefined);
142
163
  }
143
164
 
144
165
  const servers: ServerHandle[] = [];
@@ -173,8 +194,14 @@ describe('createServer', () => {
173
194
  expect(mocks.aggregateLoginMethods).toHaveBeenCalledWith({modules});
174
195
  expect(mocks.createPostgresClient).toHaveBeenCalledOnce();
175
196
  expect(mocks.initializeModules).toHaveBeenCalledWith({modules});
176
- expect(mocks.registerModuleMetrics).toHaveBeenCalledWith({modules});
177
- expect(mocks.runModuleStartupTasks).toHaveBeenCalledWith({modules});
197
+ expect(mocks.registerModuleMetrics).toHaveBeenCalledWith({
198
+ modules,
199
+ context: {outboxRegistry: undefined},
200
+ });
201
+ expect(mocks.runModuleStartupTasks).toHaveBeenCalledWith({
202
+ modules,
203
+ context: {outboxRegistry: undefined},
204
+ });
178
205
  expect(mocks.createApp).toHaveBeenCalledWith({
179
206
  auth: [],
180
207
  routes: [],
@@ -201,6 +228,7 @@ describe('createServer', () => {
201
228
  await server.start();
202
229
 
203
230
  expect(mocks.startModuleWorkers).toHaveBeenCalledOnce();
231
+ expect(mocks.startModuleServices).toHaveBeenCalledOnce();
204
232
  expect(mocks.listen).toHaveBeenCalledOnce();
205
233
  });
206
234
 
@@ -230,6 +258,35 @@ describe('createServer', () => {
230
258
  );
231
259
  });
232
260
 
261
+ it('starts module services after workers and before listening for HTTP requests', async () => {
262
+ const server = await createTestServer({modules: [module()]});
263
+ let resolveServices: ((handle: ModuleServicesHandle) => void) | undefined;
264
+ const servicesStarted = new Promise<void>((resolve) => {
265
+ mocks.startModuleServices.mockImplementationOnce(
266
+ () =>
267
+ new Promise<ModuleServicesHandle>((innerResolve) => {
268
+ resolveServices = innerResolve;
269
+ resolve();
270
+ }),
271
+ );
272
+ });
273
+
274
+ const start = server.start();
275
+ await servicesStarted;
276
+
277
+ expect(mocks.listen).not.toHaveBeenCalled();
278
+ expect(mocks.startModuleWorkers.mock.invocationCallOrder[0]).toBeLessThan(
279
+ mocks.startModuleServices.mock.invocationCallOrder[0] ?? 0,
280
+ );
281
+
282
+ resolveServices?.(mocks.servicesHandle);
283
+ await start;
284
+
285
+ expect(mocks.startModuleServices.mock.invocationCallOrder[0]).toBeLessThan(
286
+ mocks.listen.mock.invocationCallOrder[0] ?? 0,
287
+ );
288
+ });
289
+
233
290
  it('passes API_PORT to the HTTP listener when configured', async () => {
234
291
  mocks.apiConfig.API_PORT = 55_291;
235
292
  const server = await createTestServer({modules: [module()]});
@@ -250,6 +307,20 @@ describe('createServer', () => {
250
307
  expect(mocks.listen).not.toHaveBeenCalled();
251
308
  });
252
309
 
310
+ it('does not listen when module service startup fails', async () => {
311
+ const failure = new Error('service configuration invalid');
312
+ mocks.startModuleServices.mockRejectedValueOnce(failure);
313
+ const server = await createTestServer({modules: [module()]});
314
+
315
+ const result = server.start();
316
+
317
+ await expect(result).rejects.toBe(failure);
318
+ expect(mocks.listen).not.toHaveBeenCalled();
319
+
320
+ await server.stop();
321
+ expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
322
+ });
323
+
253
324
  it('releases service metrics and Postgres when boot fails', async () => {
254
325
  const failure = new Error('migration failed');
255
326
  mocks.initializeModules.mockRejectedValueOnce(failure);
@@ -260,8 +331,6 @@ describe('createServer', () => {
260
331
  expect(mocks.shutdownServiceMetrics.mock.invocationCallOrder[0]).toBeLessThan(
261
332
  mocks.closePostgresClient.mock.invocationCallOrder[0] ?? 0,
262
333
  );
263
- expect(mocks.resetPublishers).toHaveBeenCalledOnce();
264
- expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
265
334
  });
266
335
 
267
336
  it('rejects a second server while another server owns process resources', async () => {
@@ -287,8 +356,6 @@ describe('createServer', () => {
287
356
 
288
357
  await expect(result).rejects.toBe(failure);
289
358
  expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
290
- expect(mocks.resetPublishers).toHaveBeenCalledOnce();
291
- expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
292
359
  expect(mocks.logger.error).toHaveBeenCalledWith(
293
360
  {err: cleanupFailure, bootError: failure},
294
361
  'Failed to clean up API server boot',
@@ -305,11 +372,10 @@ describe('createServer', () => {
305
372
  await secondStop;
306
373
 
307
374
  expect(mocks.closeApp).toHaveBeenCalledOnce();
375
+ expect(mocks.servicesHandle.stop).toHaveBeenCalledOnce();
308
376
  expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
309
377
  expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
310
378
  expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
311
- expect(mocks.resetPublishers).toHaveBeenCalledOnce();
312
- expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
313
379
  expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
314
380
  });
315
381
 
@@ -319,11 +385,10 @@ describe('createServer', () => {
319
385
  await server.stop();
320
386
 
321
387
  expect(mocks.closeApp).toHaveBeenCalledOnce();
388
+ expect(mocks.servicesHandle.stop).not.toHaveBeenCalled();
322
389
  expect(mocks.workersHandle.stop).not.toHaveBeenCalled();
323
390
  expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
324
391
  expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
325
- expect(mocks.resetPublishers).toHaveBeenCalledOnce();
326
- expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
327
392
  });
328
393
 
329
394
  it('waits for worker startup before stopping', async () => {
@@ -351,11 +416,10 @@ describe('createServer', () => {
351
416
 
352
417
  expect(mocks.listen).not.toHaveBeenCalled();
353
418
  expect(mocks.closeApp).toHaveBeenCalledOnce();
419
+ expect(mocks.servicesHandle.stop).toHaveBeenCalledOnce();
354
420
  expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
355
421
  expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
356
422
  expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
357
- expect(mocks.resetPublishers).toHaveBeenCalledOnce();
358
- expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
359
423
  expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
360
424
  });
361
425
 
@@ -366,6 +430,9 @@ describe('createServer', () => {
366
430
  await server.stop();
367
431
 
368
432
  expect(mocks.closeApp.mock.invocationCallOrder[0]).toBeLessThan(
433
+ mocks.servicesHandle.stop.mock.invocationCallOrder[0] ?? 0,
434
+ );
435
+ expect(mocks.servicesHandle.stop.mock.invocationCallOrder[0]).toBeLessThan(
369
436
  mocks.workersHandle.stop.mock.invocationCallOrder[0] ?? 0,
370
437
  );
371
438
  expect(mocks.workersHandle.stop.mock.invocationCallOrder[0]).toBeLessThan(
@@ -375,9 +442,6 @@ describe('createServer', () => {
375
442
  mocks.closePostgresClient.mock.invocationCallOrder[0] ?? 0,
376
443
  );
377
444
  expect(mocks.closePostgresClient.mock.invocationCallOrder[0]).toBeLessThan(
378
- mocks.resetPublishers.mock.invocationCallOrder[0] ?? 0,
379
- );
380
- expect(mocks.resetSubscribers.mock.invocationCallOrder[0]).toBeLessThan(
381
445
  mocks.closeErrorMonitoring.mock.invocationCallOrder[0] ?? 0,
382
446
  );
383
447
  });
@@ -389,22 +453,15 @@ describe('createServer', () => {
389
453
  mocks.workersHandle.stop.mockRejectedValueOnce(new Error('stop workers failed'));
390
454
  mocks.shutdownServiceMetrics.mockRejectedValueOnce(new Error('shutdown metrics failed'));
391
455
  mocks.closePostgresClient.mockRejectedValueOnce(new Error('close Postgres failed'));
392
- mocks.resetPublishers.mockImplementationOnce(() => {
393
- throw new Error('reset publishers failed');
394
- });
395
- mocks.resetSubscribers.mockImplementationOnce(() => {
396
- throw new Error('reset subscribers failed');
397
- });
398
456
  mocks.closeErrorMonitoring.mockRejectedValueOnce(new Error('close monitoring failed'));
399
457
 
400
458
  const result = server.stop();
401
459
 
402
460
  await expect(result).rejects.toBeInstanceOf(AggregateError);
461
+ expect(mocks.servicesHandle.stop).toHaveBeenCalledOnce();
403
462
  expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
404
463
  expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
405
464
  expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
406
- expect(mocks.resetPublishers).toHaveBeenCalledOnce();
407
- expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
408
465
  expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
409
466
  });
410
467
 
@@ -443,6 +500,27 @@ describe('createServer', () => {
443
500
  );
444
501
  });
445
502
 
503
+ it('reports service failures after closing HTTP and error monitoring', async () => {
504
+ const onServiceFailure = vi.fn();
505
+ const server = await createTestServer({modules: [module()], onServiceFailure});
506
+ await server.start();
507
+ const failure = new Error('poller failed');
508
+ const failedService = service();
509
+
510
+ await lastStartModuleServicesOptions().onServiceFailure?.(failure, failedService);
511
+
512
+ expect(mocks.logger.error).toHaveBeenCalledWith(
513
+ {err: failure, service: 'runtime-service'},
514
+ 'Module service stopped unexpectedly',
515
+ );
516
+ expect(mocks.captureException).toHaveBeenCalledWith(failure);
517
+ expect(mocks.closeApp).toHaveBeenCalledOnce();
518
+ expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
519
+ expect(mocks.closeErrorMonitoring.mock.invocationCallOrder[0]).toBeLessThan(
520
+ onServiceFailure.mock.invocationCallOrder[0] ?? 0,
521
+ );
522
+ });
523
+
446
524
  it('reports a timed-out HTTP close before invoking the worker failure hook', async () => {
447
525
  vi.useFakeTimers();
448
526
  const onWorkerFailure = vi.fn();
@@ -459,7 +537,7 @@ describe('createServer', () => {
459
537
 
460
538
  expect(mocks.logger.error).toHaveBeenCalledWith(
461
539
  {timeoutMs: 10_000},
462
- 'Timed out closing HTTP server after worker failure',
540
+ 'Timed out closing HTTP server after module runtime failure',
463
541
  );
464
542
  expect(onWorkerFailure).toHaveBeenCalledWith(
465
543
  failure,
@@ -478,7 +556,7 @@ describe('createServer', () => {
478
556
 
479
557
  expect(mocks.logger.error).toHaveBeenCalledWith(
480
558
  {err: closeFailure},
481
- 'Failed to close HTTP server after worker failure',
559
+ 'Failed to close HTTP server after module runtime failure',
482
560
  );
483
561
  expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
484
562
  expect(mocks.closeErrorMonitoring.mock.invocationCallOrder[0]).toBeLessThan(
@@ -519,11 +597,10 @@ describe('runServer', () => {
519
597
  await expect(result).rejects.toBe(failure);
520
598
  expect(onStartupFailure).toHaveBeenCalledWith(failure);
521
599
  expect(mocks.closeApp).toHaveBeenCalledOnce();
600
+ expect(mocks.servicesHandle.stop).toHaveBeenCalledOnce();
522
601
  expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
523
602
  expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
524
603
  expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
525
- expect(mocks.resetPublishers).toHaveBeenCalledOnce();
526
- expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
527
604
  expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
528
605
  expect(onStartupFailure.mock.invocationCallOrder[0]).toBeLessThan(
529
606
  mocks.closeErrorMonitoring.mock.invocationCallOrder[0] ?? 0,
@@ -557,10 +634,25 @@ describe('runServer', () => {
557
634
  it('exits unsuccessfully when a module worker fails', async () => {
558
635
  vi.spyOn(process, 'once').mockImplementation((() => process) as never);
559
636
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
560
- await runServer({modules: [module()]});
637
+ const handle = await runServer({modules: [module()]});
561
638
 
562
639
  await lastStartModuleWorkersOptions().onWorkerFailure?.(new Error('poller failed'), worker());
563
640
 
564
641
  expect(exitSpy).toHaveBeenCalledWith(1);
642
+ await handle.stop();
643
+ });
644
+
645
+ it('exits unsuccessfully when a module service fails', async () => {
646
+ vi.spyOn(process, 'once').mockImplementation((() => process) as never);
647
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
648
+ const handle = await runServer({modules: [module()]});
649
+
650
+ await lastStartModuleServicesOptions().onServiceFailure?.(
651
+ new Error('poller failed'),
652
+ service(),
653
+ );
654
+
655
+ expect(exitSpy).toHaveBeenCalledWith(1);
656
+ await handle.stop();
565
657
  });
566
658
  });