@uniflowed/host 0.0.0-alpha.7 → 0.0.0-alpha.8
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/module-mocks.js +356 -0
- package/package.json +4 -1
- package/write-atomically.js +54 -0
package/module-mocks.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this is part of the loader, and `bun-preload.js` imports
|
|
4
|
+
// it before any transform exists.
|
|
5
|
+
//
|
|
6
|
+
// Standing a module in for another one, at the only place that can do it: the
|
|
7
|
+
// loader. `@uniflowed/test`'s `uft.mock` is the API; this is the mechanism, and
|
|
8
|
+
// it lives here because by the time the runner sees an `import` the module has
|
|
9
|
+
// already been fetched, linked and evaluated.
|
|
10
|
+
//
|
|
11
|
+
// # Why these are *synchronous* hooks
|
|
12
|
+
//
|
|
13
|
+
// `./register.js` installs Node's asynchronous customization hooks, and those
|
|
14
|
+
// run on a loader thread of their own. That is right for the transform, which
|
|
15
|
+
// only needs the file's bytes — and useless for a mock, which is a value the
|
|
16
|
+
// test built: a spy the test holds a reference to cannot be sent to another
|
|
17
|
+
// thread, and a registry written on the main thread is not a registry the
|
|
18
|
+
// loader thread can read.
|
|
19
|
+
//
|
|
20
|
+
// So interception uses `node:module`'s `registerHooks`, which run in the thread
|
|
21
|
+
// that is doing the importing. They see this module's `mocks` map directly, and
|
|
22
|
+
// they chain into the asynchronous hooks for everything they do not claim — so
|
|
23
|
+
// a module that is not mocked is still transformed, still cached on disk, and
|
|
24
|
+
// still costs exactly what it cost before.
|
|
25
|
+
//
|
|
26
|
+
// # Why a mocked module gets a URL of its own
|
|
27
|
+
//
|
|
28
|
+
// A worker runs one file at a time and reuses the process, and Node's module
|
|
29
|
+
// registry is keyed by URL and never forgets. If a mocked module were served
|
|
30
|
+
// under its own URL, the *next* file in that worker would import the plain
|
|
31
|
+
// specifier and be handed the previous file's stand-in — a mock leaking across
|
|
32
|
+
// files, which is the one failure `docs/architecture.md` says the runner exists
|
|
33
|
+
// to prevent.
|
|
34
|
+
//
|
|
35
|
+
// So a mock does not replace a module; it redirects to a new one. Registering a
|
|
36
|
+
// mock takes the next revision number, the resolve hook appends it to the URL,
|
|
37
|
+
// and the mocked module is a different module from the real one rather than the
|
|
38
|
+
// same module with different contents. `unmock` stops appending, so the real
|
|
39
|
+
// URL — which may still hold the real module — is what the next import gets.
|
|
40
|
+
// Revisions are handed out for the life of the worker and never reused, which
|
|
41
|
+
// is what makes a cleared registry actually clear.
|
|
42
|
+
//
|
|
43
|
+
// The same parameter carries the module *epoch*. A module that imports a mocked
|
|
44
|
+
// one is standing in for it too — `consumer.js` computed its exports from
|
|
45
|
+
// whatever `client.js` gave it — so registering a mock, removing one, or
|
|
46
|
+
// calling `uft.resetModules` all start a new epoch, and every module reached by
|
|
47
|
+
// a path is evaluated again on the next import of it. Epochs are handed out for
|
|
48
|
+
// the life of the worker and never reused, for the reason revisions are not:
|
|
49
|
+
// the file after this one must not be able to land on a URL this one filled.
|
|
50
|
+
//
|
|
51
|
+
// The epoch is appended only to modules reached by a path specifier (`./x.js`,
|
|
52
|
+
// `/srv/x.js`): a bare specifier is a package, and handing a second copy of
|
|
53
|
+
// `@uniflowed/test` to a test file would give it a second registry, a second
|
|
54
|
+
// set of spies, and two of everything this package assumes there is one of. A
|
|
55
|
+
// *mocked* package still gets a URL of its own, from its revision — being
|
|
56
|
+
// stood in for is exactly the case where identity has to be given up.
|
|
57
|
+
|
|
58
|
+
import * as nodeModule from "node:module";
|
|
59
|
+
|
|
60
|
+
/** The URL parameter carrying `<module epoch>.<mock revision>`. */
|
|
61
|
+
export const REVISION_PARAM = "uf-modules";
|
|
62
|
+
|
|
63
|
+
/** The URL parameter marking an import that must reach the real module. */
|
|
64
|
+
export const ACTUAL_PARAM = "uf-actual";
|
|
65
|
+
|
|
66
|
+
/** This module's own URL, which a generated stand-in imports its values from. */
|
|
67
|
+
const SELF = import.meta.url;
|
|
68
|
+
|
|
69
|
+
/** Registered mocks, by module key. */
|
|
70
|
+
const mocks = new Map();
|
|
71
|
+
|
|
72
|
+
/** Namespaces handed to a generated module, by the exact URL it was loaded as. */
|
|
73
|
+
const served = new Map();
|
|
74
|
+
|
|
75
|
+
/** How many mocks have ever been registered in this process. */
|
|
76
|
+
let revisions = 0;
|
|
77
|
+
|
|
78
|
+
/** How many epochs have ever been started in this process. */
|
|
79
|
+
let epochs = 0;
|
|
80
|
+
|
|
81
|
+
/** The epoch this file is in; `0` until it does something that starts one. */
|
|
82
|
+
let epoch = 0;
|
|
83
|
+
|
|
84
|
+
/** The installed hooks, or `null` when interception is not installed. */
|
|
85
|
+
let handle = null;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A module's identity for the purpose of mocking: its URL with no query.
|
|
89
|
+
*
|
|
90
|
+
* The query is where every mechanism in this file writes — the worker's
|
|
91
|
+
* cache-busting `uf-run`, this file's revision, `importActual`'s marker — so
|
|
92
|
+
* two URLs that differ only there are the same module as far as a mock is
|
|
93
|
+
* concerned.
|
|
94
|
+
*/
|
|
95
|
+
export function moduleKey(url) {
|
|
96
|
+
const parsed = new URL(url);
|
|
97
|
+
parsed.search = "";
|
|
98
|
+
parsed.hash = "";
|
|
99
|
+
return parsed.href;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether this host can intercept a module before it is imported.
|
|
104
|
+
*
|
|
105
|
+
* The one requirement is synchronous, in-thread module hooks. Node has them;
|
|
106
|
+
* Bun's `node:module` has neither `register` nor `registerHooks`, and Deno has
|
|
107
|
+
* no loader in `@uniflowed/host` at all. `@uniflowed/test` turns a `false` here
|
|
108
|
+
* into an error that names the host rather than a mock that quietly does
|
|
109
|
+
* nothing.
|
|
110
|
+
*/
|
|
111
|
+
export function interceptionSupported() {
|
|
112
|
+
return typeof nodeModule.registerHooks === "function";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Install the interception hooks, once.
|
|
117
|
+
*
|
|
118
|
+
* Called on the first `uft.mock` or `uft.resetModules` rather than at import,
|
|
119
|
+
* because a resolve hook that runs for every specifier in the process is not
|
|
120
|
+
* something a suite that never mocks anything should pay for.
|
|
121
|
+
*/
|
|
122
|
+
export function installInterception() {
|
|
123
|
+
if (handle != null) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (!interceptionSupported()) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
handle = nodeModule.registerHooks({ load: loadHook, resolve: resolveHook });
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Register `namespace` as the stand-in for the module at `url`.
|
|
135
|
+
*
|
|
136
|
+
* Returns nothing: what the caller needs is that the *next* resolution of that
|
|
137
|
+
* module lands somewhere else, which the resolve hook arranges from the
|
|
138
|
+
* revision recorded here.
|
|
139
|
+
*/
|
|
140
|
+
export function defineModuleMock(url, namespace) {
|
|
141
|
+
revisions += 1;
|
|
142
|
+
mocks.set(moduleKey(url), { namespace, revision: revisions });
|
|
143
|
+
startModuleEpoch();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Stop standing in for the module at `url`. */
|
|
147
|
+
export function removeModuleMock(url) {
|
|
148
|
+
if (!mocks.delete(moduleKey(url))) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
startModuleEpoch();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether the module at `url` is currently stood in for. */
|
|
156
|
+
export function isModuleMocked(url) {
|
|
157
|
+
return mocks.has(moduleKey(url));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The epoch path imports are currently loading into. */
|
|
161
|
+
export function moduleEpoch() {
|
|
162
|
+
return epoch;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Begin a new epoch, so a path import evaluates its module again.
|
|
167
|
+
*
|
|
168
|
+
* Counted process-wide rather than per file. A file that reused the number a
|
|
169
|
+
* previous file's epoch had would be handed that file's modules, mocks and all,
|
|
170
|
+
* which is the leak the whole scheme exists to prevent.
|
|
171
|
+
*/
|
|
172
|
+
export function startModuleEpoch() {
|
|
173
|
+
epochs += 1;
|
|
174
|
+
epoch = epochs;
|
|
175
|
+
return epoch;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Forget every mock and leave the epoch.
|
|
180
|
+
*
|
|
181
|
+
* Called by the worker between files. Epochs and revisions deliberately keep
|
|
182
|
+
* counting: the next file's mock of the same module must not be handed the URL
|
|
183
|
+
* this file's mock is cached under.
|
|
184
|
+
*/
|
|
185
|
+
export function resetModuleMocks() {
|
|
186
|
+
mocks.clear();
|
|
187
|
+
served.clear();
|
|
188
|
+
epoch = 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The URL an import that must reach the real module should use.
|
|
193
|
+
*
|
|
194
|
+
* A mocked module is standing at the URL an ordinary import resolves to, so
|
|
195
|
+
* reaching past it takes a URL of its own — one per epoch, so that two calls in
|
|
196
|
+
* a row hand back the same module rather than compiling it twice.
|
|
197
|
+
*
|
|
198
|
+
* A module nobody is standing in for needs no marker: the URL an ordinary
|
|
199
|
+
* import would use already holds the real thing, and taking the marked path
|
|
200
|
+
* anyway would hand back a second copy of a module the test is already holding.
|
|
201
|
+
*
|
|
202
|
+
* `pathLike` is whether the caller wrote a path rather than a package name, and
|
|
203
|
+
* it is not a detail: this is the resolve hook's rule applied by hand, and the
|
|
204
|
+
* rule is that a package keeps its identity across an epoch. Getting it wrong
|
|
205
|
+
* here would hand a test a second copy of `@uniflowed/test` — a second registry
|
|
206
|
+
* and a second set of spies — from the one call that is supposed to reach the
|
|
207
|
+
* real thing.
|
|
208
|
+
*/
|
|
209
|
+
export function actualUrl(url, pathLike) {
|
|
210
|
+
const parsed = new URL(url);
|
|
211
|
+
if (!mocks.has(moduleKey(url))) {
|
|
212
|
+
if (epoch !== 0 && pathLike) {
|
|
213
|
+
parsed.searchParams.set(REVISION_PARAM, `${epoch}.0`);
|
|
214
|
+
}
|
|
215
|
+
return parsed.href;
|
|
216
|
+
}
|
|
217
|
+
parsed.searchParams.set(ACTUAL_PARAM, String(epoch));
|
|
218
|
+
return parsed.href;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The values a generated stand-in module exports.
|
|
223
|
+
*
|
|
224
|
+
* Keyed by the exact URL the stand-in was loaded as rather than by the module
|
|
225
|
+
* key, so that a stand-in evaluated after its mock was replaced still reads the
|
|
226
|
+
* namespace its export list was written from.
|
|
227
|
+
*/
|
|
228
|
+
export function namespaceFor(url) {
|
|
229
|
+
const namespace = served.get(url);
|
|
230
|
+
if (namespace == null) {
|
|
231
|
+
throw new Error(`@uniflowed/host: no mocked namespace was recorded for ${url}`);
|
|
232
|
+
}
|
|
233
|
+
return namespace;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The source of the stand-in module for `url`, or `null` when there is none.
|
|
238
|
+
*
|
|
239
|
+
* An ES module's export names are fixed when it is compiled, so they are
|
|
240
|
+
* written out here from the keys the mock actually has. That is the whole
|
|
241
|
+
* reason this returns source rather than an object: a namespace object cannot
|
|
242
|
+
* be handed to `import`, and a module with the wrong names would fail to link
|
|
243
|
+
* with an error about the importer rather than about the mock.
|
|
244
|
+
*/
|
|
245
|
+
export function mockedSource(url) {
|
|
246
|
+
const parsed = new URL(url);
|
|
247
|
+
if (parsed.searchParams.has(ACTUAL_PARAM)) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const record = mocks.get(moduleKey(url));
|
|
251
|
+
if (record == null) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
served.set(url, record.namespace);
|
|
256
|
+
const lines = [
|
|
257
|
+
`import { namespaceFor } from ${JSON.stringify(SELF)};`,
|
|
258
|
+
`const values = namespaceFor(${JSON.stringify(url)});`,
|
|
259
|
+
];
|
|
260
|
+
const bindings = [];
|
|
261
|
+
for (const [index, name] of Object.keys(record.namespace).entries()) {
|
|
262
|
+
lines.push(`const binding${index} = values[${JSON.stringify(name)}];`);
|
|
263
|
+
bindings.push(`binding${index} as ${exportName(name)}`);
|
|
264
|
+
}
|
|
265
|
+
// `export {}` rather than nothing, so a mock with no exports is still an ES
|
|
266
|
+
// module: without an export or import declaration Node would have to guess
|
|
267
|
+
// the format from the package, and the guess is not always "module".
|
|
268
|
+
lines.push(bindings.length === 0 ? "export {};" : `export { ${bindings.join(", ")} };`);
|
|
269
|
+
return `${lines.join("\n")}\n`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** How an export is named in an `export {}` clause. */
|
|
273
|
+
function exportName(name) {
|
|
274
|
+
// Reserved words are fine — `export { x as default }` is the whole point —
|
|
275
|
+
// but anything that is not an identifier at all has to be a string, which
|
|
276
|
+
// ES2022 allows and which is how a mock keeps a name like `foo-bar`.
|
|
277
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The `load` hook: serve a stand-in, or defer to the transform.
|
|
282
|
+
*
|
|
283
|
+
* Deferring is the common case and has to stay cheap: with no mocks registered
|
|
284
|
+
* this is one map lookup on an empty map.
|
|
285
|
+
*/
|
|
286
|
+
function loadHook(url, context, nextLoad) {
|
|
287
|
+
if (mocks.size === 0 || !url.startsWith("file:")) {
|
|
288
|
+
return nextLoad(url, context);
|
|
289
|
+
}
|
|
290
|
+
const source = mockedSource(url);
|
|
291
|
+
if (source == null) {
|
|
292
|
+
return nextLoad(url, context);
|
|
293
|
+
}
|
|
294
|
+
return { format: "module", shortCircuit: true, source };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The `resolve` hook: send an import to the revision it belongs to.
|
|
299
|
+
*
|
|
300
|
+
* Runs after the rest of the chain, so what it rewrites is a fully resolved
|
|
301
|
+
* URL rather than a specifier it would have to resolve itself.
|
|
302
|
+
*/
|
|
303
|
+
function resolveHook(specifier, context, nextResolve) {
|
|
304
|
+
const resolved = nextResolve(specifier, context);
|
|
305
|
+
if (mocks.size === 0 && epoch === 0) {
|
|
306
|
+
return resolved;
|
|
307
|
+
}
|
|
308
|
+
const url = redirect(specifier, context?.parentURL, resolved?.url);
|
|
309
|
+
return url === resolved.url ? resolved : { ...resolved, url };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Where an import of `url` should actually go. */
|
|
313
|
+
function redirect(specifier, parentURL, url) {
|
|
314
|
+
if (typeof url !== "string" || !url.startsWith("file:")) {
|
|
315
|
+
return url;
|
|
316
|
+
}
|
|
317
|
+
const parsed = new URL(url);
|
|
318
|
+
// Already answered: `importActual` names the URL it wants, and a URL that
|
|
319
|
+
// carries a revision was produced by this function on the way in.
|
|
320
|
+
if (parsed.searchParams.has(ACTUAL_PARAM) || parsed.searchParams.has(REVISION_PARAM)) {
|
|
321
|
+
return url;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const revision = mocks.get(moduleKey(url))?.revision ?? 0;
|
|
325
|
+
// A package keeps its identity across an epoch; a module of this project's
|
|
326
|
+
// own does not. See the note at the top of the file.
|
|
327
|
+
const at = isPathSpecifier(specifier) ? epochOf(parentURL) : 0;
|
|
328
|
+
if (revision === 0 && at === 0) {
|
|
329
|
+
return url;
|
|
330
|
+
}
|
|
331
|
+
parsed.searchParams.set(REVISION_PARAM, `${at}.${revision}`);
|
|
332
|
+
return parsed.href;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Whether `specifier` names a file rather than a package. */
|
|
336
|
+
function isPathSpecifier(specifier) {
|
|
337
|
+
return specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The epoch an import from `parentURL` belongs to.
|
|
342
|
+
*
|
|
343
|
+
* A module loaded in epoch two loads its own dependencies in epoch two, however
|
|
344
|
+
* many mocks have been registered since: a graph half of which is one epoch and
|
|
345
|
+
* half another is two copies of a module that expects to be one.
|
|
346
|
+
*/
|
|
347
|
+
function epochOf(parentURL) {
|
|
348
|
+
if (parentURL == null || !parentURL.startsWith("file:")) {
|
|
349
|
+
return epoch;
|
|
350
|
+
}
|
|
351
|
+
const carried = new URL(parentURL).searchParams.get(REVISION_PARAM);
|
|
352
|
+
if (carried == null) {
|
|
353
|
+
return epoch;
|
|
354
|
+
}
|
|
355
|
+
return Number.parseInt(carried.split(".")[0], 10);
|
|
356
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/host",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.8",
|
|
4
4
|
"description": "Running Flow on a Capability JS Host, with no bundler in the way.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
"./bun-preload": "./bun-preload.js",
|
|
15
15
|
"./internal/node-hooks.js": "./internal/node-hooks.js",
|
|
16
|
+
"./module-mocks": "./module-mocks.js",
|
|
16
17
|
"./register": "./register.js",
|
|
17
18
|
"./transform": "./transform.js",
|
|
18
19
|
"./write-atomically": "./write-atomically.js"
|
|
@@ -20,7 +21,9 @@
|
|
|
20
21
|
"files": [
|
|
21
22
|
"register.js",
|
|
22
23
|
"bun-preload.js",
|
|
24
|
+
"module-mocks.js",
|
|
23
25
|
"transform.js",
|
|
26
|
+
"write-atomically.js",
|
|
24
27
|
"internal/*.js"
|
|
25
28
|
]
|
|
26
29
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this is reached from the loader hooks, which run before
|
|
4
|
+
// any transform, and from the config loader, which runs before them.
|
|
5
|
+
//
|
|
6
|
+
// Writing a file a second process may be reading at the same time.
|
|
7
|
+
//
|
|
8
|
+
// `writeFileSync` truncates and then writes, so a reader can observe the empty
|
|
9
|
+
// file or half of one. When what is being written is a module, the reader does
|
|
10
|
+
// not get an error it can act on — it gets a module with no exports, and says
|
|
11
|
+
// so about the *source*:
|
|
12
|
+
//
|
|
13
|
+
// uf: uf.config.js must `export default defineConfig({ ... })`
|
|
14
|
+
//
|
|
15
|
+
// which is a sentence about a file that is perfectly correct. That is not
|
|
16
|
+
// hypothetical: two `uf` commands in one project, which is `uf dev` in one
|
|
17
|
+
// terminal and `uf build` in another, produced exactly it. See
|
|
18
|
+
// ubugeeei-prod/uf#240.
|
|
19
|
+
//
|
|
20
|
+
// Writing to a private name and renaming is atomic within a filesystem, so a
|
|
21
|
+
// reader sees the old file or the new one and never a part of either.
|
|
22
|
+
|
|
23
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write `contents` to `target` so a concurrent reader never sees half of it.
|
|
28
|
+
*
|
|
29
|
+
* The temporary name carries the process id and a random suffix, because two
|
|
30
|
+
* processes racing to write the *same* target is the case this exists for and
|
|
31
|
+
* they must not collide on the temporary either.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} target absolute path to write
|
|
34
|
+
* @param {string} contents what to write
|
|
35
|
+
* @param {{ tolerant?: boolean }} [options] `tolerant` swallows a failure,
|
|
36
|
+
* which is what a cache wants — a read-only checkout still runs, just
|
|
37
|
+
* without one. A config that cannot be written is a failure the caller has
|
|
38
|
+
* to see.
|
|
39
|
+
*/
|
|
40
|
+
export function writeAtomically(target, contents, options = {}) {
|
|
41
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
44
|
+
writeFileSync(temporary, contents);
|
|
45
|
+
renameSync(temporary, target);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(temporary);
|
|
49
|
+
} catch {
|
|
50
|
+
// Nothing to clean up.
|
|
51
|
+
}
|
|
52
|
+
if (options.tolerant !== true) throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|