@zenfs/core 2.3.9 → 2.3.11

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.
@@ -21,6 +21,13 @@ export function _fnOpt(name, fn) {
21
21
  Object.defineProperty(fn, 'name', { value: name });
22
22
  return fn;
23
23
  }
24
+ function _isClass(func) {
25
+ if (!(func && func.constructor === Function) || func.prototype === undefined)
26
+ return false;
27
+ if (Function.prototype !== Object.getPrototypeOf(func))
28
+ return true;
29
+ return Object.getOwnPropertyNames(func.prototype).length > 1;
30
+ }
24
31
  /**
25
32
  * Checks that `options` object is valid for the file system options.
26
33
  * @category Backends and Configuration
@@ -41,7 +48,7 @@ export function checkOptions(backend, options) {
41
48
  throw err(withErrno('EINVAL', 'Missing required option: ' + optName));
42
49
  }
43
50
  const isType = (type, _ = value) => typeof type == 'function'
44
- ? Symbol.hasInstance in type && type.prototype
51
+ ? _isClass(type)
45
52
  ? value instanceof type
46
53
  : type(value)
47
54
  : typeof value === type || value?.constructor?.name === type;
@@ -33,11 +33,6 @@ export declare class PortFS<T extends RPC.Channel = RPC.Channel> extends PortFS_
33
33
  readonly channel: T;
34
34
  readonly timeout: number;
35
35
  readonly port: RPC.Port<T>;
36
- /**
37
- * A map of outstanding RPC requests
38
- * @internal @hidden
39
- */
40
- readonly _executors: Map<string, RPC.Executor>;
41
36
  /**
42
37
  * @hidden
43
38
  */
@@ -19,11 +19,6 @@ export class PortFS extends Async(FileSystem) {
19
19
  channel;
20
20
  timeout;
21
21
  port;
22
- /**
23
- * A map of outstanding RPC requests
24
- * @internal @hidden
25
- */
26
- _executors = new Map();
27
22
  /**
28
23
  * @hidden
29
24
  */
@@ -61,10 +56,8 @@ export class PortFS extends Async(FileSystem) {
61
56
  await this.rpc('touch', path, new Uint8Array(inode.buffer, inode.byteOffset, inode.byteLength));
62
57
  }
63
58
  async sync() {
59
+ await super.sync();
64
60
  await this.rpc('sync');
65
- for (const executor of this._executors.values()) {
66
- await executor.promise.catch(() => { });
67
- }
68
61
  }
69
62
  async createFile(path, options) {
70
63
  if (options instanceof Inode)
package/dist/config.d.ts CHANGED
@@ -87,3 +87,4 @@ export declare function addDevice(driver: DeviceDriver, options?: object): Devic
87
87
  * @see Configuration
88
88
  */
89
89
  export declare function configure<T extends ConfigMounts>(configuration: Partial<Configuration<T>>): Promise<void>;
90
+ export declare function sync(): Promise<void>;
package/dist/config.js CHANGED
@@ -65,7 +65,7 @@ export async function resolveMountConfig(configuration, _depth = 0) {
65
65
  * @category Backends and Configuration
66
66
  */
67
67
  export async function configureSingle(configuration) {
68
- if (!isBackendConfig(configuration)) {
68
+ if (!isMountConfig(configuration)) {
69
69
  throw new TypeError('Invalid single mount point configuration');
70
70
  }
71
71
  const resolved = await resolveMountConfig(configuration);
@@ -137,3 +137,7 @@ export async function configure(configuration) {
137
137
  await mount('/dev', devfs);
138
138
  }
139
139
  }
140
+ export async function sync() {
141
+ for (const fs of mounts.values())
142
+ await fs.sync();
143
+ }
@@ -30,6 +30,8 @@ export declare abstract class IndexFS extends FileSystem {
30
30
  protected create(path: string, options: CreationOptions): Inode;
31
31
  createFile(path: string, options: CreationOptions): Promise<InodeLike>;
32
32
  createFileSync(path: string, options: CreationOptions): InodeLike;
33
+ protected _mkdir?(path: string, options: CreationOptions): Promise<void>;
34
+ protected _mkdirSync?(path: string, options: CreationOptions): void;
33
35
  mkdir(path: string, options: CreationOptions): Promise<InodeLike>;
34
36
  mkdirSync(path: string, options: CreationOptions): InodeLike;
35
37
  link(target: string, link: string): Promise<void>;
@@ -38,31 +38,56 @@ export class IndexFS extends FileSystem {
38
38
  to = to.slice(0, -1);
39
39
  toRename.push({ from, to, inode });
40
40
  }
41
+ toRename.sort((a, b) => b.from.length - a.from.length);
41
42
  return toRename;
42
43
  }
43
44
  async rename(oldPath, newPath) {
44
45
  if (oldPath == newPath)
45
46
  return;
46
- for (const { from, to, inode } of this.pathsForRename(oldPath, newPath)) {
47
+ const toRename = this.pathsForRename(oldPath, newPath);
48
+ const contents = new Map();
49
+ for (const { from, to, inode } of toRename) {
47
50
  const data = new Uint8Array(inode.size);
48
51
  await this.read(from, data, 0, inode.size);
52
+ contents.set(to, data);
49
53
  this.index.delete(from);
54
+ await this.remove(from);
55
+ if (this.index.has(to))
56
+ await this.remove(to);
57
+ }
58
+ toRename.reverse();
59
+ for (const { to, inode } of toRename) {
60
+ const data = contents.get(to);
50
61
  this.index.set(to, inode);
51
- await this.write(to, data, 0);
62
+ if ((inode.mode & S_IFMT) == S_IFDIR)
63
+ await this._mkdir?.(to, inode);
64
+ else
65
+ await this.write(to, data, 0);
52
66
  }
53
- await this.remove(oldPath);
54
67
  }
55
68
  renameSync(oldPath, newPath) {
56
69
  if (oldPath == newPath)
57
70
  return;
58
- for (const { from, to, inode } of this.pathsForRename(oldPath, newPath)) {
71
+ const toRename = this.pathsForRename(oldPath, newPath);
72
+ const contents = new Map();
73
+ for (const { from, to, inode } of toRename) {
59
74
  const data = new Uint8Array(inode.size);
60
75
  this.readSync(from, data, 0, inode.size);
76
+ contents.set(to, data);
61
77
  this.index.delete(from);
78
+ this.removeSync(from);
79
+ if (this.index.has(to))
80
+ this.removeSync(to);
81
+ }
82
+ toRename.reverse();
83
+ for (const { to, inode } of toRename) {
84
+ const data = contents.get(to);
62
85
  this.index.set(to, inode);
63
- this.writeSync(to, data, 0);
86
+ if ((inode.mode & S_IFMT) == S_IFDIR)
87
+ this._mkdirSync?.(to, inode);
88
+ else
89
+ this.writeSync(to, data, 0);
64
90
  }
65
- this.removeSync(oldPath);
66
91
  }
67
92
  async stat(path) {
68
93
  const inode = this.index.get(path);
@@ -93,9 +118,8 @@ export class IndexFS extends FileSystem {
93
118
  throw withErrno('ENOTDIR');
94
119
  if (isDir && isUnlink)
95
120
  throw withErrno('EISDIR');
96
- if (isDir && this.readdirSync(path).length)
97
- throw withErrno('ENOTEMPTY');
98
- this.index.delete(path);
121
+ if (!isDir)
122
+ this.index.delete(path);
99
123
  }
100
124
  async unlink(path) {
101
125
  this._remove(path, true);
@@ -107,10 +131,17 @@ export class IndexFS extends FileSystem {
107
131
  }
108
132
  async rmdir(path) {
109
133
  this._remove(path, false);
134
+ const entries = await this.readdir(path);
135
+ if (entries.length)
136
+ throw withErrno('ENOTEMPTY');
137
+ this.index.delete(path);
110
138
  await this.remove(path);
111
139
  }
112
140
  rmdirSync(path) {
113
141
  this._remove(path, false);
142
+ if (this.readdirSync(path).length)
143
+ throw withErrno('ENOTEMPTY');
144
+ this.index.delete(path);
114
145
  this.removeSync(path);
115
146
  }
116
147
  create(path, options) {
@@ -142,11 +173,15 @@ export class IndexFS extends FileSystem {
142
173
  }
143
174
  async mkdir(path, options) {
144
175
  options.mode |= S_IFDIR;
145
- return this.create(path, options);
176
+ const inode = this.create(path, options);
177
+ await this._mkdir?.(path, options);
178
+ return inode;
146
179
  }
147
180
  mkdirSync(path, options) {
148
181
  options.mode |= S_IFDIR;
149
- return this.create(path, options);
182
+ const inode = this.create(path, options);
183
+ this._mkdirSync?.(path, options);
184
+ return inode;
150
185
  }
151
186
  link(target, link) {
152
187
  throw withErrno('ENOSYS');
@@ -10,14 +10,17 @@ export function isPort(port) {
10
10
  * Creates a new RPC port from a `Worker` or `MessagePort` that extends `EventTarget`
11
11
  */
12
12
  export function fromWeb(port) {
13
+ const _handlers = new Map();
13
14
  return {
14
15
  channel: port,
15
16
  send: port.postMessage.bind(port),
16
17
  addHandler(handler) {
17
- port.addEventListener('message', (event) => handler(event.data));
18
+ const _handler = (event) => handler(event.data);
19
+ _handlers.set(handler, _handler);
20
+ port.addEventListener('message', _handler);
18
21
  },
19
22
  removeHandler(handler) {
20
- port.removeEventListener('message', (event) => handler(event.data));
23
+ port.removeEventListener('message', _handlers.get(handler));
21
24
  },
22
25
  };
23
26
  }
@@ -133,7 +136,6 @@ function disposeExecutors(id) {
133
136
  if (typeof executor.timeout == 'object')
134
137
  executor.timeout.unref();
135
138
  }
136
- executor.fs._executors.delete(id);
137
139
  executors.delete(id);
138
140
  }
139
141
  /**
@@ -145,15 +147,14 @@ export function request(request, { port, timeout: ms = 1000, fs }) {
145
147
  if (!port)
146
148
  throw err(withErrno('EINVAL', 'Can not make an RPC request without a port'));
147
149
  const { resolve, reject, promise } = Promise.withResolvers();
148
- const id = Math.random().toString(16).slice(10);
150
+ const id = Math.random().toString(16).slice(5);
149
151
  const timeout = setTimeout(() => {
150
- const error = err(withErrno('EIO', 'RPC Failed'));
152
+ const error = err(withErrno('ETIMEDOUT', 'RPC request timed out'));
151
153
  error.stack += stack;
152
154
  disposeExecutors(id);
153
155
  reject(error);
154
156
  }, ms);
155
157
  const executor = { resolve, reject, promise, fs, timeout };
156
- fs._executors.set(id, executor);
157
158
  executors.set(id, executor);
158
159
  port.send({ ...request, _zenfs: true, id, stack });
159
160
  return promise;
@@ -20,10 +20,8 @@ export interface AsyncMixin extends Pick<FileSystem, Exclude<_SyncFSKeys, 'exist
20
20
  * @deprecated Use {@link sync | `sync`} instead
21
21
  */
22
22
  queueDone(): Promise<void>;
23
- /**
24
- * @deprecated Use {@link sync | `sync`} instead
25
- */
26
23
  ready(): Promise<void>;
24
+ sync(): Promise<void>;
27
25
  }
28
26
  /**
29
27
  * Async() implements synchronous methods on an asynchronous file system
@@ -29,8 +29,9 @@ export function Async(FS) {
29
29
  return this.sync();
30
30
  }
31
31
  _promise = Promise.resolve();
32
- _async(promise) {
33
- this._promise = this._promise.then(() => promise);
32
+ _async(thunk) {
33
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
34
+ this._promise = this._promise.finally(() => thunk());
34
35
  }
35
36
  _isInitialized = false;
36
37
  /** Tracks how many updates to the sync. cache we skipped during initialization */
@@ -41,9 +42,9 @@ export function Async(FS) {
41
42
  }
42
43
  async ready() {
43
44
  await super.ready();
44
- await this._promise;
45
45
  if (this._isInitialized || this.attributes.has('no_async_preload'))
46
46
  return;
47
+ await this._promise;
47
48
  this.checkSync();
48
49
  await this._sync.ready();
49
50
  // optimization: for 2 storeFS', we copy at a lower abstraction level.
@@ -79,7 +80,7 @@ export function Async(FS) {
79
80
  renameSync(oldPath, newPath) {
80
81
  this.checkSync();
81
82
  this._sync.renameSync(oldPath, newPath);
82
- this._async(this.rename(oldPath, newPath));
83
+ this._async(() => this.rename(oldPath, newPath));
83
84
  }
84
85
  statSync(path) {
85
86
  this.checkSync();
@@ -88,27 +89,29 @@ export function Async(FS) {
88
89
  touchSync(path, metadata) {
89
90
  this.checkSync();
90
91
  this._sync.touchSync(path, metadata);
91
- this._async(this.touch(path, metadata));
92
+ this._async(() => this.touch(path, metadata));
92
93
  }
93
94
  createFileSync(path, options) {
94
95
  this.checkSync();
95
- this._async(this.createFile(path, options));
96
- return this._sync.createFileSync(path, options);
96
+ const result = this._sync.createFileSync(path, options);
97
+ this._async(() => this.createFile(path, options));
98
+ return result;
97
99
  }
98
100
  unlinkSync(path) {
99
101
  this.checkSync();
100
- this._async(this.unlink(path));
101
102
  this._sync.unlinkSync(path);
103
+ this._async(() => this.unlink(path));
102
104
  }
103
105
  rmdirSync(path) {
104
106
  this.checkSync();
105
107
  this._sync.rmdirSync(path);
106
- this._async(this.rmdir(path));
108
+ this._async(() => this.rmdir(path));
107
109
  }
108
110
  mkdirSync(path, options) {
109
111
  this.checkSync();
110
- this._async(this.mkdir(path, options));
111
- return this._sync.mkdirSync(path, options);
112
+ const result = this._sync.mkdirSync(path, options);
113
+ this._async(() => this.mkdir(path, options));
114
+ return result;
112
115
  }
113
116
  readdirSync(path) {
114
117
  this.checkSync();
@@ -117,12 +120,12 @@ export function Async(FS) {
117
120
  linkSync(srcpath, dstpath) {
118
121
  this.checkSync();
119
122
  this._sync.linkSync(srcpath, dstpath);
120
- this._async(this.link(srcpath, dstpath));
123
+ this._async(() => this.link(srcpath, dstpath));
121
124
  }
122
125
  async sync() {
123
126
  if (!this.attributes.has('no_async_preload') && this._sync)
124
127
  this._sync.syncSync();
125
- await this._promise;
128
+ await this._promise.catch(() => { });
126
129
  }
127
130
  syncSync() {
128
131
  this.checkSync();
@@ -139,7 +142,7 @@ export function Async(FS) {
139
142
  writeSync(path, buffer, offset) {
140
143
  this.checkSync();
141
144
  this._sync.writeSync(path, buffer, offset);
142
- this._async(this.write(path, buffer, offset));
145
+ this._async(() => this.write(path, buffer, offset));
143
146
  }
144
147
  streamWrite(path, options) {
145
148
  this.checkSync();
@@ -186,18 +189,27 @@ export function Async(FS) {
186
189
  * Patch all async methods to also call their synchronous counterparts unless called from themselves (either sync or async)
187
190
  */
188
191
  _patchAsync() {
189
- debug(`Async: patched ${_asyncFSKeys.length} methods`);
190
- for (const key of _asyncFSKeys) {
192
+ const noPatch = ['read', 'readdir', 'stat', 'exists'];
193
+ const toPatch = _asyncFSKeys.filter(key => !noPatch.includes(key));
194
+ for (const key of toPatch) {
191
195
  // TS does not narrow the union based on the key
192
196
  const originalMethod = this[key].bind(this);
193
- this[key] = async (...args) => {
194
- const result = await originalMethod(...args);
195
- const stack = new Error().stack.split('\n').slice(2).join('\n');
197
+ function isInLoop(depth, error) {
198
+ if (!error) {
199
+ error = new Error();
200
+ Error.captureStackTrace(error, isInLoop);
201
+ }
202
+ if (!error.stack)
203
+ return false;
204
+ const stack = error.stack.split('\n').slice(depth).join('\n');
196
205
  // From the async queue
197
- if (!stack
198
- || stack.includes(`at <computed> [as ${key}]`)
206
+ return (stack.includes(`at <computed> [as ${key}]`)
199
207
  || stack.includes(`at async <computed> [as ${key}]`)
200
- || stack.includes(`${key}Sync `))
208
+ || stack.includes(`${key}Sync `));
209
+ }
210
+ this[key] = async (...args) => {
211
+ const result = await originalMethod(...args);
212
+ if (isInLoop(2))
201
213
  return result;
202
214
  if (!this._isInitialized) {
203
215
  this._skippedCacheUpdates++;
@@ -208,10 +220,7 @@ export function Async(FS) {
208
220
  this._sync?.[`${key}Sync`]?.(...args);
209
221
  }
210
222
  catch (e) {
211
- const stack = e.stack.split('\n').slice(3).join('\n');
212
- if (stack.includes(`at <computed> [as ${key}]`)
213
- || stack.includes(`at async <computed> [as ${key}]`)
214
- || stack.includes(`${key}Sync `))
223
+ if (isInLoop(3, e))
215
224
  return result;
216
225
  e.message += ' (Out of sync!)';
217
226
  throw err(e);
@@ -219,6 +228,7 @@ export function Async(FS) {
219
228
  return result;
220
229
  };
221
230
  }
231
+ debug(`Async: patched ${toPatch.length} methods`);
222
232
  }
223
233
  }
224
234
  return AsyncFS;
@@ -49,7 +49,7 @@ export declare class _MutexedFS<T extends FileSystem> implements FileSystem {
49
49
  * If the path is currently locked, waits for it to be unlocked.
50
50
  * @internal
51
51
  */
52
- lock(): Promise<MutexLock>;
52
+ lock(timeout?: number): Promise<MutexLock>;
53
53
  /**
54
54
  * Locks `path` asynchronously.
55
55
  * If the path is currently locked, an error will be thrown
@@ -135,7 +135,7 @@ export class _MutexedFS {
135
135
  * If the path is currently locked, waits for it to be unlocked.
136
136
  * @internal
137
137
  */
138
- async lock() {
138
+ async lock(timeout = 5000) {
139
139
  const previous = this.currentLock;
140
140
  const lock = this.addLock();
141
141
  const stack = new Error().stack;
@@ -145,7 +145,7 @@ export class _MutexedFS {
145
145
  error.stack += stack?.slice('Error'.length);
146
146
  throw err(error);
147
147
  }
148
- }, 5000);
148
+ }, timeout);
149
149
  await previous?.done();
150
150
  return lock;
151
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenfs/core",
3
- "version": "2.3.9",
3
+ "version": "2.3.11",
4
4
  "description": "A filesystem, anywhere",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -60,7 +60,7 @@
60
60
  "format": "prettier --write .",
61
61
  "format:check": "prettier --check .",
62
62
  "lint": "eslint src tests",
63
- "test": "npx zenfs-test --clean; npx zenfs-test -abcfp; tests/fetch/run.sh; npx zenfs-test --report",
63
+ "test": "npx zenfs-test --clean; npx zenfs-test -abcp; tests/fetch/run.sh; npx zenfs-test --report",
64
64
  "build": "tsc -p tsconfig.json",
65
65
  "build:docs": "typedoc",
66
66
  "dev": "npm run build -- --watch",
@@ -12,7 +12,7 @@ setupLogs();
12
12
  const timeoutChannel = new MessageChannel();
13
13
  timeoutChannel.port2.unref();
14
14
 
15
- await suite('Timeout', { timeout: 1000 }, () => {
15
+ await suite('Timeout', () => {
16
16
  test('Misconfiguration', async () => {
17
17
  const configured = configure({
18
18
  mounts: {
@@ -21,13 +21,13 @@ await suite('Timeout', { timeout: 1000 }, () => {
21
21
  },
22
22
  });
23
23
 
24
- await assert.rejects(configured, { code: 'EIO', message: /RPC Failed/ });
24
+ await assert.rejects(configured, { code: 'ETIMEDOUT' });
25
25
  });
26
26
 
27
27
  test('Remote not attached', async () => {
28
28
  const configured = configureSingle({ backend: Port, port: timeoutChannel.port1, timeout: 100 });
29
29
 
30
- await assert.rejects(configured, { code: 'EIO', message: /RPC Failed/ });
30
+ await assert.rejects(configured, { code: 'ETIMEDOUT' });
31
31
  });
32
32
 
33
33
  after(() => {
@@ -20,11 +20,11 @@ suite('Mutexed FS', () => {
20
20
  let lock1Resolved = false;
21
21
  let lock2Resolved = false;
22
22
 
23
- const lock1 = fs.lock().then(lock => {
23
+ const lock1 = fs.lock(100).then(lock => {
24
24
  lock1Resolved = true;
25
25
  lock.unlock();
26
26
  });
27
- const lock2 = fs.lock().then(lock => {
27
+ const lock2 = fs.lock(100).then(lock => {
28
28
  lock2Resolved = true;
29
29
  lock.unlock();
30
30
  });
@@ -50,7 +50,7 @@ suite('Mutexed FS', () => {
50
50
  let x = 1;
51
51
 
52
52
  async function foo() {
53
- const lock = await fs.lock();
53
+ const lock = await fs.lock(100);
54
54
  await wait(25);
55
55
  x++;
56
56
  lock.unlock();
package/tests/common.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import { join, resolve } from 'node:path';
2
2
  import { fs as defaultFS } from '../dist/index.js';
3
3
  import { setupLogs } from './logs.js';
4
+ import { styleText } from 'node:util';
4
5
  export type * from '../dist/index.js';
5
6
 
6
7
  setupLogs();
7
8
 
8
9
  const setupPath = resolve(process.env.SETUP || join(import.meta.dirname, 'setup/memory.ts'));
9
10
 
11
+ process.on('unhandledRejection', (reason: Error) => {
12
+ console.error('Unhandled rejection:', styleText('red', reason.stack || reason.message));
13
+ });
14
+
10
15
  const setup = await import(setupPath).catch(error => {
11
16
  console.log('Failed to import test setup:');
12
17
  throw error;
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
3
3
  import type { OpenMode, PathLike } from 'node:fs';
4
4
  import { suite, test } from 'node:test';
5
5
  import { promisify } from 'node:util';
6
- import { fs, type Callback } from '../common.js';
6
+ import { sync } from '../../dist/config.js';
7
+ import { fs } from '../common.js';
7
8
 
8
9
  const filepath = 'x.txt';
9
10
  const expected = 'xyz\n';
@@ -73,6 +74,8 @@ suite('read', () => {
73
74
  const path = '/text.txt';
74
75
 
75
76
  fs.writeFileSync(path, 'hello world');
77
+ await sync();
78
+
76
79
  const fd: number = (await promisify<PathLike, OpenMode, number | string>(fs.open)(path, 0, 0)) as any;
77
80
 
78
81
  const read = promisify(fs.read);
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { suite, test } from 'node:test';
3
+ import { sync } from '../../dist/config.js';
3
4
  import { fs } from '../common.js';
4
5
 
5
6
  const n_files = 130;
@@ -14,6 +15,7 @@ suite('Scaling', () => {
14
15
  fs.writeFileSync('/n/' + i, i.toString(16));
15
16
  }
16
17
 
18
+ await sync();
17
19
  assert.equal(fs.readdirSync('/n').length, n_files);
18
20
 
19
21
  const results = [];
@@ -1,6 +1,6 @@
1
- import { after } from 'node:test';
1
+ import { after, afterEach } from 'node:test';
2
2
  import { MessageChannel } from 'node:worker_threads';
3
- import { InMemory, Port, configureSingle, fs, resolveMountConfig, resolveRemoteMount } from '../../dist/index.js';
3
+ import { InMemory, Port, configureSingle, fs, resolveMountConfig, resolveRemoteMount, sync } from '../../dist/index.js';
4
4
  import { copySync, data } from '../setup.js';
5
5
 
6
6
  const { port1: localPort, port2: remotePort } = new MessageChannel();
@@ -15,6 +15,8 @@ await resolveRemoteMount(remotePort, tmpfs);
15
15
 
16
16
  await configureSingle({ backend: Port, port: localPort });
17
17
 
18
+ afterEach(sync);
19
+
18
20
  after(() => {
19
21
  localPort.close();
20
22
  remotePort.close();