wardens 0.1.0 → 0.4.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 CHANGED
@@ -6,7 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.1.0]
9
+ ## [0.4.0] - 2022-06-19
10
+
11
+ ### Added
12
+
13
+ - Support for provisioning resources through async functions instead of `Resource` subclasses. This offers better type safety around null conditions.
14
+ - A new `Resource` utility type is exported. The new functional API expects you to return this interface.
15
+
16
+ ### Removed
17
+
18
+ - The `Resource` abstract class was removed. Use async functions instead.
19
+ - The `Controls<...>` utility type was removed. Import the type you need from the module instead.
20
+
21
+ ## [0.3.0] - 2022-06-04
22
+
23
+ ### Changed
24
+
25
+ - Prevent use of `allocate(...)`/`deallocate(...)` outside a resource subclass.
26
+ - Renamed `enter()` and `leave()` to `create()` and `destroy()`.
27
+ - Renamed `mount()` and `unmount()` to `create()` and `destroy()`.
28
+
29
+ ### Removed
30
+
31
+ - Second type parameter to `Resource` is gone. Arguments to `enter(...)` are now inferred.
32
+ - No more default implementations for `enter(...)`/`leave(...)` on resources.
33
+
34
+ ## [0.2.0] - 2022-05-24
35
+
36
+ ### Fixed
37
+
38
+ - `mount(...)` and `allocate(...)` no longer require a config argument if the resource doesn't explicitly define one.
39
+
40
+ ### Added
41
+
42
+ - `enter(...)` now supports variable arguments.
43
+
44
+ ### Changed
45
+
46
+ - The second generic parameter of `Resource` was a config parameter, but now it's an argument tuple.
47
+ - The `ExternalControls` utility type was renamed to `Controls`.
48
+
49
+ ## [0.1.0] - 2022-05-22
10
50
 
11
51
  ### Added
12
52
 
@@ -14,5 +54,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
14
54
  - `mount`/`unmount` hooks to provision resources
15
55
  - `allocate`/`deallocate` for creating hierarchies of resources
16
56
 
17
- [Unreleased]: https://github.com/PsychoLlama/wardens/compare/v0.1.0...HEAD
57
+ [Unreleased]: https://github.com/PsychoLlama/wardens/compare/v0.4.0...HEAD
58
+ [0.4.0]: https://github.com/PsychoLlama/wardens/compare/v0.3.0...v0.4.0
59
+ [0.3.0]: https://github.com/PsychoLlama/wardens/compare/v0.2.0...v0.3.0
60
+ [0.2.0]: https://github.com/PsychoLlama/wardens/compare/v0.1.0...v0.2.0
18
61
  [0.1.0]: https://github.com/PsychoLlama/wardens/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
- # Wardens
2
-
3
- A tiny framework for managing resources.
1
+ <div align="center">
2
+ <h1>Wardens</h1>
3
+ <p>A tiny framework for managing resources.</p>
4
+ <img alt="Build status" src="https://img.shields.io/github/workflow/status/PsychoLlama/wardens/Test/main" />
5
+ <img alt="TypeScript" src="https://img.shields.io/npm/types/wardens" />
6
+ <img alt="npm version" src="https://img.shields.io/npm/v/wardens" />
7
+ </div>
4
8
 
5
9
  ## Overview
6
10
 
@@ -9,50 +13,40 @@ This library is designed for applications that dynamically provision and dealloc
9
13
  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
14
 
11
15
  ```typescript
12
- class Worker extends Resource<Thread> {
13
- thread!: Thread;
16
+ async function Worker() {
17
+ const thread = await spawn()
14
18
 
15
- // Called when the resource is created
16
- async enter() {
17
- this.thread = await spawn()
18
- }
19
+ return {
20
+ // The value returned after initialization completes
21
+ value: thread,
19
22
 
20
- // Called when the resource is destroyed
21
- async leave() {
22
- this.thread.close()
23
+ // Called when the resource is destroyed
24
+ destroy: () => thread.close(),
23
25
  }
24
-
25
- // The value returned after initialization completes
26
- exports = () => this.thread
27
26
  }
28
27
  ```
29
28
 
30
29
  Now define a pool that creates and manages workers:
31
30
 
32
31
  ```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)
32
+ async function WorkerPool({ create }: ResourceContext, config: { poolSize: number }) {
33
+ const promises = Array(config.poolSize).fill(Worker).map(create)
34
+ const threads = await Promise.all(promises)
35
+
36
+ return {
37
+ // ... External API goes here ...
38
+ value: {
39
+ doSomeWork() {},
40
+ doSomethingElse() {},
41
+ }
42
42
  }
43
-
44
- // ... External API goes here ...
45
- exports = (): Controls => ({
46
- doSomeWork() {},
47
- doSomethingElse() {},
48
- })
49
43
  }
50
44
  ```
51
45
 
52
- Finally, mount it:
46
+ Finally, create the pool:
53
47
 
54
48
  ```typescript
55
- const pool = await mount(WorkerPool, {
49
+ const pool = await create(WorkerPool, {
56
50
  poolSize: cpus().length,
57
51
  })
58
52
 
@@ -64,7 +58,7 @@ pool.doSomethingElse()
64
58
  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
59
 
66
60
  ```typescript
67
- await unmount(pool)
61
+ await destroy(pool)
68
62
 
69
63
  // [info] closing worker
70
64
  // [info] closing worker
@@ -1,3 +1,9 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => {
4
+ __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
5
+ return value;
6
+ };
1
7
  var __accessCheck = (obj, member, msg) => {
2
8
  if (!member.has(obj))
3
9
  throw TypeError("Cannot " + msg);
@@ -11,67 +17,12 @@ var __privateAdd = (obj, member, value) => {
11
17
  throw TypeError("Cannot add the same private member more than once");
12
18
  member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
13
19
  };
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();
20
+ var __privateSet = (obj, member, value, setter) => {
21
+ __accessCheck(obj, member, "write to private field");
22
+ setter ? setter.call(obj, value) : member.set(obj, value);
23
+ return value;
24
+ };
25
+ var _resources;
75
26
  function bindContext(value) {
76
27
  const methodBindings = /* @__PURE__ */ new WeakMap();
77
28
  return new Proxy(value, {
@@ -92,4 +43,60 @@ function bindContext(value) {
92
43
  }
93
44
  });
94
45
  }
95
- export { Resource, bindContext, mount, unmount };
46
+ const resources = /* @__PURE__ */ new WeakMap();
47
+ function wrapWithProxy(value) {
48
+ return Proxy.revocable(value, {});
49
+ }
50
+ class ResourceContext {
51
+ constructor(ownedResources) {
52
+ __privateAdd(this, _resources, void 0);
53
+ __publicField(this, "create", async (factory, ...args) => {
54
+ const controls = await create(factory, ...args);
55
+ __privateGet(this, _resources).add(controls);
56
+ return controls;
57
+ });
58
+ __publicField(this, "destroy", async (resource) => {
59
+ if (!__privateGet(this, _resources).has(resource)) {
60
+ throw new Error("You do not own this resource.");
61
+ }
62
+ try {
63
+ await destroy(resource);
64
+ } finally {
65
+ __privateGet(this, _resources).delete(resource);
66
+ }
67
+ });
68
+ __privateSet(this, _resources, ownedResources);
69
+ }
70
+ }
71
+ _resources = new WeakMap();
72
+ async function create(provision, ...args) {
73
+ const children = /* @__PURE__ */ new Set();
74
+ const context = new ResourceContext(children);
75
+ const resource = await provision(context, ...args);
76
+ const controls = resource.value;
77
+ const { proxy, revoke } = wrapWithProxy(controls);
78
+ resources.set(proxy, {
79
+ resource,
80
+ children,
81
+ revoke
82
+ });
83
+ return proxy;
84
+ }
85
+ async function destroy(controls) {
86
+ const entry = resources.get(controls);
87
+ if (entry) {
88
+ resources.delete(controls);
89
+ entry.revoke();
90
+ const recursiveUnmounts = Array.from(entry.children).map(destroy);
91
+ const results = await Promise.allSettled(recursiveUnmounts);
92
+ if (entry.resource.destroy) {
93
+ await entry.resource.destroy();
94
+ }
95
+ results.forEach((result) => {
96
+ if (result.status === "rejected") {
97
+ throw result.reason;
98
+ }
99
+ });
100
+ }
101
+ }
102
+ export { bindContext, create, destroy };
@@ -1 +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"}})});
1
+ var P=Object.defineProperty;var g=(e,t,n)=>t in e?P(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var y=(e,t,n)=>(g(e,typeof t!="symbol"?t+"":t,n),n),p=(e,t,n)=>{if(!t.has(e))throw TypeError("Cannot "+n)};var f=(e,t,n)=>(p(e,t,"read from private field"),n?n.call(e):t.get(e)),b=(e,t,n)=>{if(t.has(e))throw TypeError("Cannot add the same private member more than once");t instanceof WeakSet?t.add(e):t.set(e,n)},m=(e,t,n,u)=>(p(e,t,"write to private field"),u?u.call(e,n):t.set(e,n),n);(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;"use strict";function t(c){const o=new WeakMap;return new Proxy(c,{get(s,i){const r=Reflect.get(s,i,s);if(typeof r=="function"){if(o.has(r)===!1){const d=r.bind(s);Object.defineProperties(d,Object.getOwnPropertyDescriptors(r)),o.set(r,d)}return o.get(r)}return r},set(s,i,r){return Reflect.set(s,i,r,s)}})}const n=new WeakMap;function u(c){return Proxy.revocable(c,{})}class v{constructor(o){b(this,a,void 0);y(this,"create",async(o,...s)=>{const i=await h(o,...s);return f(this,a).add(i),i});y(this,"destroy",async o=>{if(!f(this,a).has(o))throw new Error("You do not own this resource.");try{await l(o)}finally{f(this,a).delete(o)}});m(this,a,o)}}a=new WeakMap;async function h(c,...o){const s=new Set,i=new v(s),r=await c(i,...o),d=r.value,{proxy:w,revoke:x}=u(d);return n.set(w,{resource:r,children:s,revoke:x}),w}async function l(c){const o=n.get(c);if(o){n.delete(c),o.revoke();const s=Array.from(o.children).map(l),i=await Promise.allSettled(s);o.resource.destroy&&await o.resource.destroy(),i.forEach(r=>{if(r.status==="rejected")throw r.reason})}}e.bindContext=t,e.create=h,e.destroy=l,Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wardens",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "A framework for resource management",
5
5
  "main": "./dist/wardens.umd.js",
6
6
  "module": "./dist/wardens.es.js",
@@ -11,6 +11,15 @@
11
11
  "dist",
12
12
  "src"
13
13
  ],
14
+ "keywords": [
15
+ "resource",
16
+ "management",
17
+ "manager",
18
+ "server",
19
+ "lifetimes",
20
+ "hierarchy",
21
+ "framework"
22
+ ],
14
23
  "scripts": {
15
24
  "prepare": "tsc && vite build",
16
25
  "test": "./bin/run-tests",
@@ -73,17 +82,17 @@
73
82
  "preset": "ts-jest"
74
83
  },
75
84
  "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",
85
+ "@types/jest": "28.1.2",
86
+ "@typescript-eslint/eslint-plugin": "5.28.0",
87
+ "@typescript-eslint/parser": "5.28.0",
88
+ "eslint": "8.18.0",
80
89
  "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",
90
+ "jest": "28.1.1",
91
+ "lint-staged": "13.0.2",
92
+ "prettier": "2.7.1",
93
+ "ts-jest": "28.0.5",
94
+ "typescript": "4.7.4",
95
+ "vite": "2.9.12",
87
96
  "vite-dts": "1.0.4"
88
97
  }
89
98
  }
@@ -1,87 +1,75 @@
1
- import Resource from '../resource';
2
- import { mount, unmount } from '../allocation';
1
+ import type ResourceContext from '../resource-context';
2
+ import { create, destroy } from '../allocation';
3
3
 
4
4
  describe('allocation', () => {
5
- describe('mount', () => {
5
+ describe('create', () => {
6
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);
7
+ const config = { test: 'init-args' };
8
+ const Test = jest.fn(
9
+ async (_resource: ResourceContext, config: { test: string }) => ({
10
+ value: config,
11
+ }),
12
+ );
13
+
14
+ await expect(create(Test, config)).resolves.toEqual(config);
15
+ expect(Test).toHaveBeenCalledWith(expect.anything(), config);
17
16
  });
18
17
  });
19
18
 
20
- describe('unmount', () => {
19
+ describe('destroy', () => {
21
20
  it('deallocates the resource', async () => {
22
- const leave = jest.fn();
23
-
24
- class Test extends Resource<Array<string>> {
25
- exports = () => [];
26
- leave = leave;
27
- }
21
+ const spy = jest.fn<void, []>();
22
+ const Test = async () => ({
23
+ value: {},
24
+ destroy: spy,
25
+ });
28
26
 
29
- const api = await mount(Test, null);
27
+ const test = await create(Test);
30
28
 
31
- expect(leave).not.toHaveBeenCalled();
32
- await expect(unmount(api)).resolves.not.toThrow();
33
- expect(leave).toHaveBeenCalled();
29
+ expect(spy).not.toHaveBeenCalled();
30
+ await expect(destroy(test)).resolves.not.toThrow();
31
+ expect(spy).toHaveBeenCalled();
34
32
  });
35
33
 
36
34
  it('survives if the resource is already deallocated', async () => {
37
- class Test extends Resource<Array<number>> {
38
- exports = () => [];
39
- }
35
+ const Test = async () => ({ value: [] });
40
36
 
41
- const api = await mount(Test, null);
42
- await unmount(api);
37
+ const test = await create(Test);
38
+ await destroy(test);
43
39
 
44
- await expect(unmount(api)).resolves.not.toThrow();
40
+ await expect(destroy(test)).resolves.not.toThrow();
45
41
  });
46
42
 
47
43
  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
- }
44
+ const spy = jest.fn();
45
+ const Child = async () => ({ value: [], destroy: spy });
46
+ async function Parent(resource: ResourceContext) {
47
+ await resource.create(Child);
48
+ return { value: [] };
59
49
  }
60
50
 
61
- const parent = await mount(Parent, null);
62
- await unmount(parent);
51
+ const parent = await create(Parent);
52
+ await destroy(parent);
63
53
 
64
- expect(leave).toHaveBeenCalled();
54
+ expect(spy).toHaveBeenCalled();
65
55
  });
66
56
 
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() {
57
+ it('throws an error if any of the children fail to close', async () => {
58
+ const error = new Error('Testing resource destruction errors');
59
+ const Child = async () => ({
60
+ value: [],
61
+ destroy() {
72
62
  throw error;
73
- }
74
- }
63
+ },
64
+ });
75
65
 
76
- class Parent extends Resource<string[]> {
77
- exports = () => [];
78
- async enter() {
79
- await this.allocate(Child, null);
80
- }
81
- }
66
+ const Parent = async (resource: ResourceContext) => {
67
+ await resource.create(Child);
68
+ return { value: [] };
69
+ };
82
70
 
83
- const parent = await mount(Parent, null);
84
- await expect(unmount(parent)).rejects.toThrow(error);
71
+ const parent = await create(Parent);
72
+ await expect(destroy(parent)).rejects.toThrow(error);
85
73
  });
86
74
  });
87
75
  });
@@ -0,0 +1,63 @@
1
+ import ResourceContext from '../resource-context';
2
+ import { create } from '../allocation';
3
+
4
+ describe('ResourceContext', () => {
5
+ async function Test() {
6
+ return { value: {} };
7
+ }
8
+
9
+ it('can spawn children of its own', async () => {
10
+ const Child = async () => ({
11
+ value: { child: true },
12
+ });
13
+
14
+ const Parent = async (resource: ResourceContext) => {
15
+ return { value: await resource.create(Child) };
16
+ };
17
+
18
+ await expect(create(Parent)).resolves.toEqual({ child: true });
19
+ });
20
+
21
+ it('can deallocate child resources on demand', async () => {
22
+ const spy = jest.fn();
23
+
24
+ const Child = async () => ({
25
+ value: { child: true },
26
+ destroy: spy,
27
+ });
28
+
29
+ const Parent = async (resource: ResourceContext) => {
30
+ const child = await resource.create(Child);
31
+ await resource.destroy(child);
32
+
33
+ return {
34
+ value: { parent: true },
35
+ };
36
+ };
37
+
38
+ await expect(create(Parent)).resolves.toEqual({ parent: true });
39
+ expect(spy).toHaveBeenCalled();
40
+ });
41
+
42
+ it('fails to destroy resources owned by someone else', async () => {
43
+ const test = await create(Test);
44
+
45
+ const Sneaky = async (resource: ResourceContext) => {
46
+ await resource.destroy(test);
47
+ return { value: {} };
48
+ };
49
+
50
+ await expect(create(Sneaky)).rejects.toThrow(/do not own/i);
51
+ });
52
+
53
+ it('binds create/destroy handlers to the class instance', async () => {
54
+ async function Allocator({ create, destroy }: ResourceContext) {
55
+ const test = await create(Test);
56
+ await destroy(test);
57
+
58
+ return { value: [] };
59
+ }
60
+
61
+ await expect(create(Allocator)).resolves.not.toThrow();
62
+ });
63
+ });
package/src/allocation.ts CHANGED
@@ -1,20 +1,28 @@
1
- import type Resource from './resource';
2
- import { resources, ownership } from './state';
1
+ import { resources } from './state';
3
2
  import wrap from './proxy';
3
+ import ResourceContext from './resource-context';
4
+ import { ResourceFactory, ParametrizedResourceFactory } from './types';
4
5
 
5
6
  /** 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);
7
+ export async function create<
8
+ Controls extends object,
9
+ Args extends Array<unknown>,
10
+ >(
11
+ provision:
12
+ | ParametrizedResourceFactory<Controls, Args>
13
+ | ResourceFactory<Controls>,
14
+ ...args: Args
15
+ ): Promise<Controls> {
16
+ const children: Set<object> = new Set();
17
+ const context = new ResourceContext(children);
18
+ const resource = await provision(context, ...args);
12
19
 
13
- const api = resource.exports();
14
- const { proxy, revoke } = wrap(api);
20
+ const controls = resource.value;
21
+ const { proxy, revoke } = wrap(controls);
15
22
 
16
23
  resources.set(proxy, {
17
24
  resource,
25
+ children,
18
26
  revoke,
19
27
  });
20
28
 
@@ -27,25 +35,24 @@ export async function mount<Api extends object, InitArgs>(
27
35
  *
28
36
  * @todo Add type marker to catch cases where the wrong object is unmounted.
29
37
  */
30
- export async function unmount(api: object) {
31
- const entry = resources.get(api);
38
+ export async function destroy(controls: object) {
39
+ const entry = resources.get(controls);
32
40
 
33
41
  if (entry) {
34
42
  // Instantly delete to prevent race conditions.
35
- resources.delete(api);
43
+ resources.delete(controls);
36
44
 
37
45
  // Free all references.
38
46
  entry.revoke();
39
47
 
40
- const children = ownership.get(entry.resource)!;
41
- ownership.delete(entry.resource);
42
-
43
48
  // Recursively close out the children first...
44
- const recursiveUnmounts = children.map((api) => unmount(api));
49
+ const recursiveUnmounts = Array.from(entry.children).map(destroy);
45
50
  const results = await Promise.allSettled(recursiveUnmounts);
46
51
 
47
52
  // Then close the parent.
48
- await entry.resource.leave();
53
+ if (entry.resource.destroy) {
54
+ await entry.resource.destroy();
55
+ }
49
56
 
50
57
  // Fail loudly if any of the children couldn't be deallocated.
51
58
  results.forEach((result) => {
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
- export { default as Resource, type ExternalControls } from './resource';
2
1
  export { default as bindContext } from './bind-context';
3
- export { mount, unmount } from './allocation';
2
+ export { create, destroy } from './allocation';
3
+ export type { Resource } from './types';
4
+ export type { default as ResourceContext } from './resource-context';
@@ -0,0 +1,44 @@
1
+ import { create, destroy } from './allocation';
2
+ import { ResourceFactory, ParametrizedResourceFactory } from './types';
3
+
4
+ /**
5
+ * An instance of this class is passed to resources as they're being
6
+ * provisioned. It allows them to provision other resources while keeping
7
+ * track of ownership and lifetimes.
8
+ */
9
+ export default class ResourceContext {
10
+ #resources: Set<object>;
11
+
12
+ constructor(ownedResources: Set<object>) {
13
+ this.#resources = ownedResources;
14
+ }
15
+
16
+ /** Provision an owned resource and make sure it doesn't outlive us. */
17
+ public create = async <Controls extends object, Args extends Array<unknown>>(
18
+ factory:
19
+ | ParametrizedResourceFactory<Controls, Args>
20
+ | ResourceFactory<Controls>,
21
+ ...args: Args
22
+ ): Promise<Controls> => {
23
+ const controls = await create(factory, ...args);
24
+ this.#resources.add(controls);
25
+
26
+ return controls;
27
+ };
28
+
29
+ /**
30
+ * Tear down a resource. Happens automatically when resource owners are
31
+ * deallocated.
32
+ */
33
+ public destroy = async (resource: object) => {
34
+ if (!this.#resources.has(resource)) {
35
+ throw new Error('You do not own this resource.');
36
+ }
37
+
38
+ try {
39
+ await destroy(resource);
40
+ } finally {
41
+ this.#resources.delete(resource);
42
+ }
43
+ };
44
+ }
package/src/state.ts CHANGED
@@ -1,7 +1,10 @@
1
- import type Resource from './resource';
1
+ import type { Resource } from './types';
2
2
 
3
3
  interface RevokableResource {
4
- resource: Resource<object, unknown>;
4
+ resource: Resource<object>;
5
+
6
+ /** All the resources this resource personally allocated. */
7
+ children: Set<object>;
5
8
 
6
9
  /**
7
10
  * Destroys outer references to the API and frees the object for garbage
@@ -12,9 +15,3 @@ interface RevokableResource {
12
15
 
13
16
  /** Maps an external API back to the resource that created it. */
14
17
  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
- >();
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import type ResourceContext from './resource-context';
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 interface ResourceFactory<Controls extends object> {
10
+ (resource: ResourceContext): Promise<Resource<Controls>>;
11
+ }
12
+
13
+ export interface ParametrizedResourceFactory<
14
+ Controls extends object,
15
+ Args extends Array<unknown>,
16
+ > {
17
+ (resource: ResourceContext, ...args: Args): Promise<Resource<Controls>>;
18
+ }
19
+
20
+ export interface Resource<Value extends object> {
21
+ /** The resource value returned by `create(...)`. */
22
+ value: Value;
23
+
24
+ /** A hook that gets called when the resource is destroyed. */
25
+ destroy?(): Promise<unknown> | unknown;
26
+ }
@@ -1,67 +0,0 @@
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
- });
package/src/resource.ts DELETED
@@ -1,63 +0,0 @@
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;