Skip to content

Commit 9ec3965

Browse files
committed
Initial commit
0 parents  commit 9ec3965

File tree

9 files changed

+451
-0
lines changed

9 files changed

+451
-0
lines changed

.github/workflows/publish.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: "Automated release"
2+
permissions:
3+
id-token: write
4+
contents: read
5+
on:
6+
push:
7+
branches:
8+
- main
9+
10+
jobs:
11+
publish:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v3
15+
- uses: denoland/setup-deno@v2
16+
- run: deno test
17+
- run: deno lint
18+
- run: deno publish

.vscode/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"deno.enable": true,
3+
"[typescript]": {
4+
"editor.defaultFormatter": "denoland.vscode-deno"
5+
}
6+
}

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Anthony Manning-Franklin
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Lifecycle Manager
2+
3+
Manages the clean startup and shutdown of a process. Register lifecycle components in the order they will be initialized, and then call `start()` on the lifecycle. When the lifecycle manager receives a SIGTERM or if the `close()` method is called, it will begin the shutdown process. The lifecycle manager closes each component in the reverse order of their registration

deno.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@antman/lifecycle",
3+
"version": "0.1.0",
4+
"exports": "./mod.ts",
5+
"tasks": {
6+
"dev": "deno test --watch"
7+
},
8+
"fmt": {
9+
"lineWidth": 110,
10+
"indentWidth": 2,
11+
"semiColons": true,
12+
"singleQuote": true
13+
},
14+
"license": "MIT",
15+
"imports": {
16+
}
17+
}

deno.lock

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./src/lifecycle.ts";

src/lifecycle.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { delay } from 'jsr:@std/async';
2+
import { expect } from 'jsr:@std/expect';
3+
import { Lifecycle, type LifecycleComponent } from './lifecycle.ts';
4+
5+
const createChecks = () => {
6+
const checks = {
7+
didStart: false,
8+
didRun: false,
9+
didClosing: false,
10+
didClose: false,
11+
};
12+
const passCheck = (check: keyof typeof checks) => () => (checks[check] = true);
13+
return { checks, passCheck };
14+
};
15+
16+
type EventName =
17+
| 'componentStarted'
18+
| 'componentRestarting'
19+
| 'componentRestarted'
20+
| 'componentClosing'
21+
| 'componentClosed';
22+
type Event = `${EventName} ${string}`;
23+
type Events = Event[];
24+
25+
type MakeEvent = (event: EventName) => Event;
26+
const makeEventFn = (name?: string): MakeEvent => (event) => `${event} ${name}`;
27+
28+
const makeComponent = (c?: Partial<LifecycleComponent>): LifecycleComponent => {
29+
const name = c?.name ?? 'test-component';
30+
const component: LifecycleComponent = {
31+
name,
32+
start: async () => (await delay(1)) ?? (component.status = 'running'),
33+
status: 'pending',
34+
close: () => delay(1),
35+
...c,
36+
};
37+
return component;
38+
};
39+
40+
const makeCrashingComponent = (
41+
c?: Partial<LifecycleComponent>,
42+
crashAfterMs = 10,
43+
): LifecycleComponent => {
44+
const component = makeComponent({
45+
start: async () => {
46+
(await delay(1)) ?? (component.status = 'running');
47+
delay(crashAfterMs).then(() => (component.status = 'crashed'));
48+
},
49+
restart: () => {
50+
component.status = 'running';
51+
return Promise.resolve();
52+
},
53+
...c,
54+
});
55+
return component;
56+
};
57+
58+
type EventTracker = (en: EventName) => [EventName, (cn?: string) => void];
59+
const setupEvents = (): [Events, EventTracker] => {
60+
const actual: Events = [];
61+
const trackActual = (e: Event) => actual.push(e);
62+
return [
63+
actual,
64+
(en: EventName) => [en, (cn?: string) => trackActual(makeEventFn(cn)(en))],
65+
];
66+
};
67+
68+
Deno.test('LifeCycle', async ({ step }) => {
69+
await step('can start & close a lifecycle', async () => {
70+
const { checks, passCheck } = createChecks();
71+
const lc = new Lifecycle({ healthCheckIntervalMs: 50 });
72+
lc.on('starting', passCheck('didStart'));
73+
lc.on('running', passCheck('didRun'));
74+
lc.on('closing', passCheck('didClosing'));
75+
lc.on('closed', passCheck('didClose'));
76+
await lc.start();
77+
await lc.close(false);
78+
expect(checks).toEqual({
79+
didStart: true,
80+
didRun: true,
81+
didClosing: true,
82+
didClose: true,
83+
});
84+
});
85+
await step('start and close lifecycle components', async () => {
86+
const [actual, actualEvent] = setupEvents();
87+
const lc = new Lifecycle({ healthCheckIntervalMs: 50 });
88+
lc.register(makeComponent({ name: 'test-component-1' }));
89+
lc.register(makeComponent({ name: 'test-component-2' }));
90+
lc.register(makeComponent({ name: 'test-component-3' }));
91+
92+
lc.on(...actualEvent('componentStarted'));
93+
lc.on(...actualEvent('componentClosing'));
94+
lc.on(...actualEvent('componentClosed'));
95+
await lc.start();
96+
await lc.close(false);
97+
await delay(2);
98+
expect(actual).toEqual([
99+
'componentStarted test-component-1',
100+
'componentStarted test-component-2',
101+
'componentStarted test-component-3',
102+
'componentClosing test-component-3',
103+
'componentClosed test-component-3',
104+
'componentClosing test-component-2',
105+
'componentClosed test-component-2',
106+
'componentClosing test-component-1',
107+
'componentClosed test-component-1',
108+
]);
109+
});
110+
await step('restarts crashed life cycle component', async () => {
111+
const [actual, actualEvent] = setupEvents();
112+
const lc = new Lifecycle({ healthCheckIntervalMs: 1 });
113+
114+
lc.register(makeComponent({ name: 'test-component-1' }));
115+
lc.register(makeComponent({ name: 'test-component-2' }));
116+
lc.register(makeCrashingComponent({ name: 'test-component-3' }, 4));
117+
lc.register(makeComponent({ name: 'test-component-4' }));
118+
lc.register(makeCrashingComponent({ name: 'test-component-5' }, 4));
119+
lc.on(...actualEvent('componentStarted'));
120+
lc.on(...actualEvent('componentClosing'));
121+
lc.on(...actualEvent('componentClosed'));
122+
lc.on(...actualEvent('componentRestarting'));
123+
lc.on(...actualEvent('componentRestarted'));
124+
await lc.start();
125+
await delay(15);
126+
await lc.close(false);
127+
await delay(30);
128+
expect(actual).toEqual([
129+
'componentStarted test-component-1',
130+
'componentStarted test-component-2',
131+
'componentStarted test-component-3',
132+
'componentStarted test-component-4',
133+
'componentStarted test-component-5',
134+
'componentRestarting test-component-3',
135+
'componentRestarted test-component-3',
136+
'componentRestarting test-component-5',
137+
'componentRestarted test-component-5',
138+
'componentClosing test-component-5',
139+
'componentClosed test-component-5',
140+
'componentClosing test-component-4',
141+
'componentClosed test-component-4',
142+
'componentClosing test-component-3',
143+
'componentClosed test-component-3',
144+
'componentClosing test-component-2',
145+
'componentClosed test-component-2',
146+
'componentClosing test-component-1',
147+
'componentClosed test-component-1',
148+
]);
149+
});
150+
});

0 commit comments

Comments
 (0)