Skip to content

Commit 3a56dbb

Browse files
author
Viacheslav Bereza
committed
feat: make zones overrides and scoped async resources
1 parent 901a557 commit 3a56dbb

7 files changed

+239
-22
lines changed

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export { autorun, reaction, sync, when } from './reaction';
3535

3636
export { contextHook } from './contextHook';
3737
export { event, type Event } from './event';
38-
export { service, isolate, isolated, initAsyncHooksZonator } from './service';
38+
export { service, isolate, isolated, scoped, ScopedAsyncResource, initAsyncHooksZonator } from './service';
3939
export { hook, type HookWithParams, type HookWithoutParams } from './hook';
4040
export { untracked } from './untracked';
4141

src/service/ServiceHandler.ts

+41-11
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const getGlobalServicesRegistry = () => {
2727
return globalServicesRegistryByZones.get(zoneId)!;
2828
};
2929

30+
type Configure<T extends object> = (instance: T & ServiceInternal<T>) => void;
31+
3032
/**
3133
* Registry of all currently active services
3234
* Necessary for destroy all machanism
@@ -43,6 +45,17 @@ class ServiceState<T extends object> {
4345
* Set of destroy listeners
4446
*/
4547
public unsubs = unsubscriber();
48+
49+
constructor(
50+
/**
51+
* Service class can be overrided in child packages
52+
*/
53+
public FinalClass: Ctor<T>,
54+
/**
55+
* List of configures functions
56+
*/
57+
public configures: Configure<T>[] = [],
58+
) {}
4659
}
4760

4861
/**
@@ -55,14 +68,9 @@ export class ServiceHandler<T extends object> {
5568
private stateByZones = new Map<ZoneId, ServiceState<T>>();
5669

5770
/**
58-
* Service class can be overrided in child packages
59-
*/
60-
private FinalClass: Ctor<T>;
61-
62-
/**
63-
* List of configures functions
71+
* Root zone state
6472
*/
65-
private configures: ((instance: T & ServiceInternal<T>) => void)[] = [];
73+
private rootZoneState: ServiceState<T>;
6674

6775
/**
6876
* Proxy of service
@@ -73,7 +81,8 @@ export class ServiceHandler<T extends object> {
7381
* Service handler constructor
7482
*/
7583
constructor(Class: Ctor<T>) {
76-
this.FinalClass = Class;
84+
this.rootZoneState = new ServiceState<T>(Class);
85+
this.stateByZones.set(0, this.rootZoneState);
7786
this.proxy = this.createProxy();
7887
}
7988

@@ -190,7 +199,7 @@ export class ServiceHandler<T extends object> {
190199
const zoneId = getZoneId();
191200
let state = this.stateByZones.get(zoneId);
192201
if (!state) {
193-
state = new ServiceState();
202+
state = new ServiceState<T>(this.rootZoneState.FinalClass, [...this.rootZoneState.configures]);
194203
this.stateByZones.set(zoneId, state);
195204
}
196205
return state;
@@ -210,6 +219,27 @@ export class ServiceHandler<T extends object> {
210219
this.getStateByCurrentZone().rawInstance = value;
211220
}
212221

222+
/**
223+
* Zoned get final class
224+
*/
225+
private get finalClass(): Ctor<T> {
226+
return this.getStateByCurrentZone().FinalClass;
227+
}
228+
229+
/**
230+
* Zoned set final class
231+
*/
232+
private set finalClass(value: Ctor<T>) {
233+
this.getStateByCurrentZone().FinalClass = value;
234+
}
235+
236+
/**
237+
* Zoned get configures
238+
*/
239+
private get configures(): ((instance: T & ServiceInternal<T>) => void)[] {
240+
return this.getStateByCurrentZone().configures;
241+
}
242+
213243
/**
214244
* Zoned get unsubs
215245
*/
@@ -232,7 +262,7 @@ export class ServiceHandler<T extends object> {
232262
public ensureInstantiate() {
233263
if (!this.rawInstance) {
234264
untracked(() => {
235-
this.rawInstance = new this.FinalClass();
265+
this.rawInstance = new this.finalClass();
236266
this.register();
237267
this.runConfigures();
238268
this.runInit();
@@ -247,7 +277,7 @@ export class ServiceHandler<T extends object> {
247277
if (this.rawInstance) {
248278
throw new Error('You should override service before its instantiate');
249279
}
250-
this.FinalClass = OverridedClass;
280+
this.finalClass = OverridedClass;
251281
}
252282

253283
/**

src/service/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { service } from './service';
22
export { isolate, isolated } from './isolate';
3+
export { ScopedAsyncResource, scoped } from './resource';
34
export { initAsyncHooksZonator } from './zones';

src/service/isolate.test.ts

+71-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
22
import { beforeAll, expect, it, vi } from 'vitest';
33
import { service, un } from '..';
44
import { getZoneId } from './zones';
5-
import { initAsyncHooksZonator, isolate } from '.';
5+
import { initAsyncHooksZonator, isolate, ScopedAsyncResource } from '.';
66

77
beforeAll(async () => {
88
await initAsyncHooksZonator();
@@ -130,6 +130,76 @@ it('isolate works', async () => {
130130
expect(a.value).toBe(10);
131131
});
132132

133+
it('ScopedAsyncResource works', async () => {
134+
const destroy_spy = vi.fn();
135+
class A {
136+
value = 0;
137+
138+
init() {
139+
un(() => destroy_spy(this.value));
140+
}
141+
}
142+
143+
const a = service(A);
144+
145+
a.value = 10;
146+
147+
const zone_id_0 = getZoneId();
148+
149+
const asyncResource = new ScopedAsyncResource('test');
150+
151+
let storedResolve: (value: void) => void;
152+
const promise = new Promise<void>(resolve => (storedResolve = resolve));
153+
154+
asyncResource.runInAsyncScope(() => {
155+
expect(a.value).toBe(0);
156+
a.value = 11;
157+
158+
const zone_id_1 = getZoneId();
159+
expect(zone_id_1).not.toBe(zone_id_0);
160+
161+
// Test that nested async operation not change current zone
162+
process.nextTick(() =>
163+
(async () => {
164+
const zone_id_async_1 = getZoneId();
165+
expect(zone_id_async_1).toBe(zone_id_1);
166+
expect(a.value).toBe(11);
167+
168+
a.value = 12;
169+
expect(a.value).toBe(12);
170+
})().then(() => {
171+
storedResolve();
172+
}),
173+
);
174+
175+
expect(a.value).toBe(11);
176+
expect(destroy_spy).not.toBeCalled();
177+
});
178+
179+
await promise;
180+
181+
asyncResource.emitDestroy();
182+
expect(destroy_spy).toBeCalledWith(12);
183+
destroy_spy.mockClear();
184+
185+
expect(zone_id_0).toBe(getZoneId());
186+
expect(a.value).toBe(10);
187+
188+
// Next serial isolated resource
189+
const asyncResource2 = new ScopedAsyncResource('test2');
190+
asyncResource2.runInAsyncScope(() => {
191+
expect(a.value).toBe(0);
192+
a.value = 9;
193+
expect(destroy_spy).not.toBeCalled();
194+
});
195+
196+
asyncResource2.emitDestroy();
197+
expect(destroy_spy).toBeCalledWith(9);
198+
destroy_spy.mockClear();
199+
200+
expect(a.value).toBe(10);
201+
});
202+
133203
vi.mock('node:fs');
134204

135205
it('isolate log not destroyed nested resource', async () => {

src/service/resource.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { service } from './service';
2+
import { createIsolatedAsyncResourceClass } from './zones';
3+
4+
export type ScopedAsyncResource = InstanceType<typeof ScopedAsyncResource>;
5+
export const ScopedAsyncResource = createIsolatedAsyncResourceClass(() => service.destroy());
6+
7+
export function scoped<This extends ScopedAsyncResource, A extends unknown[] = unknown[], R = unknown>(
8+
target: (this: This, ...args: A) => R,
9+
_context: ClassMethodDecoratorContext<This, (this: This, ...args: A) => R>,
10+
) {
11+
return function (this: This, ...args: A) {
12+
return this.runInAsyncScope(target, this, ...args);
13+
};
14+
}

src/service/service.test.ts

+57
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, it, vi } from 'vitest';
22
import { Initable, signal, un, wrapSignal } from '..';
33
import { service } from './service';
44
import { getGlobalServicesRegistry } from './ServiceHandler';
5+
import { initAsyncHooksZonator, isolate } from '.';
56

67
it('service works', () => {
78
const create_spy = vi.fn();
@@ -247,3 +248,59 @@ it('destroy all should works', () => {
247248

248249
expect(getGlobalServicesRegistry().size).toBe(0);
249250
});
251+
252+
it('service isolation', async () => {
253+
await initAsyncHooksZonator();
254+
255+
class A {
256+
a = 10;
257+
test() {
258+
return this.a;
259+
}
260+
}
261+
262+
const a = service(A);
263+
264+
expect(a.test()).toBe(10);
265+
266+
await isolate(async () => {
267+
class B extends A {
268+
b = 5;
269+
test() {
270+
return this.a + this.b;
271+
}
272+
}
273+
274+
service.override(a, B);
275+
276+
expect(a.test()).toBe(15);
277+
});
278+
279+
expect(a.test()).toBe(10);
280+
281+
class D {
282+
a = 10;
283+
test() {
284+
return this.a;
285+
}
286+
}
287+
288+
const d = service(D);
289+
290+
class C extends D {
291+
c = 2;
292+
test() {
293+
return this.a - this.c;
294+
}
295+
}
296+
297+
service.override(d, C);
298+
299+
expect(d.test()).toBe(8);
300+
301+
await isolate(async () => {
302+
expect(d.test()).toBe(8);
303+
});
304+
305+
expect(d.test()).toBe(8);
306+
});

src/service/zones.ts

+54-9
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import { NamedLogger } from '../logger/logger';
44
const logger = new NamedLogger('zones');
55

66
type AsyncHooks = typeof AsyncHooksModule;
7+
type AsyncResource = AsyncHooksModule.AsyncResource;
78

89
export type ZoneId = number;
910

1011
const isNode = () => typeof window === 'undefined';
1112

1213
let nodeAsyncHooksModulePromise: Promise<AsyncHooks> | undefined;
14+
let asyncHooks!: AsyncHooks;
15+
1316
export const asyncHooksZonator = async () => {
14-
const asyncHooks = await (nodeAsyncHooksModulePromise ??= import('node:async_hooks'));
17+
asyncHooks ??= await (nodeAsyncHooksModulePromise ??= import('node:async_hooks'));
1518
const zoneIndex = new Map<number, ZoneId>();
1619
let zoneId: ZoneId = 0;
1720
asyncHooks
@@ -20,21 +23,30 @@ export const asyncHooksZonator = async () => {
2023
zoneIndex.set(asyncId, zoneIndex.get(triggerAsyncId) ?? 0);
2124
},
2225
before(asyncId) {
26+
// Called only before start new async context execution
2327
zoneId = zoneIndex.get(asyncId) ?? 0; // root zone
2428
},
29+
after() {
30+
// Called only after current async context execution backwards one level up
31+
zoneId = zoneIndex.get(asyncHooks.triggerAsyncId()) ?? 0; // root zone
32+
},
2533
destroy(asyncId) {
2634
zoneIndex.delete(asyncId);
2735
},
2836
})
2937
.enable();
3038
return {
3139
getZoneId: () => zoneId,
32-
ensureZone: () => {
33-
zoneId = asyncHooks.executionAsyncId();
34-
zoneIndex.set(zoneId, zoneId);
40+
ensureZone: (overrideZoneId?: ZoneId) => {
41+
if (overrideZoneId === undefined) {
42+
zoneId = asyncHooks.executionAsyncId();
43+
zoneIndex.set(zoneId, zoneId);
44+
} else {
45+
zoneIndex.set(overrideZoneId, overrideZoneId);
46+
}
3547
},
36-
createCheckDestroy: () => {
37-
const keepZoneId = zoneId;
48+
createCheckDestroy: (overrideZoneId?: ZoneId) => {
49+
const keepZoneId = overrideZoneId ?? zoneId;
3850
return () => {
3951
if (zoneIndex.has(keepZoneId)) {
4052
logger.warn('Isolation destroyed but zone has not fulfilled async resources');
@@ -57,11 +69,11 @@ export const initAsyncHooksZonator = async () => {
5769

5870
export interface Zonator {
5971
getZoneId(): ZoneId;
60-
ensureZone(): void; // Function for set current zone_id when new isolate starts
61-
createCheckDestroy(): () => void;
72+
ensureZone(overrideZoneId?: ZoneId): void; // Function for set current zone_id when new isolate starts
73+
createCheckDestroy(overrideZoneId?: ZoneId): () => void;
6274
}
6375

64-
let zonator: Zonator | undefined;
76+
let zonator!: Zonator;
6577

6678
export const getZoneId = () => zonator?.getZoneId() ?? 0;
6779

@@ -86,3 +98,36 @@ export const createIsolate =
8698
zonePromise.finally(() => checkZoneDestroy());
8799
return zonePromise;
88100
};
101+
102+
export const createIsolatedAsyncResourceClass = (destroy: () => void) => {
103+
return class IsolatedAsyncResource {
104+
private resource: AsyncResource;
105+
private zoneId: ZoneId;
106+
private checkZoneDestroy: () => void;
107+
108+
runInAsyncScope<This, A extends unknown[], R = unknown>(
109+
target: (this: This, ...args: A) => R,
110+
thisArg?: This,
111+
...args: A
112+
): R {
113+
return this.resource.runInAsyncScope(target, thisArg, ...args);
114+
}
115+
116+
constructor(name = 'IsolatedAsyncResource') {
117+
if (!zonator) {
118+
throw new Error('initAsyncHooksZonator should be called for isolate usage');
119+
}
120+
this.resource = new asyncHooks.AsyncResource(name, { requireManualDestroy: true });
121+
this.zoneId = this.resource.asyncId();
122+
zonator.ensureZone(this.zoneId);
123+
this.checkZoneDestroy = zonator.createCheckDestroy(this.zoneId);
124+
}
125+
126+
emitDestroy(): this {
127+
this.runInAsyncScope(destroy);
128+
this.resource.emitDestroy();
129+
this.checkZoneDestroy();
130+
return this;
131+
}
132+
};
133+
};

0 commit comments

Comments
 (0)