@adecore/utils 0.0.0-stage → 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,110 @@
1
+ # Functional Source License, Version 1.1, MIT Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-MIT
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 - present Bas Milius
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the MIT license that is effective on the second anniversary of the date we make
91
+ the Software available. On or after that date, you may use the Software under
92
+ the MIT license, in which case the following will apply:
93
+
94
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
95
+ this software and associated documentation files (the "Software"), to deal in
96
+ the Software without restriction, including without limitation the rights to
97
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
98
+ of the Software, and to permit persons to whom the Software is furnished to do
99
+ so, subject to the following conditions:
100
+
101
+ The above copyright notice and this permission notice shall be included in all
102
+ copies or substantial portions of the Software.
103
+
104
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
105
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
106
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
107
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
108
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
109
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
110
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # @adecore/utils
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Small helpers without dependencies: atomic file writes, path containment, the order of async work and reading a thrown value. Each lives at its own subpath; `async`, `errors` and `record` run in a page, `fs` and `path` need Node or Bun.
4
+
5
+ ```sh
6
+ bun add @adecore/utils
7
+ ```
8
+
9
+ Licensed under FSL-1.1-MIT, see [LICENSE](./LICENSE).
10
+
11
+ ```ts
12
+ import { writeAtomic } from '@adecore/utils/fs';
13
+ import { isBelow } from '@adecore/utils/path';
14
+
15
+ if (isBelow(folder, target)) {
16
+ await writeAtomic(target, JSON.stringify(state), 0o600, { durable: true });
17
+ }
18
+ ```
19
+
20
+ `isBelow` compares text and reads nothing from the disk. Resolve real paths first when a link could lead out of the folder.
21
+
22
+ Read the [overview](https://adecore.dev/utils/).
@@ -0,0 +1,12 @@
1
+ export { KeyedSerializer, Serializer } from './serializer.ts';
2
+ export { SingleFlight } from './single-flight.ts';
3
+ export declare function wait(ms: number, signal?: AbortSignal): Promise<void>;
4
+ export interface DeadlineTimer {
5
+ setTimeout(run: () => void, ms: number): unknown;
6
+ clearTimeout(handle: unknown): void;
7
+ }
8
+ export declare function withDeadline<T>(promise: Promise<T>, ms: number, message: string, timer?: DeadlineTimer): Promise<T>;
9
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T>;
10
+ export type Timer = (ms: number, run: () => void) => () => void;
11
+ export declare const cancellableTimeout: Timer;
12
+ export declare const unrefTimeout: Timer;
package/dist/async.js ADDED
@@ -0,0 +1,46 @@
1
+ import { asError } from './errors.js';
2
+ export { KeyedSerializer, Serializer } from './serializer.js';
3
+ export { SingleFlight } from './single-flight.js';
4
+ /*
5
+ * Resolves after `ms`, or at once when the signal is raised. An abort resolves instead of rejecting:
6
+ * every caller is a loop that checks the signal itself on its next turn.
7
+ */
8
+ export function wait(ms, signal) {
9
+ return new Promise((resolve) => {
10
+ const timer = setTimeout(resolve, ms);
11
+ signal?.addEventListener('abort', () => {
12
+ clearTimeout(timer);
13
+ resolve();
14
+ }, { once: true });
15
+ });
16
+ }
17
+ const SYSTEM_TIMER = {
18
+ setTimeout: (run, ms) => setTimeout(run, ms),
19
+ clearTimeout: (handle) => clearTimeout(handle)
20
+ };
21
+ /* The timer is cleared either way, so a call that answers at once does not hold the process until its deadline. */
22
+ export function withDeadline(promise, ms, message, timer = SYSTEM_TIMER) {
23
+ return new Promise((resolve, reject) => {
24
+ const handle = timer.setTimeout(() => reject(new Error(message)), ms);
25
+ promise.then((value) => {
26
+ timer.clearTimeout(handle);
27
+ resolve(value);
28
+ }, (e) => {
29
+ timer.clearTimeout(handle);
30
+ reject(asError(e));
31
+ });
32
+ });
33
+ }
34
+ export function withTimeout(promise, ms, message) {
35
+ return withDeadline(promise, ms, message);
36
+ }
37
+ export const cancellableTimeout = (ms, run) => {
38
+ const timer = setTimeout(run, ms);
39
+ return () => clearTimeout(timer);
40
+ };
41
+ /* The same, without holding the process open for it. */
42
+ export const unrefTimeout = (ms, run) => {
43
+ const timer = setTimeout(run, ms);
44
+ timer.unref?.();
45
+ return () => clearTimeout(timer);
46
+ };
@@ -0,0 +1,4 @@
1
+ export declare function hasErrorCode(e: unknown, ...codes: string[]): boolean;
2
+ export declare function isNotFound(e: unknown): boolean;
3
+ export declare function describeError(error: unknown, debug: boolean): string;
4
+ export declare function asError(thrown: unknown): Error;
package/dist/errors.js ADDED
@@ -0,0 +1,29 @@
1
+ /*
2
+ * How a thrown value is read. These helpers use no platform API, so a page imports them as well as a
3
+ * process. A message for a person to read and a host-wide choice to print stacks stay with their owners.
4
+ */
5
+ /* Whether `e` carries one of `codes` as its `code`, the way Node and Bun mark a system error. */
6
+ export function hasErrorCode(e, ...codes) {
7
+ return typeof e === 'object' && e !== null && 'code' in e && codes.includes(e.code);
8
+ }
9
+ export function isNotFound(e) {
10
+ return hasErrorCode(e, 'ENOENT');
11
+ }
12
+ /*
13
+ * The message of an error, or its whole stack with `debug` set. Bun prints an Error object handed to the
14
+ * console with a code frame of the bundle it came from, which in a compiled binary is a few lines of
15
+ * minified source, so an error is turned into a string first.
16
+ */
17
+ export function describeError(error, debug) {
18
+ if (!(error instanceof Error)) {
19
+ return String(error);
20
+ }
21
+ if (debug) {
22
+ return error.stack ?? `${error.name}: ${error.message}`;
23
+ }
24
+ return error.message === '' ? error.name : error.message;
25
+ }
26
+ /* What was thrown, as an Error a rejection can carry. */
27
+ export function asError(thrown) {
28
+ return thrown instanceof Error ? thrown : new Error(String(thrown));
29
+ }
package/dist/fs.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare function isBusyRename(e: unknown): boolean;
2
+ export declare function tempNameFor(target: string): string;
3
+ export interface WriteAtomicOptions {
4
+ durable?: boolean;
5
+ }
6
+ export declare function syncDirectory(path: string): Promise<void>;
7
+ export declare function writeAtomic(target: string, content: string | Uint8Array, mode?: number, options?: WriteAtomicOptions): Promise<void>;
8
+ export declare function replaceSync(temp: string, target: string, rename?: (from: string, to: string) => void): void;
9
+ export declare function writeAtomicSync(target: string, content: string | Uint8Array, mode?: number): void;
10
+ export declare function removeFile(path: string, remove?: (path: string, options: {
11
+ force: true;
12
+ }) => Promise<void>): Promise<void>;
13
+ export declare function fileExists(path: string): Promise<boolean>;
14
+ export declare function entryExists(path: string): Promise<boolean>;
package/dist/fs.js ADDED
@@ -0,0 +1,161 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { closeSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { lstat, open, rename, rm, stat } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ import { wait } from './async.js';
6
+ import { hasErrorCode } from './errors.js';
7
+ // Windows can refuse a rename while a watcher or an indexer holds the target; a few short retries cover it.
8
+ const RENAME_RETRIES = 5;
9
+ const RENAME_RETRY_MS = 40;
10
+ // Node has no synchronous sleep; a wait on a value nobody changes is one.
11
+ function sleepSync(ms) {
12
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13
+ }
14
+ /* Whether a rename failed because another process holds the file open for a moment, as Windows reports it. */
15
+ export function isBusyRename(e) {
16
+ return hasErrorCode(e, 'EPERM', 'EBUSY', 'EACCES');
17
+ }
18
+ /* A temporary name is still opened exclusively: an unpredictable name alone cannot protect a write. */
19
+ export function tempNameFor(target) {
20
+ return `${target}.${process.pid}-${randomUUID()}.tmp`;
21
+ }
22
+ async function createTemp(target, mode) {
23
+ for (let attempt = 0;; attempt++) {
24
+ const path = tempNameFor(target);
25
+ try {
26
+ return { path, handle: await open(path, 'wx', mode) };
27
+ }
28
+ catch (error) {
29
+ if (!hasErrorCode(error, 'EEXIST') || attempt >= 31) {
30
+ throw error;
31
+ }
32
+ }
33
+ }
34
+ }
35
+ // Windows cannot open a directory to flush it, and NTFS journals the rename itself.
36
+ export async function syncDirectory(path) {
37
+ if (process.platform === 'win32') {
38
+ return;
39
+ }
40
+ const handle = await open(path, 'r');
41
+ try {
42
+ await handle.sync();
43
+ }
44
+ finally {
45
+ await handle.close();
46
+ }
47
+ }
48
+ /*
49
+ * A crash between the two steps leaves a stray temp file, never a half-written file. That holds for
50
+ * a process that dies; after a power cut APFS and ext4 can still hold an empty target, unless the
51
+ * write is durable.
52
+ */
53
+ export async function writeAtomic(target, content, mode = 0o600, options = {}) {
54
+ const { path: temp, handle } = await createTemp(target, mode);
55
+ try {
56
+ try {
57
+ await handle.writeFile(content);
58
+ if (options.durable) {
59
+ await handle.sync();
60
+ }
61
+ }
62
+ finally {
63
+ await handle.close();
64
+ }
65
+ for (let attempt = 0;; attempt++) {
66
+ try {
67
+ await rename(temp, target);
68
+ break;
69
+ }
70
+ catch (e) {
71
+ if (!isBusyRename(e) || attempt >= RENAME_RETRIES) {
72
+ throw e;
73
+ }
74
+ await wait(RENAME_RETRY_MS);
75
+ }
76
+ }
77
+ if (options.durable) {
78
+ await syncDirectory(dirname(target));
79
+ }
80
+ }
81
+ finally {
82
+ await rm(temp, { force: true });
83
+ }
84
+ }
85
+ /*
86
+ * The same rename, without a turn of the event loop. A `bun --watch` reload restarts a module before
87
+ * an awaited write comes back, and an exit path is gone before one lands, so what has to be on disk
88
+ * before the process turns around goes through here.
89
+ */
90
+ export function replaceSync(temp, target, rename = renameSync) {
91
+ for (let attempt = 0;; attempt++) {
92
+ try {
93
+ rename(temp, target);
94
+ return;
95
+ }
96
+ catch (e) {
97
+ if (!isBusyRename(e) || attempt >= RENAME_RETRIES) {
98
+ throw e;
99
+ }
100
+ sleepSync(RENAME_RETRY_MS);
101
+ }
102
+ }
103
+ }
104
+ export function writeAtomicSync(target, content, mode = 0o600) {
105
+ for (let attempt = 0;; attempt++) {
106
+ const temp = tempNameFor(target);
107
+ let descriptor;
108
+ try {
109
+ descriptor = openSync(temp, 'wx', mode);
110
+ }
111
+ catch (error) {
112
+ if (hasErrorCode(error, 'EEXIST') && attempt < 31) {
113
+ continue;
114
+ }
115
+ throw error;
116
+ }
117
+ try {
118
+ try {
119
+ writeFileSync(descriptor, content);
120
+ }
121
+ finally {
122
+ closeSync(descriptor);
123
+ }
124
+ replaceSync(temp, target);
125
+ return;
126
+ }
127
+ finally {
128
+ rmSync(temp, { force: true });
129
+ }
130
+ }
131
+ }
132
+ // Bun on Windows fails a forced rm with EFAULT while another delete of the same file is pending; it goes either way.
133
+ export async function removeFile(path, remove = rm) {
134
+ try {
135
+ await remove(path, { force: true });
136
+ }
137
+ catch (e) {
138
+ if (!hasErrorCode(e, 'EFAULT')) {
139
+ throw e;
140
+ }
141
+ }
142
+ }
143
+ export async function fileExists(path) {
144
+ try {
145
+ await stat(path);
146
+ return true;
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ /* Whether anything is at the path, a link that leads nowhere included, where `fileExists` follows the link. */
153
+ export async function entryExists(path) {
154
+ try {
155
+ await lstat(path);
156
+ return true;
157
+ }
158
+ catch {
159
+ return false;
160
+ }
161
+ }
package/dist/path.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare function climbsOut(rel: string): boolean;
2
+ export declare function isWithin(folder: string, path: string): boolean;
3
+ export declare function isBelow(folder: string, path: string): boolean;
package/dist/path.js ADDED
@@ -0,0 +1,20 @@
1
+ import { isAbsolute, relative, sep } from 'node:path';
2
+ /*
3
+ * Where one path lies against another, compared as text after `path.relative` resolved and normalized
4
+ * both, with the platform's separator, drive letters and case rules. Nothing here reads the disk, so a
5
+ * symbolic link inside the folder still counts as inside: a caller that serves or writes a file checks
6
+ * its real path or holds a handle on it.
7
+ */
8
+ /* Whether a path `relative` gave leaves the folder it was taken from; a name such as `..cache` does not. */
9
+ export function climbsOut(rel) {
10
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
11
+ }
12
+ /* Whether `path` is `folder` or lies below it. */
13
+ export function isWithin(folder, path) {
14
+ return !climbsOut(relative(folder, path));
15
+ }
16
+ /* Whether `path` lies below `folder`, the folder itself not counted. */
17
+ export function isBelow(folder, path) {
18
+ const rel = relative(folder, path);
19
+ return rel !== '' && !climbsOut(rel);
20
+ }
@@ -0,0 +1 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
package/dist/record.js ADDED
@@ -0,0 +1,4 @@
1
+ /* A plain object read off the wire or a file, whose fields are still unchecked; an array is not one. */
2
+ export function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
@@ -0,0 +1,10 @@
1
+ export declare class Serializer {
2
+ private chain;
3
+ run<T>(work: () => Promise<T>): Promise<T>;
4
+ idle(): Promise<void>;
5
+ }
6
+ export declare class KeyedSerializer {
7
+ private readonly chains;
8
+ get size(): number;
9
+ run<T>(key: string, work: () => Promise<T>): Promise<T>;
10
+ }
@@ -0,0 +1,38 @@
1
+ /*
2
+ * One writer at a time: the next piece of work starts once the one before it settled, whether that
3
+ * one kept its promise or not, and every caller hears its own result. Two changes in a row never
4
+ * read the same file and drop one of them.
5
+ */
6
+ export class Serializer {
7
+ chain = Promise.resolve();
8
+ run(work) {
9
+ const next = this.chain.then(work, work);
10
+ this.chain = next.catch(() => undefined);
11
+ return next;
12
+ }
13
+ /* Resolves once what is queued has settled, without adding work of its own. */
14
+ idle() {
15
+ return this.chain.then(() => undefined, () => undefined);
16
+ }
17
+ }
18
+ /* The same per key, for a store whose records are written one by one and never wait on each other. */
19
+ export class KeyedSerializer {
20
+ chains = new Map();
21
+ /* How many keys still have work queued or running. */
22
+ get size() {
23
+ return this.chains.size;
24
+ }
25
+ run(key, work) {
26
+ const previous = this.chains.get(key) ?? Promise.resolve();
27
+ const next = previous.then(work, work);
28
+ const settled = next.catch(() => undefined);
29
+ this.chains.set(key, settled);
30
+ // A key whose chain ran out leaves nothing behind, or the map grows with every id ever written.
31
+ void settled.then(() => {
32
+ if (this.chains.get(key) === settled) {
33
+ this.chains.delete(key);
34
+ }
35
+ });
36
+ return next;
37
+ }
38
+ }
@@ -0,0 +1,6 @@
1
+ export declare class SingleFlight<T> {
2
+ private readonly flights;
3
+ get(key: string): Promise<T> | undefined;
4
+ has(key: string): boolean;
5
+ run(key: string, work: () => Promise<T>): Promise<T>;
6
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ * Work that runs once per key at a time: a caller that asks while it runs shares its outcome, and
3
+ * a key that settled leaves nothing behind.
4
+ */
5
+ export class SingleFlight {
6
+ flights = new Map();
7
+ /* The run in flight for `key`, if any. */
8
+ get(key) {
9
+ return this.flights.get(key);
10
+ }
11
+ has(key) {
12
+ return this.flights.has(key);
13
+ }
14
+ run(key, work) {
15
+ const running = this.flights.get(key);
16
+ if (running !== undefined) {
17
+ return running;
18
+ }
19
+ const flight = work().finally(() => {
20
+ if (this.flights.get(key) === flight) {
21
+ this.flights.delete(key);
22
+ }
23
+ });
24
+ this.flights.set(key, flight);
25
+ return flight;
26
+ }
27
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function isHighSurrogate(code: number): boolean;
2
+ export declare function isLowSurrogate(code: number): boolean;
package/dist/text.js ADDED
@@ -0,0 +1,8 @@
1
+ /* The first half of a surrogate pair, a UTF-16 code unit that cannot stand alone. */
2
+ export function isHighSurrogate(code) {
3
+ return code >= 0xd800 && code <= 0xdbff;
4
+ }
5
+ /* The second half of a surrogate pair. */
6
+ export function isLowSurrogate(code) {
7
+ return code >= 0xdc00 && code <= 0xdfff;
8
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
- "name": "@adecore/utils",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@adecore/utils",
3
+ "version": "0.0.1",
4
+ "description": "Small helpers without dependencies: atomic file writes, path containment, async ordering and error reading.",
5
+ "license": "FSL-1.1-MIT",
6
+ "author": "Bas Milius",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/basmilius/adecore.git",
10
+ "directory": "packages/utils"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/basmilius/adecore/issues"
14
+ },
15
+ "homepage": "https://adecore.dev/utils/",
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts"
22
+ ],
23
+ "exports": {
24
+ "./async": {
25
+ "source": "./src/async.ts",
26
+ "types": "./dist/async.d.ts",
27
+ "default": "./dist/async.js"
28
+ },
29
+ "./errors": {
30
+ "source": "./src/errors.ts",
31
+ "types": "./dist/errors.d.ts",
32
+ "default": "./dist/errors.js"
33
+ },
34
+ "./fs": {
35
+ "source": "./src/fs.ts",
36
+ "types": "./dist/fs.d.ts",
37
+ "default": "./dist/fs.js"
38
+ },
39
+ "./path": {
40
+ "source": "./src/path.ts",
41
+ "types": "./dist/path.d.ts",
42
+ "default": "./dist/path.js"
43
+ },
44
+ "./record": {
45
+ "source": "./src/record.ts",
46
+ "types": "./dist/record.d.ts",
47
+ "default": "./dist/record.js"
48
+ },
49
+ "./text": {
50
+ "source": "./src/text.ts",
51
+ "types": "./dist/text.d.ts",
52
+ "default": "./dist/text.js"
53
+ }
54
+ },
55
+ "scripts": {
56
+ "build": "bun scripts/build.ts",
57
+ "typecheck": "bun check --no-script",
58
+ "test": "bun test --conditions=source"
59
+ },
60
+ "devDependencies": {
61
+ "@types/bun": "^1.4.4",
62
+ "@types/node": "^26.6.6",
63
+ "typescript": "^7.0.2"
64
+ }
65
+ }
package/src/async.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { asError } from './errors.ts';
2
+
3
+ export { KeyedSerializer, Serializer } from './serializer.ts';
4
+ export { SingleFlight } from './single-flight.ts';
5
+
6
+ /*
7
+ * Resolves after `ms`, or at once when the signal is raised. An abort resolves instead of rejecting:
8
+ * every caller is a loop that checks the signal itself on its next turn.
9
+ */
10
+ export function wait(ms: number, signal?: AbortSignal): Promise<void> {
11
+ return new Promise((resolve) => {
12
+ const timer = setTimeout(resolve, ms);
13
+
14
+ signal?.addEventListener(
15
+ 'abort',
16
+ () => {
17
+ clearTimeout(timer);
18
+ resolve();
19
+ },
20
+ { once: true }
21
+ );
22
+ });
23
+ }
24
+
25
+ export interface DeadlineTimer {
26
+ setTimeout(run: () => void, ms: number): unknown;
27
+ clearTimeout(handle: unknown): void;
28
+ }
29
+
30
+ const SYSTEM_TIMER: DeadlineTimer = {
31
+ setTimeout: (run, ms) => setTimeout(run, ms),
32
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>)
33
+ };
34
+
35
+ /* The timer is cleared either way, so a call that answers at once does not hold the process until its deadline. */
36
+ export function withDeadline<T>(promise: Promise<T>, ms: number, message: string, timer: DeadlineTimer = SYSTEM_TIMER): Promise<T> {
37
+ return new Promise((resolve, reject) => {
38
+ const handle = timer.setTimeout(() => reject(new Error(message)), ms);
39
+
40
+ promise.then(
41
+ (value) => {
42
+ timer.clearTimeout(handle);
43
+ resolve(value);
44
+ },
45
+ (e: unknown) => {
46
+ timer.clearTimeout(handle);
47
+ reject(asError(e));
48
+ }
49
+ );
50
+ });
51
+ }
52
+
53
+ export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
54
+ return withDeadline(promise, ms, message);
55
+ }
56
+
57
+ /* Runs `run` after `ms` and answers how to call it off. */
58
+ export type Timer = (ms: number, run: () => void) => () => void;
59
+
60
+ export const cancellableTimeout: Timer = (ms, run) => {
61
+ const timer = setTimeout(run, ms);
62
+
63
+ return () => clearTimeout(timer);
64
+ };
65
+
66
+ /* The same, without holding the process open for it. */
67
+ export const unrefTimeout: Timer = (ms, run) => {
68
+ const timer = setTimeout(run, ms);
69
+ timer.unref?.();
70
+
71
+ return () => clearTimeout(timer);
72
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,35 @@
1
+ /*
2
+ * How a thrown value is read. These helpers use no platform API, so a page imports them as well as a
3
+ * process. A message for a person to read and a host-wide choice to print stacks stay with their owners.
4
+ */
5
+
6
+ /* Whether `e` carries one of `codes` as its `code`, the way Node and Bun mark a system error. */
7
+ export function hasErrorCode(e: unknown, ...codes: string[]): boolean {
8
+ return typeof e === 'object' && e !== null && 'code' in e && codes.includes(e.code as string);
9
+ }
10
+
11
+ export function isNotFound(e: unknown): boolean {
12
+ return hasErrorCode(e, 'ENOENT');
13
+ }
14
+
15
+ /*
16
+ * The message of an error, or its whole stack with `debug` set. Bun prints an Error object handed to the
17
+ * console with a code frame of the bundle it came from, which in a compiled binary is a few lines of
18
+ * minified source, so an error is turned into a string first.
19
+ */
20
+ export function describeError(error: unknown, debug: boolean): string {
21
+ if (!(error instanceof Error)) {
22
+ return String(error);
23
+ }
24
+
25
+ if (debug) {
26
+ return error.stack ?? `${error.name}: ${error.message}`;
27
+ }
28
+
29
+ return error.message === '' ? error.name : error.message;
30
+ }
31
+
32
+ /* What was thrown, as an Error a rejection can carry. */
33
+ export function asError(thrown: unknown): Error {
34
+ return thrown instanceof Error ? thrown : new Error(String(thrown));
35
+ }
package/src/fs.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { closeSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { lstat, open, rename, rm, stat } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ import { wait } from './async.ts';
6
+ import { hasErrorCode } from './errors.ts';
7
+
8
+ // Windows can refuse a rename while a watcher or an indexer holds the target; a few short retries cover it.
9
+ const RENAME_RETRIES = 5;
10
+ const RENAME_RETRY_MS = 40;
11
+
12
+ // Node has no synchronous sleep; a wait on a value nobody changes is one.
13
+ function sleepSync(ms: number): void {
14
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
15
+ }
16
+
17
+ /* Whether a rename failed because another process holds the file open for a moment, as Windows reports it. */
18
+ export function isBusyRename(e: unknown): boolean {
19
+ return hasErrorCode(e, 'EPERM', 'EBUSY', 'EACCES');
20
+ }
21
+
22
+ /* A temporary name is still opened exclusively: an unpredictable name alone cannot protect a write. */
23
+ export function tempNameFor(target: string): string {
24
+ return `${target}.${process.pid}-${randomUUID()}.tmp`;
25
+ }
26
+
27
+ export interface WriteAtomicOptions {
28
+ /* Flushed to the disk before and after the rename, for a file whose loss costs a person their work. */
29
+ durable?: boolean;
30
+ }
31
+
32
+ async function createTemp(target: string, mode: number) {
33
+ for (let attempt = 0; ; attempt++) {
34
+ const path = tempNameFor(target);
35
+
36
+ try {
37
+ return { path, handle: await open(path, 'wx', mode) };
38
+ } catch (error) {
39
+ if (!hasErrorCode(error, 'EEXIST') || attempt >= 31) {
40
+ throw error;
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ // Windows cannot open a directory to flush it, and NTFS journals the rename itself.
47
+ export async function syncDirectory(path: string): Promise<void> {
48
+ if (process.platform === 'win32') {
49
+ return;
50
+ }
51
+
52
+ const handle = await open(path, 'r');
53
+
54
+ try {
55
+ await handle.sync();
56
+ } finally {
57
+ await handle.close();
58
+ }
59
+ }
60
+
61
+ /*
62
+ * A crash between the two steps leaves a stray temp file, never a half-written file. That holds for
63
+ * a process that dies; after a power cut APFS and ext4 can still hold an empty target, unless the
64
+ * write is durable.
65
+ */
66
+ export async function writeAtomic(target: string, content: string | Uint8Array, mode = 0o600, options: WriteAtomicOptions = {}): Promise<void> {
67
+ const { path: temp, handle } = await createTemp(target, mode);
68
+
69
+ try {
70
+ try {
71
+ await handle.writeFile(content);
72
+
73
+ if (options.durable) {
74
+ await handle.sync();
75
+ }
76
+ } finally {
77
+ await handle.close();
78
+ }
79
+
80
+ for (let attempt = 0; ; attempt++) {
81
+ try {
82
+ await rename(temp, target);
83
+
84
+ break;
85
+ } catch (e) {
86
+ if (!isBusyRename(e) || attempt >= RENAME_RETRIES) {
87
+ throw e;
88
+ }
89
+
90
+ await wait(RENAME_RETRY_MS);
91
+ }
92
+ }
93
+
94
+ if (options.durable) {
95
+ await syncDirectory(dirname(target));
96
+ }
97
+ } finally {
98
+ await rm(temp, { force: true });
99
+ }
100
+ }
101
+
102
+ /*
103
+ * The same rename, without a turn of the event loop. A `bun --watch` reload restarts a module before
104
+ * an awaited write comes back, and an exit path is gone before one lands, so what has to be on disk
105
+ * before the process turns around goes through here.
106
+ */
107
+ export function replaceSync(temp: string, target: string, rename: (from: string, to: string) => void = renameSync): void {
108
+ for (let attempt = 0; ; attempt++) {
109
+ try {
110
+ rename(temp, target);
111
+
112
+ return;
113
+ } catch (e) {
114
+ if (!isBusyRename(e) || attempt >= RENAME_RETRIES) {
115
+ throw e;
116
+ }
117
+
118
+ sleepSync(RENAME_RETRY_MS);
119
+ }
120
+ }
121
+ }
122
+
123
+ export function writeAtomicSync(target: string, content: string | Uint8Array, mode = 0o600): void {
124
+ for (let attempt = 0; ; attempt++) {
125
+ const temp = tempNameFor(target);
126
+ let descriptor: number;
127
+
128
+ try {
129
+ descriptor = openSync(temp, 'wx', mode);
130
+ } catch (error) {
131
+ if (hasErrorCode(error, 'EEXIST') && attempt < 31) {
132
+ continue;
133
+ }
134
+
135
+ throw error;
136
+ }
137
+
138
+ try {
139
+ try {
140
+ writeFileSync(descriptor, content);
141
+ } finally {
142
+ closeSync(descriptor);
143
+ }
144
+
145
+ replaceSync(temp, target);
146
+
147
+ return;
148
+ } finally {
149
+ rmSync(temp, { force: true });
150
+ }
151
+ }
152
+ }
153
+
154
+ // Bun on Windows fails a forced rm with EFAULT while another delete of the same file is pending; it goes either way.
155
+ export async function removeFile(path: string, remove: (path: string, options: { force: true }) => Promise<void> = rm): Promise<void> {
156
+ try {
157
+ await remove(path, { force: true });
158
+ } catch (e) {
159
+ if (!hasErrorCode(e, 'EFAULT')) {
160
+ throw e;
161
+ }
162
+ }
163
+ }
164
+
165
+ export async function fileExists(path: string): Promise<boolean> {
166
+ try {
167
+ await stat(path);
168
+
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /* Whether anything is at the path, a link that leads nowhere included, where `fileExists` follows the link. */
176
+ export async function entryExists(path: string): Promise<boolean> {
177
+ try {
178
+ await lstat(path);
179
+
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
package/src/path.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { isAbsolute, relative, sep } from 'node:path';
2
+
3
+ /*
4
+ * Where one path lies against another, compared as text after `path.relative` resolved and normalized
5
+ * both, with the platform's separator, drive letters and case rules. Nothing here reads the disk, so a
6
+ * symbolic link inside the folder still counts as inside: a caller that serves or writes a file checks
7
+ * its real path or holds a handle on it.
8
+ */
9
+
10
+ /* Whether a path `relative` gave leaves the folder it was taken from; a name such as `..cache` does not. */
11
+ export function climbsOut(rel: string): boolean {
12
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
13
+ }
14
+
15
+ /* Whether `path` is `folder` or lies below it. */
16
+ export function isWithin(folder: string, path: string): boolean {
17
+ return !climbsOut(relative(folder, path));
18
+ }
19
+
20
+ /* Whether `path` lies below `folder`, the folder itself not counted. */
21
+ export function isBelow(folder: string, path: string): boolean {
22
+ const rel = relative(folder, path);
23
+
24
+ return rel !== '' && !climbsOut(rel);
25
+ }
package/src/record.ts ADDED
@@ -0,0 +1,4 @@
1
+ /* A plain object read off the wire or a file, whose fields are still unchecked; an array is not one. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
@@ -0,0 +1,49 @@
1
+ /*
2
+ * One writer at a time: the next piece of work starts once the one before it settled, whether that
3
+ * one kept its promise or not, and every caller hears its own result. Two changes in a row never
4
+ * read the same file and drop one of them.
5
+ */
6
+ export class Serializer {
7
+ private chain: Promise<unknown> = Promise.resolve();
8
+
9
+ run<T>(work: () => Promise<T>): Promise<T> {
10
+ const next = this.chain.then(work, work);
11
+ this.chain = next.catch(() => undefined);
12
+
13
+ return next;
14
+ }
15
+
16
+ /* Resolves once what is queued has settled, without adding work of its own. */
17
+ idle(): Promise<void> {
18
+ return this.chain.then(
19
+ () => undefined,
20
+ () => undefined
21
+ );
22
+ }
23
+ }
24
+
25
+ /* The same per key, for a store whose records are written one by one and never wait on each other. */
26
+ export class KeyedSerializer {
27
+ private readonly chains = new Map<string, Promise<unknown>>();
28
+
29
+ /* How many keys still have work queued or running. */
30
+ get size(): number {
31
+ return this.chains.size;
32
+ }
33
+
34
+ run<T>(key: string, work: () => Promise<T>): Promise<T> {
35
+ const previous = this.chains.get(key) ?? Promise.resolve();
36
+ const next = previous.then(work, work);
37
+ const settled = next.catch(() => undefined);
38
+ this.chains.set(key, settled);
39
+
40
+ // A key whose chain ran out leaves nothing behind, or the map grows with every id ever written.
41
+ void settled.then(() => {
42
+ if (this.chains.get(key) === settled) {
43
+ this.chains.delete(key);
44
+ }
45
+ });
46
+
47
+ return next;
48
+ }
49
+ }
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Work that runs once per key at a time: a caller that asks while it runs shares its outcome, and
3
+ * a key that settled leaves nothing behind.
4
+ */
5
+ export class SingleFlight<T> {
6
+ private readonly flights = new Map<string, Promise<T>>();
7
+
8
+ /* The run in flight for `key`, if any. */
9
+ get(key: string): Promise<T> | undefined {
10
+ return this.flights.get(key);
11
+ }
12
+
13
+ has(key: string): boolean {
14
+ return this.flights.has(key);
15
+ }
16
+
17
+ run(key: string, work: () => Promise<T>): Promise<T> {
18
+ const running = this.flights.get(key);
19
+
20
+ if (running !== undefined) {
21
+ return running;
22
+ }
23
+
24
+ const flight = work().finally(() => {
25
+ if (this.flights.get(key) === flight) {
26
+ this.flights.delete(key);
27
+ }
28
+ });
29
+ this.flights.set(key, flight);
30
+
31
+ return flight;
32
+ }
33
+ }
package/src/text.ts ADDED
@@ -0,0 +1,9 @@
1
+ /* The first half of a surrogate pair, a UTF-16 code unit that cannot stand alone. */
2
+ export function isHighSurrogate(code: number): boolean {
3
+ return code >= 0xd800 && code <= 0xdbff;
4
+ }
5
+
6
+ /* The second half of a surrogate pair. */
7
+ export function isLowSurrogate(code: number): boolean {
8
+ return code >= 0xdc00 && code <= 0xdfff;
9
+ }