wardens 0.1.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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/wardens.es.d.ts +1 -0
- package/dist/wardens.es.js +95 -0
- package/dist/wardens.umd.d.ts +1 -0
- package/dist/wardens.umd.js +1 -0
- package/package.json +89 -0
- package/src/__tests__/allocation.test.ts +87 -0
- package/src/__tests__/bind-context.test.ts +47 -0
- package/src/__tests__/resource.test.ts +67 -0
- package/src/allocation.ts +57 -0
- package/src/bind-context.ts +52 -0
- package/src/index.ts +3 -0
- package/src/proxy.ts +24 -0
- package/src/resource.ts +63 -0
- package/src/state.ts +20 -0
package/CHANGELOG.md
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
6
|
+
|
7
|
+
## [Unreleased]
|
8
|
+
|
9
|
+
## [0.1.0]
|
10
|
+
|
11
|
+
### Added
|
12
|
+
|
13
|
+
- Resource class for modeling asynchronously provisioned resources
|
14
|
+
- `mount`/`unmount` hooks to provision resources
|
15
|
+
- `allocate`/`deallocate` for creating hierarchies of resources
|
16
|
+
|
17
|
+
[Unreleased]: https://github.com/PsychoLlama/wardens/compare/v0.1.0...HEAD
|
18
|
+
[0.1.0]: https://github.com/PsychoLlama/wardens/releases/tag/v0.1.0
|
package/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2022 Jesse Gibson
|
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.
|
package/README.md
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
# Wardens
|
2
|
+
|
3
|
+
A tiny framework for managing resources.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
This library is designed for applications that dynamically provision and deallocate hierarchical resources over time.
|
8
|
+
|
9
|
+
Here's an example: let's say you've got a thread pool, one per CPU. Each thread gets a `Resource`, a small wrapper that hooks into setup and teardown controls.
|
10
|
+
|
11
|
+
```typescript
|
12
|
+
class Worker extends Resource<Thread> {
|
13
|
+
thread!: Thread;
|
14
|
+
|
15
|
+
// Called when the resource is created
|
16
|
+
async enter() {
|
17
|
+
this.thread = await spawn()
|
18
|
+
}
|
19
|
+
|
20
|
+
// Called when the resource is destroyed
|
21
|
+
async leave() {
|
22
|
+
this.thread.close()
|
23
|
+
}
|
24
|
+
|
25
|
+
// The value returned after initialization completes
|
26
|
+
exports = () => this.thread
|
27
|
+
}
|
28
|
+
```
|
29
|
+
|
30
|
+
Now define a pool that creates and manages workers:
|
31
|
+
|
32
|
+
```typescript
|
33
|
+
class WorkerPool extends Resource<Controls, Config> {
|
34
|
+
threads!: Array<Thread> = [];
|
35
|
+
|
36
|
+
async enter({ poolSize }: Config) {
|
37
|
+
const promises = Array(poolSize).fill().map(() => {
|
38
|
+
return this.allocate(Worker)
|
39
|
+
})
|
40
|
+
|
41
|
+
this.threads = await Promise.all(promises)
|
42
|
+
}
|
43
|
+
|
44
|
+
// ... External API goes here ...
|
45
|
+
exports = (): Controls => ({
|
46
|
+
doSomeWork() {},
|
47
|
+
doSomethingElse() {},
|
48
|
+
})
|
49
|
+
}
|
50
|
+
```
|
51
|
+
|
52
|
+
Finally, mount it:
|
53
|
+
|
54
|
+
```typescript
|
55
|
+
const pool = await mount(WorkerPool, {
|
56
|
+
poolSize: cpus().length,
|
57
|
+
})
|
58
|
+
|
59
|
+
// Provisioned and ready to go!
|
60
|
+
pool.doSomeWork()
|
61
|
+
pool.doSomethingElse()
|
62
|
+
```
|
63
|
+
|
64
|
+
The magic of this framework is that resources never outlive their owners. If you tear down the pool, it will deallocate everything beneath it first:
|
65
|
+
|
66
|
+
```typescript
|
67
|
+
await unmount(pool)
|
68
|
+
|
69
|
+
// [info] closing worker
|
70
|
+
// [info] closing worker
|
71
|
+
// [info] closing worker
|
72
|
+
// [info] closing worker
|
73
|
+
// [info] closing pool
|
74
|
+
```
|
75
|
+
|
76
|
+
No more forgotten resources.
|
77
|
+
|
78
|
+
## Summary
|
79
|
+
|
80
|
+
The framework can be used to manage small pieces of stateful logic in your application, or it can scale to manage your entire server. Use the paradigm as much or as little as you like.
|
81
|
+
|
82
|
+
I built this for my own projects. Documentation is a bit sparse, but enough GitHub stars could change that. This is a bribe.
|
@@ -0,0 +1 @@
|
|
1
|
+
export * from "../src/index"
|
@@ -0,0 +1,95 @@
|
|
1
|
+
var __accessCheck = (obj, member, msg) => {
|
2
|
+
if (!member.has(obj))
|
3
|
+
throw TypeError("Cannot " + msg);
|
4
|
+
};
|
5
|
+
var __privateGet = (obj, member, getter) => {
|
6
|
+
__accessCheck(obj, member, "read from private field");
|
7
|
+
return getter ? getter.call(obj) : member.get(obj);
|
8
|
+
};
|
9
|
+
var __privateAdd = (obj, member, value) => {
|
10
|
+
if (member.has(obj))
|
11
|
+
throw TypeError("Cannot add the same private member more than once");
|
12
|
+
member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
|
13
|
+
};
|
14
|
+
var _resources, _children;
|
15
|
+
const resources = /* @__PURE__ */ new WeakMap();
|
16
|
+
const ownership = /* @__PURE__ */ new WeakMap();
|
17
|
+
function wrapWithProxy(value) {
|
18
|
+
return Proxy.revocable(value, {});
|
19
|
+
}
|
20
|
+
async function mount(Subtype, params) {
|
21
|
+
const resource = new Subtype();
|
22
|
+
await resource.enter(params);
|
23
|
+
const api = resource.exports();
|
24
|
+
const { proxy, revoke } = wrapWithProxy(api);
|
25
|
+
resources.set(proxy, {
|
26
|
+
resource,
|
27
|
+
revoke
|
28
|
+
});
|
29
|
+
return proxy;
|
30
|
+
}
|
31
|
+
async function unmount(api) {
|
32
|
+
const entry = resources.get(api);
|
33
|
+
if (entry) {
|
34
|
+
resources.delete(api);
|
35
|
+
entry.revoke();
|
36
|
+
const children = ownership.get(entry.resource);
|
37
|
+
ownership.delete(entry.resource);
|
38
|
+
const recursiveUnmounts = children.map((api2) => unmount(api2));
|
39
|
+
const results = await Promise.allSettled(recursiveUnmounts);
|
40
|
+
await entry.resource.leave();
|
41
|
+
results.forEach((result) => {
|
42
|
+
if (result.status === "rejected") {
|
43
|
+
throw result.reason;
|
44
|
+
}
|
45
|
+
});
|
46
|
+
}
|
47
|
+
}
|
48
|
+
class Resource {
|
49
|
+
constructor() {
|
50
|
+
__privateAdd(this, _resources, /* @__PURE__ */ new WeakSet());
|
51
|
+
__privateAdd(this, _children, []);
|
52
|
+
ownership.set(this, __privateGet(this, _children));
|
53
|
+
}
|
54
|
+
async enter(_params) {
|
55
|
+
return;
|
56
|
+
}
|
57
|
+
async leave() {
|
58
|
+
return;
|
59
|
+
}
|
60
|
+
async allocate(Child, params) {
|
61
|
+
const api = await mount(Child, params);
|
62
|
+
__privateGet(this, _resources).add(api);
|
63
|
+
__privateGet(this, _children).push(api);
|
64
|
+
return api;
|
65
|
+
}
|
66
|
+
async deallocate(api) {
|
67
|
+
if (!__privateGet(this, _resources).has(api)) {
|
68
|
+
throw new Error("You do not own this resource.");
|
69
|
+
}
|
70
|
+
return unmount(api);
|
71
|
+
}
|
72
|
+
}
|
73
|
+
_resources = new WeakMap();
|
74
|
+
_children = new WeakMap();
|
75
|
+
function bindContext(value) {
|
76
|
+
const methodBindings = /* @__PURE__ */ new WeakMap();
|
77
|
+
return new Proxy(value, {
|
78
|
+
get(target, property) {
|
79
|
+
const value2 = Reflect.get(target, property, target);
|
80
|
+
if (typeof value2 === "function") {
|
81
|
+
if (methodBindings.has(value2) === false) {
|
82
|
+
const methodBinding = value2.bind(target);
|
83
|
+
Object.defineProperties(methodBinding, Object.getOwnPropertyDescriptors(value2));
|
84
|
+
methodBindings.set(value2, methodBinding);
|
85
|
+
}
|
86
|
+
return methodBindings.get(value2);
|
87
|
+
}
|
88
|
+
return value2;
|
89
|
+
},
|
90
|
+
set(target, property, newValue) {
|
91
|
+
return Reflect.set(target, property, newValue, target);
|
92
|
+
}
|
93
|
+
});
|
94
|
+
}
|
95
|
+
export { Resource, bindContext, mount, unmount };
|
@@ -0,0 +1 @@
|
|
1
|
+
export * from "../src/index"
|
@@ -0,0 +1 @@
|
|
1
|
+
var b=(e,t,c)=>{if(!t.has(e))throw TypeError("Cannot "+c)};var f=(e,t,c)=>(b(e,t,"read from private field"),c?c.call(e):t.get(e)),h=(e,t,c)=>{if(t.has(e))throw TypeError("Cannot add the same private member more than once");t instanceof WeakSet?t.add(e):t.set(e,c)};(function(e,t){typeof exports=="object"&&typeof module!="undefined"?t(exports):typeof define=="function"&&define.amd?define(["exports"],t):(e=typeof globalThis!="undefined"?globalThis:e||self,t(e.wardens={}))})(this,function(e){var a,d;"use strict";const t=new WeakMap,c=new WeakMap;function w(i){return Proxy.revocable(i,{})}async function p(i,n){const o=new i;await o.enter(n);const s=o.exports(),{proxy:r,revoke:u}=w(s);return t.set(r,{resource:o,revoke:u}),r}async function l(i){const n=t.get(i);if(n){t.delete(i),n.revoke();const o=c.get(n.resource);c.delete(n.resource);const s=o.map(u=>l(u)),r=await Promise.allSettled(s);await n.resource.leave(),r.forEach(u=>{if(u.status==="rejected")throw u.reason})}}class y{constructor(){h(this,a,new WeakSet);h(this,d,[]);c.set(this,f(this,d))}async enter(n){}async leave(){}async allocate(n,o){const s=await p(n,o);return f(this,a).add(s),f(this,d).push(s),s}async deallocate(n){if(!f(this,a).has(n))throw new Error("You do not own this resource.");return l(n)}}a=new WeakMap,d=new WeakMap;function m(i){const n=new WeakMap;return new Proxy(i,{get(o,s){const r=Reflect.get(o,s,o);if(typeof r=="function"){if(n.has(r)===!1){const u=r.bind(o);Object.defineProperties(u,Object.getOwnPropertyDescriptors(r)),n.set(r,u)}return n.get(r)}return r},set(o,s,r){return Reflect.set(o,s,r,o)}})}e.Resource=y,e.bindContext=m,e.mount=p,e.unmount=l,Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
|
package/package.json
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
{
|
2
|
+
"name": "wardens",
|
3
|
+
"version": "0.1.0",
|
4
|
+
"description": "A framework for resource management",
|
5
|
+
"main": "./dist/wardens.umd.js",
|
6
|
+
"module": "./dist/wardens.es.js",
|
7
|
+
"repository": "git@github.com:PsychoLlama/wardens.git",
|
8
|
+
"author": "Jesse Gibson <JesseTheGibson@gmail.com>",
|
9
|
+
"license": "MIT",
|
10
|
+
"files": [
|
11
|
+
"dist",
|
12
|
+
"src"
|
13
|
+
],
|
14
|
+
"scripts": {
|
15
|
+
"prepare": "tsc && vite build",
|
16
|
+
"test": "./bin/run-tests",
|
17
|
+
"test:lint": "eslint src --color",
|
18
|
+
"test:unit": "jest --color",
|
19
|
+
"test:fmt": "prettier --check src --color",
|
20
|
+
"dev": "vite build --watch"
|
21
|
+
},
|
22
|
+
"exports": {
|
23
|
+
".": {
|
24
|
+
"require": "./dist/wardens.umd.js",
|
25
|
+
"import": "./dist/wardens.es.js"
|
26
|
+
}
|
27
|
+
},
|
28
|
+
"husky": {
|
29
|
+
"hooks": {
|
30
|
+
"pre-commit": "lint-staged"
|
31
|
+
}
|
32
|
+
},
|
33
|
+
"lint-staged": {
|
34
|
+
"*.tsx?": [
|
35
|
+
"eslint",
|
36
|
+
"prettier --check"
|
37
|
+
]
|
38
|
+
},
|
39
|
+
"prettier": {
|
40
|
+
"singleQuote": true,
|
41
|
+
"trailingComma": "all"
|
42
|
+
},
|
43
|
+
"eslintConfig": {
|
44
|
+
"parser": "@typescript-eslint/parser",
|
45
|
+
"parserOptions": {
|
46
|
+
"sourceType": "module"
|
47
|
+
},
|
48
|
+
"overrides": [
|
49
|
+
{
|
50
|
+
"files": [
|
51
|
+
"./**/__tests__/*.ts{x,}"
|
52
|
+
],
|
53
|
+
"env": {
|
54
|
+
"jest": true
|
55
|
+
},
|
56
|
+
"rules": {
|
57
|
+
"@typescript-eslint/no-explicit-any": "off"
|
58
|
+
}
|
59
|
+
}
|
60
|
+
],
|
61
|
+
"extends": [
|
62
|
+
"eslint:recommended",
|
63
|
+
"plugin:@typescript-eslint/recommended"
|
64
|
+
],
|
65
|
+
"rules": {
|
66
|
+
"@typescript-eslint/explicit-module-boundary-types": "off",
|
67
|
+
"@typescript-eslint/no-non-null-assertion": "off",
|
68
|
+
"@typescript-eslint/no-unused-vars": "error",
|
69
|
+
"no-prototype-builtins": "off"
|
70
|
+
}
|
71
|
+
},
|
72
|
+
"jest": {
|
73
|
+
"preset": "ts-jest"
|
74
|
+
},
|
75
|
+
"devDependencies": {
|
76
|
+
"@types/jest": "27.5.1",
|
77
|
+
"@typescript-eslint/eslint-plugin": "5.25.0",
|
78
|
+
"@typescript-eslint/parser": "5.25.0",
|
79
|
+
"eslint": "8.16.0",
|
80
|
+
"husky": "8.0.1",
|
81
|
+
"jest": "28.1.0",
|
82
|
+
"lint-staged": "12.4.1",
|
83
|
+
"prettier": "2.6.2",
|
84
|
+
"ts-jest": "28.0.2",
|
85
|
+
"typescript": "4.6.4",
|
86
|
+
"vite": "2.9.9",
|
87
|
+
"vite-dts": "1.0.4"
|
88
|
+
}
|
89
|
+
}
|
@@ -0,0 +1,87 @@
|
|
1
|
+
import Resource from '../resource';
|
2
|
+
import { mount, unmount } from '../allocation';
|
3
|
+
|
4
|
+
describe('allocation', () => {
|
5
|
+
describe('mount', () => {
|
6
|
+
it('allocates the resource', async () => {
|
7
|
+
const params = { test: 'init-args' };
|
8
|
+
const enter = jest.fn();
|
9
|
+
|
10
|
+
class Test extends Resource<{ test: boolean }, { test: string }> {
|
11
|
+
exports = () => ({ test: true });
|
12
|
+
enter = enter;
|
13
|
+
}
|
14
|
+
|
15
|
+
await expect(mount(Test, params)).resolves.toEqual({ test: true });
|
16
|
+
expect(enter).toHaveBeenCalledWith(params);
|
17
|
+
});
|
18
|
+
});
|
19
|
+
|
20
|
+
describe('unmount', () => {
|
21
|
+
it('deallocates the resource', async () => {
|
22
|
+
const leave = jest.fn();
|
23
|
+
|
24
|
+
class Test extends Resource<Array<string>> {
|
25
|
+
exports = () => [];
|
26
|
+
leave = leave;
|
27
|
+
}
|
28
|
+
|
29
|
+
const api = await mount(Test, null);
|
30
|
+
|
31
|
+
expect(leave).not.toHaveBeenCalled();
|
32
|
+
await expect(unmount(api)).resolves.not.toThrow();
|
33
|
+
expect(leave).toHaveBeenCalled();
|
34
|
+
});
|
35
|
+
|
36
|
+
it('survives if the resource is already deallocated', async () => {
|
37
|
+
class Test extends Resource<Array<number>> {
|
38
|
+
exports = () => [];
|
39
|
+
}
|
40
|
+
|
41
|
+
const api = await mount(Test, null);
|
42
|
+
await unmount(api);
|
43
|
+
|
44
|
+
await expect(unmount(api)).resolves.not.toThrow();
|
45
|
+
});
|
46
|
+
|
47
|
+
it('automatically unmounts all children', async () => {
|
48
|
+
const leave = jest.fn();
|
49
|
+
class Child extends Resource<number[]> {
|
50
|
+
exports = () => [];
|
51
|
+
leave = leave;
|
52
|
+
}
|
53
|
+
|
54
|
+
class Parent extends Resource<string[]> {
|
55
|
+
exports = () => [];
|
56
|
+
async enter() {
|
57
|
+
await this.allocate(Child, null);
|
58
|
+
}
|
59
|
+
}
|
60
|
+
|
61
|
+
const parent = await mount(Parent, null);
|
62
|
+
await unmount(parent);
|
63
|
+
|
64
|
+
expect(leave).toHaveBeenCalled();
|
65
|
+
});
|
66
|
+
|
67
|
+
it('throws an error if any of the children fail to unmount', async () => {
|
68
|
+
const error = new Error('Testing unmount errors');
|
69
|
+
class Child extends Resource<number[]> {
|
70
|
+
exports = () => [];
|
71
|
+
async leave() {
|
72
|
+
throw error;
|
73
|
+
}
|
74
|
+
}
|
75
|
+
|
76
|
+
class Parent extends Resource<string[]> {
|
77
|
+
exports = () => [];
|
78
|
+
async enter() {
|
79
|
+
await this.allocate(Child, null);
|
80
|
+
}
|
81
|
+
}
|
82
|
+
|
83
|
+
const parent = await mount(Parent, null);
|
84
|
+
await expect(unmount(parent)).rejects.toThrow(error);
|
85
|
+
});
|
86
|
+
});
|
87
|
+
});
|
@@ -0,0 +1,47 @@
|
|
1
|
+
import bind from '../bind-context';
|
2
|
+
|
3
|
+
describe('Method rebinding', () => {
|
4
|
+
it('redirects getters to the correct `this` value', () => {
|
5
|
+
class PrivateStore {
|
6
|
+
#hidden = 'hidden';
|
7
|
+
|
8
|
+
get value() {
|
9
|
+
return this.#hidden;
|
10
|
+
}
|
11
|
+
|
12
|
+
set value(value: string) {
|
13
|
+
this.#hidden = value;
|
14
|
+
}
|
15
|
+
}
|
16
|
+
|
17
|
+
const store = bind(new PrivateStore());
|
18
|
+
expect(store.value).toBe('hidden');
|
19
|
+
store.value = 'new value';
|
20
|
+
expect(store.value).toBe('new value');
|
21
|
+
|
22
|
+
const set = bind(new Set());
|
23
|
+
expect(set.size).toBe(0);
|
24
|
+
});
|
25
|
+
|
26
|
+
it('rebinds methods to provide correct context', () => {
|
27
|
+
const proxy = bind(new Set<number>());
|
28
|
+
expect(() => proxy.add(5)).not.toThrow();
|
29
|
+
expect(proxy.has(5)).toBe(true);
|
30
|
+
});
|
31
|
+
|
32
|
+
it('maintains function identity for bound methods', () => {
|
33
|
+
const proxy = bind(new Set());
|
34
|
+
|
35
|
+
expect(proxy.add).toBe(proxy.add);
|
36
|
+
});
|
37
|
+
|
38
|
+
it('copies extended function properties', () => {
|
39
|
+
const proxy = bind({ mock: jest.fn() });
|
40
|
+
|
41
|
+
// Mocks use state held on the function itself. This must be added to the
|
42
|
+
// bound functions as well.
|
43
|
+
expect(proxy.mock).not.toHaveBeenCalled();
|
44
|
+
proxy.mock();
|
45
|
+
expect(proxy.mock).toHaveBeenCalledTimes(1);
|
46
|
+
});
|
47
|
+
});
|
@@ -0,0 +1,67 @@
|
|
1
|
+
import Resource, { type ExternalControls } from '../resource';
|
2
|
+
import { mount } from '../allocation';
|
3
|
+
|
4
|
+
describe('Resource', () => {
|
5
|
+
class Test extends Resource<Record<string, never>> {
|
6
|
+
exports = () => ({});
|
7
|
+
}
|
8
|
+
|
9
|
+
it('implements default enter/leave methods', async () => {
|
10
|
+
const test = new Test();
|
11
|
+
|
12
|
+
await expect(test.enter()).resolves.not.toThrow();
|
13
|
+
await expect(test.leave()).resolves.not.toThrow();
|
14
|
+
});
|
15
|
+
|
16
|
+
it('can spawn children of its own', async () => {
|
17
|
+
class Child extends Resource<{ child: boolean }> {
|
18
|
+
exports = () => ({ child: true });
|
19
|
+
}
|
20
|
+
|
21
|
+
class Parent extends Resource<ExternalControls<Child>> {
|
22
|
+
exports = () => this.child;
|
23
|
+
child!: ExternalControls<Child>;
|
24
|
+
|
25
|
+
async enter() {
|
26
|
+
this.child = await this.allocate(Child, null);
|
27
|
+
}
|
28
|
+
}
|
29
|
+
|
30
|
+
await expect(mount(Parent, null)).resolves.toEqual({ child: true });
|
31
|
+
});
|
32
|
+
|
33
|
+
it('can deallocate child resources on demand', async () => {
|
34
|
+
const leave = jest.fn();
|
35
|
+
|
36
|
+
class Child extends Resource<{ child: boolean }> {
|
37
|
+
exports = () => ({ child: true });
|
38
|
+
leave = leave;
|
39
|
+
}
|
40
|
+
|
41
|
+
class Parent extends Resource<{ parent: boolean }> {
|
42
|
+
exports = () => ({ parent: true });
|
43
|
+
child!: ExternalControls<Child>;
|
44
|
+
|
45
|
+
async enter() {
|
46
|
+
const child = await this.allocate(Child, null);
|
47
|
+
await this.deallocate(child);
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
await expect(mount(Parent, null)).resolves.toEqual({ parent: true });
|
52
|
+
expect(leave).toHaveBeenCalled();
|
53
|
+
});
|
54
|
+
|
55
|
+
it('fails to destroy resources owned by someone else', async () => {
|
56
|
+
const test = await mount(Test, null);
|
57
|
+
|
58
|
+
class Sneaky extends Resource<string[]> {
|
59
|
+
exports = () => [];
|
60
|
+
async enter() {
|
61
|
+
await this.deallocate(test);
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
await expect(mount(Sneaky, null)).rejects.toThrow(/do not own/i);
|
66
|
+
});
|
67
|
+
});
|
@@ -0,0 +1,57 @@
|
|
1
|
+
import type Resource from './resource';
|
2
|
+
import { resources, ownership } from './state';
|
3
|
+
import wrap from './proxy';
|
4
|
+
|
5
|
+
/** Provision a resource and return its external API. */
|
6
|
+
export async function mount<Api extends object, InitArgs>(
|
7
|
+
Subtype: new () => Resource<Api, InitArgs>,
|
8
|
+
params: InitArgs,
|
9
|
+
): Promise<Api> {
|
10
|
+
const resource = new Subtype();
|
11
|
+
await resource.enter(params);
|
12
|
+
|
13
|
+
const api = resource.exports();
|
14
|
+
const { proxy, revoke } = wrap(api);
|
15
|
+
|
16
|
+
resources.set(proxy, {
|
17
|
+
resource,
|
18
|
+
revoke,
|
19
|
+
});
|
20
|
+
|
21
|
+
return proxy;
|
22
|
+
}
|
23
|
+
|
24
|
+
/**
|
25
|
+
* Tear down the resource and all its children, permanently destroying the
|
26
|
+
* reference.
|
27
|
+
*
|
28
|
+
* @todo Add type marker to catch cases where the wrong object is unmounted.
|
29
|
+
*/
|
30
|
+
export async function unmount(api: object) {
|
31
|
+
const entry = resources.get(api);
|
32
|
+
|
33
|
+
if (entry) {
|
34
|
+
// Instantly delete to prevent race conditions.
|
35
|
+
resources.delete(api);
|
36
|
+
|
37
|
+
// Free all references.
|
38
|
+
entry.revoke();
|
39
|
+
|
40
|
+
const children = ownership.get(entry.resource)!;
|
41
|
+
ownership.delete(entry.resource);
|
42
|
+
|
43
|
+
// Recursively close out the children first...
|
44
|
+
const recursiveUnmounts = children.map((api) => unmount(api));
|
45
|
+
const results = await Promise.allSettled(recursiveUnmounts);
|
46
|
+
|
47
|
+
// Then close the parent.
|
48
|
+
await entry.resource.leave();
|
49
|
+
|
50
|
+
// Fail loudly if any of the children couldn't be deallocated.
|
51
|
+
results.forEach((result) => {
|
52
|
+
if (result.status === 'rejected') {
|
53
|
+
throw result.reason;
|
54
|
+
}
|
55
|
+
});
|
56
|
+
}
|
57
|
+
}
|
@@ -0,0 +1,52 @@
|
|
1
|
+
/**
|
2
|
+
* The framework's usage of proxies can cause havoc on classes that depend on
|
3
|
+
* true private fields, such as Map, Set, and some networking classes in Node's
|
4
|
+
* core library, among others. If you run into those challenges, `bindContext`
|
5
|
+
* can dynamically correct the errors by retargeting the `this` context to the
|
6
|
+
* original value.
|
7
|
+
*
|
8
|
+
* Contexts aren't rebound by default because it has a noticeable side-effect:
|
9
|
+
* method identity is no longer the same. This can be very irritating in unit
|
10
|
+
* tests that assert a stable function identity.
|
11
|
+
*
|
12
|
+
* Example:
|
13
|
+
*
|
14
|
+
* exports = () => bindContext(new Set())
|
15
|
+
*
|
16
|
+
*/
|
17
|
+
export default function bindContext<T extends object>(value: T) {
|
18
|
+
const methodBindings = new WeakMap<Fn, Fn>();
|
19
|
+
|
20
|
+
return new Proxy(value, {
|
21
|
+
get(target, property) {
|
22
|
+
const value = Reflect.get(target, property, target);
|
23
|
+
|
24
|
+
// Bind methods to the real `this` context while maintaining
|
25
|
+
// a consistent function identity.
|
26
|
+
if (typeof value === 'function') {
|
27
|
+
if (methodBindings.has(value) === false) {
|
28
|
+
const methodBinding = value.bind(target);
|
29
|
+
|
30
|
+
// Copy static function properties.
|
31
|
+
Object.defineProperties(
|
32
|
+
methodBinding,
|
33
|
+
Object.getOwnPropertyDescriptors(value),
|
34
|
+
);
|
35
|
+
|
36
|
+
methodBindings.set(value, methodBinding);
|
37
|
+
}
|
38
|
+
|
39
|
+
return methodBindings.get(value);
|
40
|
+
}
|
41
|
+
|
42
|
+
return value;
|
43
|
+
},
|
44
|
+
|
45
|
+
set(target, property, newValue) {
|
46
|
+
return Reflect.set(target, property, newValue, target);
|
47
|
+
},
|
48
|
+
});
|
49
|
+
}
|
50
|
+
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
52
|
+
type Fn = (...args: any) => any;
|
package/src/index.ts
ADDED
package/src/proxy.ts
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
/**
|
2
|
+
* Wrap objects in a revocable proxy. Revocation offers guarantees about
|
3
|
+
* use-after-free and avoids memory leaks. Perhaps more importantly, it
|
4
|
+
* provides a unique identity for each API exposed by a resource, which allows
|
5
|
+
* us to map an API back to the resource that created it. The consequence of
|
6
|
+
* object identity is important.
|
7
|
+
*
|
8
|
+
* Consider: If a resource provisions and re-exports another resource, when
|
9
|
+
* you go to deallocate the parent, the API maps back to the child and
|
10
|
+
* completely misses the parent.
|
11
|
+
*
|
12
|
+
* We magically skirt that issue by wrapping everything in a proxy and thus
|
13
|
+
* assigning a new identity every time. Of course, all magic comes at a price.
|
14
|
+
* The penalty here is `this` binding. Private fields and exotic objects
|
15
|
+
* (`Map`, `Set`, some Node tools) strictly depend on the `this` context being
|
16
|
+
* itself, not a proxy. Methods can throw very confusing errors because they
|
17
|
+
* can't get at private state.
|
18
|
+
*
|
19
|
+
* The bind-context utility is exposed as a workaround. Alternatively, you can
|
20
|
+
* export a wrapping object instead: `{ value: T }`.
|
21
|
+
*/
|
22
|
+
export default function wrapWithProxy<T extends object>(value: T) {
|
23
|
+
return Proxy.revocable(value, {});
|
24
|
+
}
|
package/src/resource.ts
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
import { mount, unmount } from './allocation';
|
2
|
+
import { ownership } from './state';
|
3
|
+
|
4
|
+
/**
|
5
|
+
* Represents an arbitrary stateful resource that is asynchronously provisioned
|
6
|
+
* and destroyed. Resources can own other resources, and destroying a parent
|
7
|
+
* first tears down the children.
|
8
|
+
*/
|
9
|
+
export default abstract class Resource<
|
10
|
+
ExternalApi extends object,
|
11
|
+
InitArgs = void,
|
12
|
+
> {
|
13
|
+
#resources = new WeakSet<object>();
|
14
|
+
#children: Array<object> = [];
|
15
|
+
|
16
|
+
constructor() {
|
17
|
+
ownership.set(this, this.#children);
|
18
|
+
}
|
19
|
+
|
20
|
+
/** A hook that gets called when the resource is created. */
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
22
|
+
async enter(_params: InitArgs) {
|
23
|
+
return;
|
24
|
+
}
|
25
|
+
|
26
|
+
/** A hook that gets called when the resource is destroyed. */
|
27
|
+
async leave() {
|
28
|
+
return;
|
29
|
+
}
|
30
|
+
|
31
|
+
/** Provision an owned resource and make sure it doesn't outlive us. */
|
32
|
+
async allocate<Api extends object, Params>(
|
33
|
+
Child: new () => Resource<Api, Params>,
|
34
|
+
params: Params,
|
35
|
+
): Promise<Api> {
|
36
|
+
const api = await mount(Child, params);
|
37
|
+
this.#resources.add(api);
|
38
|
+
this.#children.push(api);
|
39
|
+
|
40
|
+
return api;
|
41
|
+
}
|
42
|
+
|
43
|
+
/**
|
44
|
+
* Tear down a resource. Happens automatically when resource owners are
|
45
|
+
* deallocated.
|
46
|
+
*/
|
47
|
+
async deallocate(api: object) {
|
48
|
+
if (!this.#resources.has(api)) {
|
49
|
+
throw new Error('You do not own this resource.');
|
50
|
+
}
|
51
|
+
|
52
|
+
return unmount(api);
|
53
|
+
}
|
54
|
+
|
55
|
+
/** Returns an external API to the parent resource. */
|
56
|
+
abstract exports(): ExternalApi;
|
57
|
+
}
|
58
|
+
|
59
|
+
/** The `exports` type for a resource. */
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
61
|
+
export type ExternalControls<ArbitraryResource extends Resource<any, any>> =
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
63
|
+
ArbitraryResource extends Resource<infer Api, any> ? Api : never;
|
package/src/state.ts
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
import type Resource from './resource';
|
2
|
+
|
3
|
+
interface RevokableResource {
|
4
|
+
resource: Resource<object, unknown>;
|
5
|
+
|
6
|
+
/**
|
7
|
+
* Destroys outer references to the API and frees the object for garbage
|
8
|
+
* collection.
|
9
|
+
*/
|
10
|
+
revoke(): void;
|
11
|
+
}
|
12
|
+
|
13
|
+
/** Maps an external API back to the resource that created it. */
|
14
|
+
export const resources = new WeakMap<object, RevokableResource>();
|
15
|
+
|
16
|
+
/** Maps a resource to all the other resources it provisioned. */
|
17
|
+
export const ownership = new WeakMap<
|
18
|
+
Resource<object, unknown>,
|
19
|
+
Array<object>
|
20
|
+
>();
|