@brianbuie/node-kit 0.7.0 → 0.9.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/README.md CHANGED
@@ -32,7 +32,6 @@ Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types
32
32
  | [FileTypeCsv](#class-filetypecsv) |
33
33
  | [FileTypeJson](#class-filetypejson) |
34
34
  | [FileTypeNdjson](#class-filetypendjson) |
35
- | [Jwt](#class-jwt) |
36
35
  | [Log](#class-log) |
37
36
  | [TempDir](#class-tempdir) |
38
37
  | [TypeWriter](#class-typewriter) |
@@ -43,16 +42,20 @@ Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types
43
42
 
44
43
  ## Class: Cache
45
44
 
46
- Save results of a function in a temporary file.
45
+ Save data to a local file with an expiration.
46
+ Fresh/stale data is returned with a flag for if it's fresh or not,
47
+ so stale data can still be used if needed.
47
48
 
48
49
  ```ts
49
50
  export class Cache<T> {
50
51
  file;
51
52
  ttl;
52
- getValue;
53
- constructor(key: string, ttl: number, getValue: () => T | Promise<T>)
54
- async read()
55
- async write()
53
+ constructor(key: string, ttl: number | Duration, initialData?: T)
54
+ write(data: T)
55
+ read(): [
56
+ T | undefined,
57
+ boolean
58
+ ]
56
59
  }
57
60
  ```
58
61
 
@@ -244,12 +247,13 @@ export class File {
244
247
  path;
245
248
  constructor(filepath: string)
246
249
  get exists()
247
- createWriteStream(options: Parameters<typeof fs.createWriteStream>[1] = {})
248
250
  delete()
249
251
  read()
250
- write(contents: string)
251
- append(lines: string | string[])
252
252
  lines()
253
+ get readStream()
254
+ get writeStream()
255
+ write(contents: string | ReadableStream)
256
+ append(lines: string | string[])
253
257
  static get FileType()
254
258
  json<T>(contents?: T)
255
259
  static get json()
@@ -292,13 +296,15 @@ Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types
292
296
  ---
293
297
  ## Class: FileType
294
298
 
299
+ A generic file adaptor, extended by specific file type implementations
300
+
295
301
  ```ts
296
- export class FileType<T = string> {
302
+ export class FileType {
297
303
  file;
298
- constructor(filepath: string, contents?: T)
304
+ constructor(filepath: string, contents?: string)
299
305
  get exists()
300
- delete()
301
306
  get path()
307
+ delete()
302
308
  }
303
309
  ```
304
310
 
@@ -307,10 +313,14 @@ Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types
307
313
  ---
308
314
  ## Class: FileTypeCsv
309
315
 
316
+ Comma separated values (.csv).
317
+ Input rows as objects, keys are used as column headers
318
+
310
319
  ```ts
311
320
  export class FileTypeCsv<Row extends object> extends FileType {
312
321
  constructor(filepath: string)
313
322
  async write(rows: Row[], keys?: Key<Row>[])
323
+ #parseVal(val: string)
314
324
  async read()
315
325
  }
316
326
  ```
@@ -322,6 +332,9 @@ Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types
322
332
  ---
323
333
  ## Class: FileTypeJson
324
334
 
335
+ A .json file that maintains data type when reading/writing.
336
+ This is unsafe! Type is not checked at runtime, avoid using on files manipulated outside of your application.
337
+
325
338
  ```ts
326
339
  export class FileTypeJson<T> extends FileType {
327
340
  constructor(filepath: string, contents?: T)
@@ -337,6 +350,8 @@ Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types
337
350
  ---
338
351
  ## Class: FileTypeNdjson
339
352
 
353
+ New-line delimited json file (.ndjson)
354
+
340
355
  ```ts
341
356
  export class FileTypeNdjson<T extends object> extends FileType {
342
357
  constructor(filepath: string, lines?: T | T[])
@@ -349,25 +364,6 @@ See also: [FileType](#class-filetype)
349
364
 
350
365
  Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types), [Variables](#variables)
351
366
 
352
- ---
353
- ## Class: Jwt
354
-
355
- ```ts
356
- export class Jwt {
357
- config;
358
- #saved?: {
359
- exp: number;
360
- token: string;
361
- };
362
- constructor(config: JwtConfig)
363
- get now()
364
- #createToken()
365
- get token()
366
- }
367
- ```
368
-
369
- Links: [API](#api), [Classes](#classes), [Functions](#functions), [Types](#types), [Variables](#variables)
370
-
371
367
  ---
372
368
  ## Class: Log
373
369
 
package/dist/Cache.d.ts CHANGED
@@ -1,17 +1,16 @@
1
+ import { type Duration } from 'date-fns';
1
2
  /**
2
- * Save results of a function in a temporary file.
3
- * @param key A unique name for the file
4
- * @param ttl cache duration in ms
5
- * @param getValue the function to populate the cache (eg. fetch results, generate key, etc)
3
+ * Save data to a local file with an expiration.
4
+ * Fresh/stale data is returned with a flag for if it's fresh or not,
5
+ * so stale data can still be used if needed.
6
6
  */
7
7
  export declare class Cache<T> {
8
8
  file: import("./File.js").FileTypeJson<{
9
- createdAt: number;
10
- value: T;
9
+ savedAt: string;
10
+ data: T;
11
11
  }>;
12
- ttl: number;
13
- getValue: () => T | Promise<T>;
14
- constructor(key: string, ttl: number, getValue: () => T | Promise<T>);
15
- read(): Promise<T>;
16
- write(): Promise<T>;
12
+ ttl: Duration;
13
+ constructor(key: string, ttl: number | Duration, initialData?: T);
14
+ write(data: T): void;
15
+ read(): [T | undefined, boolean];
17
16
  }
package/dist/Cache.js CHANGED
@@ -1,29 +1,26 @@
1
+ import { isAfter, add } from 'date-fns';
1
2
  import { temp } from './Dir.js';
2
3
  const cacheDir = temp.dir('cache');
3
4
  /**
4
- * Save results of a function in a temporary file.
5
- * @param key A unique name for the file
6
- * @param ttl cache duration in ms
7
- * @param getValue the function to populate the cache (eg. fetch results, generate key, etc)
5
+ * Save data to a local file with an expiration.
6
+ * Fresh/stale data is returned with a flag for if it's fresh or not,
7
+ * so stale data can still be used if needed.
8
8
  */
9
9
  export class Cache {
10
10
  file;
11
11
  ttl;
12
- getValue;
13
- constructor(key, ttl, getValue) {
12
+ constructor(key, ttl, initialData) {
14
13
  this.file = cacheDir.file(key).json();
15
- this.ttl = ttl;
16
- this.getValue = getValue;
14
+ this.ttl = typeof ttl === 'number' ? { minutes: ttl } : ttl;
15
+ if (initialData)
16
+ this.write(initialData);
17
17
  }
18
- async read() {
19
- const { createdAt, value } = this.file.read() || {};
20
- if (value && createdAt && createdAt + this.ttl > Date.now())
21
- return value;
22
- return this.write();
18
+ write(data) {
19
+ this.file.write({ savedAt: new Date().toUTCString(), data });
23
20
  }
24
- async write() {
25
- const value = await this.getValue();
26
- this.file.write({ createdAt: Date.now(), value });
27
- return value;
21
+ read() {
22
+ const { savedAt, data } = this.file.read() || {};
23
+ const isFresh = Boolean(savedAt && isAfter(add(savedAt, this.ttl), new Date()));
24
+ return [data, isFresh];
28
25
  }
29
26
  }
package/dist/Dir.js CHANGED
@@ -30,7 +30,7 @@ export class Dir {
30
30
  return new Dir(path.resolve(this.path, subPath));
31
31
  }
32
32
  sanitize(name) {
33
- return sanitizeFilename(name.replace('https://', '').replace('www.', ''), { replacement: '_' });
33
+ return sanitizeFilename(name.replace('https://', '').replace('www.', ''), { replacement: '_' }).slice(-200);
34
34
  }
35
35
  /**
36
36
  * @param base - The file name with extension
package/dist/File.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import { Readable } from 'node:stream';
2
3
  /**
3
4
  * WARNING: API will change!
4
5
  */
@@ -6,19 +7,20 @@ export declare class File {
6
7
  path: string;
7
8
  constructor(filepath: string);
8
9
  get exists(): boolean;
9
- createWriteStream(options?: Parameters<typeof fs.createWriteStream>[1]): fs.WriteStream;
10
10
  delete(): void;
11
11
  read(): string | undefined;
12
- write(contents: string): void;
12
+ /**
13
+ * @returns lines as strings, removes trailing '\n'
14
+ */
15
+ lines(): string[];
16
+ get readStream(): fs.ReadStream | Readable;
17
+ get writeStream(): fs.WriteStream;
18
+ write(contents: string | ReadableStream): void | Promise<void>;
13
19
  /**
14
20
  * creates file if it doesn't exist, appends string or array of strings as new lines.
15
21
  * File always ends with '\n', so contents don't need to be read before appending
16
22
  */
17
23
  append(lines: string | string[]): void;
18
- /**
19
- * @returns lines as strings, removes trailing '\n'
20
- */
21
- lines(): string[];
22
24
  static get FileType(): typeof FileType;
23
25
  json<T>(contents?: T): FileTypeJson<T>;
24
26
  static get json(): typeof FileTypeJson;
@@ -27,25 +29,41 @@ export declare class File {
27
29
  csv<T extends object>(rows?: T[], keys?: (keyof T)[]): Promise<FileTypeCsv<T>>;
28
30
  static get csv(): typeof FileTypeCsv;
29
31
  }
30
- export declare class FileType<T = string> {
32
+ /**
33
+ * A generic file adaptor, extended by specific file type implementations
34
+ */
35
+ export declare class FileType {
31
36
  file: File;
32
- constructor(filepath: string, contents?: T);
37
+ constructor(filepath: string, contents?: string);
33
38
  get exists(): boolean;
34
- delete(): void;
35
39
  get path(): string;
40
+ delete(): void;
36
41
  }
42
+ /**
43
+ * A .json file that maintains data type when reading/writing.
44
+ * This is unsafe! Type is not checked at runtime, avoid using on files manipulated outside of your application.
45
+ */
37
46
  export declare class FileTypeJson<T> extends FileType {
38
47
  constructor(filepath: string, contents?: T);
39
48
  read(): T | undefined;
40
49
  write(contents: T): void;
41
50
  }
51
+ /**
52
+ * New-line delimited json file (.ndjson)
53
+ * @see https://jsonltools.com/ndjson-format-specification
54
+ */
42
55
  export declare class FileTypeNdjson<T extends object> extends FileType {
43
56
  constructor(filepath: string, lines?: T | T[]);
44
57
  append(lines: T | T[]): void;
45
58
  lines(): T[];
46
59
  }
47
60
  type Key<T extends object> = keyof T;
61
+ /**
62
+ * Comma separated values (.csv).
63
+ * Input rows as objects, keys are used as column headers
64
+ */
48
65
  export declare class FileTypeCsv<Row extends object> extends FileType {
66
+ #private;
49
67
  constructor(filepath: string);
50
68
  write(rows: Row[], keys?: Key<Row>[]): Promise<void>;
51
69
  read(): Promise<Row[]>;
package/dist/File.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { writeToString, parseString } from 'fast-csv';
3
+ import { Readable } from 'node:stream';
4
+ import { finished } from 'node:stream/promises';
5
+ import { writeToStream, parseStream } from 'fast-csv';
4
6
  import { snapshot } from './snapshot.js';
5
7
  /**
6
8
  * WARNING: API will change!
@@ -13,18 +15,33 @@ export class File {
13
15
  get exists() {
14
16
  return fs.existsSync(this.path);
15
17
  }
16
- createWriteStream(options = {}) {
17
- return fs.createWriteStream(this.path, options);
18
- }
19
18
  delete() {
20
19
  fs.rmSync(this.path, { force: true });
21
20
  }
22
21
  read() {
23
22
  return this.exists ? fs.readFileSync(this.path, 'utf8') : undefined;
24
23
  }
24
+ /**
25
+ * @returns lines as strings, removes trailing '\n'
26
+ */
27
+ lines() {
28
+ const contents = (this.read() || '').split('\n');
29
+ return contents.slice(0, contents.length - 1);
30
+ }
31
+ get readStream() {
32
+ return this.exists ? fs.createReadStream(this.path) : Readable.from([]);
33
+ }
34
+ get writeStream() {
35
+ fs.mkdirSync(path.parse(this.path).dir, { recursive: true });
36
+ return fs.createWriteStream(this.path);
37
+ }
25
38
  write(contents) {
26
39
  fs.mkdirSync(path.parse(this.path).dir, { recursive: true });
27
- fs.writeFileSync(this.path, contents);
40
+ if (typeof contents === 'string')
41
+ return fs.writeFileSync(this.path, contents);
42
+ if (contents instanceof ReadableStream)
43
+ return finished(Readable.from(contents).pipe(this.writeStream));
44
+ throw new Error(`Invalid content type: ${typeof contents}`);
28
45
  }
29
46
  /**
30
47
  * creates file if it doesn't exist, appends string or array of strings as new lines.
@@ -36,13 +53,6 @@ export class File {
36
53
  const contents = Array.isArray(lines) ? lines.join('\n') : lines;
37
54
  fs.appendFileSync(this.path, contents + '\n');
38
55
  }
39
- /**
40
- * @returns lines as strings, removes trailing '\n'
41
- */
42
- lines() {
43
- const contents = (this.read() || '').split('\n');
44
- return contents.slice(0, contents.length - 1);
45
- }
46
56
  static get FileType() {
47
57
  return FileType;
48
58
  }
@@ -68,27 +78,30 @@ export class File {
68
78
  return FileTypeCsv;
69
79
  }
70
80
  }
81
+ /**
82
+ * A generic file adaptor, extended by specific file type implementations
83
+ */
71
84
  export class FileType {
72
85
  file;
73
86
  constructor(filepath, contents) {
74
87
  this.file = new File(filepath);
75
- if (contents) {
76
- if (typeof contents !== 'string') {
77
- throw new Error('File contents must be a string');
78
- }
88
+ if (contents)
79
89
  this.file.write(contents);
80
- }
81
90
  }
82
91
  get exists() {
83
92
  return this.file.exists;
84
93
  }
85
- delete() {
86
- this.file.delete();
87
- }
88
94
  get path() {
89
95
  return this.file.path;
90
96
  }
97
+ delete() {
98
+ this.file.delete();
99
+ }
91
100
  }
101
+ /**
102
+ * A .json file that maintains data type when reading/writing.
103
+ * This is unsafe! Type is not checked at runtime, avoid using on files manipulated outside of your application.
104
+ */
92
105
  export class FileTypeJson extends FileType {
93
106
  constructor(filepath, contents) {
94
107
  super(filepath.endsWith('.json') ? filepath : filepath + '.json');
@@ -103,6 +116,10 @@ export class FileTypeJson extends FileType {
103
116
  this.file.write(JSON.stringify(snapshot(contents), null, 2));
104
117
  }
105
118
  }
119
+ /**
120
+ * New-line delimited json file (.ndjson)
121
+ * @see https://jsonltools.com/ndjson-format-specification
122
+ */
106
123
  export class FileTypeNdjson extends FileType {
107
124
  constructor(filepath, lines) {
108
125
  super(filepath.endsWith('.ndjson') ? filepath : filepath + '.ndjson');
@@ -116,6 +133,10 @@ export class FileTypeNdjson extends FileType {
116
133
  return this.file.lines().map((l) => JSON.parse(l));
117
134
  }
118
135
  }
136
+ /**
137
+ * Comma separated values (.csv).
138
+ * Input rows as objects, keys are used as column headers
139
+ */
119
140
  export class FileTypeCsv extends FileType {
120
141
  constructor(filepath) {
121
142
  super(filepath.endsWith('.csv') ? filepath : filepath + '.csv');
@@ -134,35 +155,31 @@ export class FileTypeCsv extends FileType {
134
155
  }
135
156
  const headers = Array.from(headerSet);
136
157
  const outRows = rows.map((row) => headers.map((key) => row[key]));
137
- const contents = await writeToString([headers, ...outRows]);
138
- this.file.write(contents);
158
+ return finished(writeToStream(this.file.writeStream, [headers, ...outRows]));
159
+ }
160
+ #parseVal(val) {
161
+ if (val.toLowerCase() === 'false')
162
+ return false;
163
+ if (val.toLowerCase() === 'true')
164
+ return true;
165
+ if (val.length === 0)
166
+ return null;
167
+ if (/^[\.0-9]+$/.test(val))
168
+ return Number(val);
169
+ return val;
139
170
  }
140
171
  async read() {
141
172
  return new Promise((resolve, reject) => {
142
173
  const parsed = [];
143
- const content = this.file.read();
144
- if (!content)
145
- return resolve(parsed);
146
- function parseVal(val) {
147
- if (val.toLowerCase() === 'false')
148
- return false;
149
- if (val.toLowerCase() === 'true')
150
- return true;
151
- if (val.length === 0)
152
- return null;
153
- if (/^[\.0-9]+$/.test(val))
154
- return Number(val);
155
- return val;
156
- }
157
- parseString(content, { headers: true })
174
+ parseStream(this.file.readStream, { headers: true })
158
175
  .on('error', (e) => reject(e))
159
- .on('end', () => resolve(parsed))
160
176
  .on('data', (raw) => {
161
177
  parsed.push(Object.entries(raw).reduce((all, [key, val]) => ({
162
178
  ...all,
163
- [key]: parseVal(val),
179
+ [key]: this.#parseVal(val),
164
180
  }), {}));
165
- });
181
+ })
182
+ .on('end', () => resolve(parsed));
166
183
  });
167
184
  }
168
185
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,6 @@ export { Dir, TempDir, temp } from './Dir.js';
2
2
  export { Cache } from './Cache.js';
3
3
  export { Fetcher, type Route, type Query, type FetchOptions } from './Fetcher.js';
4
4
  export { File, FileType, FileTypeJson, FileTypeNdjson, FileTypeCsv } from './File.js';
5
- export { Jwt } from './Jwt.js';
6
5
  export { Log } from './Log.js';
7
6
  export { snapshot } from './snapshot.js';
8
7
  export { timeout } from './timeout.js';
package/dist/index.js CHANGED
@@ -2,7 +2,6 @@ export { Dir, TempDir, temp } from './Dir.js';
2
2
  export { Cache } from './Cache.js';
3
3
  export { Fetcher } from './Fetcher.js';
4
4
  export { File, FileType, FileTypeJson, FileTypeNdjson, FileTypeCsv } from './File.js';
5
- export { Jwt } from './Jwt.js';
6
5
  export { Log } from './Log.js';
7
6
  export { snapshot } from './snapshot.js';
8
7
  export { timeout } from './timeout.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brianbuie/node-kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,15 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "chalk": "^5.6.2",
33
+ "date-fns": "^4.1.0",
33
34
  "extract-domain": "^5.0.2",
34
35
  "fast-csv": "^5.0.5",
35
- "jsonwebtoken": "^9.0.2",
36
36
  "lodash-es": "^4.17.21",
37
37
  "quicktype-core": "^23.2.6",
38
38
  "sanitize-filename": "^1.6.3"
39
39
  },
40
40
  "devDependencies": {
41
- "@types/jsonwebtoken": "^9.0.10",
42
41
  "@types/lodash-es": "^4.17.12",
43
42
  "@types/node": "^24.9.1",
44
43
  "ts2md": "^0.2.8",
package/src/Cache.ts CHANGED
@@ -1,33 +1,30 @@
1
+ import { type Duration, isAfter, add } from 'date-fns';
1
2
  import { temp } from './Dir.js';
2
3
 
3
4
  const cacheDir = temp.dir('cache');
4
5
 
5
6
  /**
6
- * Save results of a function in a temporary file.
7
- * @param key A unique name for the file
8
- * @param ttl cache duration in ms
9
- * @param getValue the function to populate the cache (eg. fetch results, generate key, etc)
7
+ * Save data to a local file with an expiration.
8
+ * Fresh/stale data is returned with a flag for if it's fresh or not,
9
+ * so stale data can still be used if needed.
10
10
  */
11
11
  export class Cache<T> {
12
12
  file;
13
13
  ttl;
14
- getValue;
15
14
 
16
- constructor(key: string, ttl: number, getValue: () => T | Promise<T>) {
17
- this.file = cacheDir.file(key).json<{ createdAt: number; value: T }>();
18
- this.ttl = ttl;
19
- this.getValue = getValue;
15
+ constructor(key: string, ttl: number | Duration, initialData?: T) {
16
+ this.file = cacheDir.file(key).json<{ savedAt: string; data: T }>();
17
+ this.ttl = typeof ttl === 'number' ? { minutes: ttl } : ttl;
18
+ if (initialData) this.write(initialData);
20
19
  }
21
20
 
22
- async read() {
23
- const { createdAt, value } = this.file.read() || {};
24
- if (value && createdAt && createdAt + this.ttl > Date.now()) return value;
25
- return this.write();
21
+ write(data: T) {
22
+ this.file.write({ savedAt: new Date().toUTCString(), data });
26
23
  }
27
24
 
28
- async write() {
29
- const value = await this.getValue();
30
- this.file.write({ createdAt: Date.now(), value });
31
- return value;
25
+ read(): [T | undefined, boolean] {
26
+ const { savedAt, data } = this.file.read() || {};
27
+ const isFresh = Boolean(savedAt && isAfter(add(savedAt, this.ttl), new Date()));
28
+ return [data, isFresh];
32
29
  }
33
30
  }
package/src/Dir.ts CHANGED
@@ -35,7 +35,7 @@ export class Dir {
35
35
  }
36
36
 
37
37
  sanitize(name: string) {
38
- return sanitizeFilename(name.replace('https://', '').replace('www.', ''), { replacement: '_' });
38
+ return sanitizeFilename(name.replace('https://', '').replace('www.', ''), { replacement: '_' }).slice(-200);
39
39
  }
40
40
 
41
41
  /**
package/src/File.test.ts CHANGED
@@ -14,6 +14,17 @@ const thing = {
14
14
  e: null,
15
15
  };
16
16
 
17
+ describe('File', () => {
18
+ it('Handles request body as stream input', async () => {
19
+ const img = testDir.file('image.jpg');
20
+ await fetch('https://testingbot.com/free-online-tools/random-avatar/300').then((res) => {
21
+ if (!res.body) throw new Error('No response body');
22
+ return img.write(res.body);
23
+ });
24
+ assert(img.exists);
25
+ });
26
+ });
27
+
17
28
  describe('FileType', () => {
18
29
  it('Creates instances', () => {
19
30
  const test1 = new File.FileType(testDir.filepath('test1.txt'));
@@ -35,7 +46,23 @@ describe('FileType', () => {
35
46
  });
36
47
  });
37
48
 
38
- describe('File.ndjson', () => {
49
+ describe('FileTypeJson', () => {
50
+ it('Saves data as json', () => {
51
+ const file = testDir.file('jsonfile-data').json(thing);
52
+ assert.deepStrictEqual(file.read(), thing);
53
+ file.write(thing);
54
+ assert.deepStrictEqual(file.read(), thing);
55
+ });
56
+
57
+ it('Does not create file when reading', () => {
58
+ const file = testDir.file('test123').json();
59
+ const contents = file.read();
60
+ assert(contents === undefined);
61
+ assert(!file.exists);
62
+ });
63
+ });
64
+
65
+ describe('FileTypeNdjson', () => {
39
66
  it('Appends new lines correctly', () => {
40
67
  const file = testDir.file('appends-lines').ndjson();
41
68
  file.delete();
@@ -57,23 +84,7 @@ describe('File.ndjson', () => {
57
84
  });
58
85
  });
59
86
 
60
- describe('File.json', () => {
61
- it('Saves data as json', () => {
62
- const file = testDir.file('jsonfile-data').json(thing);
63
- assert.deepStrictEqual(file.read(), thing);
64
- file.write(thing);
65
- assert.deepStrictEqual(file.read(), thing);
66
- });
67
-
68
- it('Does not create file when reading', () => {
69
- const file = testDir.file('test123').json();
70
- const contents = file.read();
71
- assert(contents === undefined);
72
- assert(!file.exists);
73
- });
74
- });
75
-
76
- describe('File.csv', () => {
87
+ describe('FileTypeCsv', () => {
77
88
  it('Saves data as csv', async () => {
78
89
  const things = [thing, thing, thing];
79
90
  const file = await testDir.file('csv-data').csv(things);
@@ -82,4 +93,10 @@ describe('File.csv', () => {
82
93
  assert.deepEqual(row, thing);
83
94
  });
84
95
  });
96
+ it('Reads file that does not exist', async () => {
97
+ const file = await testDir.file('bogus').csv();
98
+ const contents = await file.read();
99
+ assert(Array.isArray(contents));
100
+ assert(contents.length === 0);
101
+ });
85
102
  });
package/src/File.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { writeToString, parseString } from 'fast-csv';
3
+ import { Readable } from 'node:stream';
4
+ import { finished } from 'node:stream/promises';
5
+ import { writeToStream, parseStream } from 'fast-csv';
4
6
  import { snapshot } from './snapshot.js';
5
7
 
6
8
  /**
@@ -17,10 +19,6 @@ export class File {
17
19
  return fs.existsSync(this.path);
18
20
  }
19
21
 
20
- createWriteStream(options: Parameters<typeof fs.createWriteStream>[1] = {}) {
21
- return fs.createWriteStream(this.path, options);
22
- }
23
-
24
22
  delete() {
25
23
  fs.rmSync(this.path, { force: true });
26
24
  }
@@ -29,9 +27,28 @@ export class File {
29
27
  return this.exists ? fs.readFileSync(this.path, 'utf8') : undefined;
30
28
  }
31
29
 
32
- write(contents: string) {
30
+ /**
31
+ * @returns lines as strings, removes trailing '\n'
32
+ */
33
+ lines() {
34
+ const contents = (this.read() || '').split('\n');
35
+ return contents.slice(0, contents.length - 1);
36
+ }
37
+
38
+ get readStream() {
39
+ return this.exists ? fs.createReadStream(this.path) : Readable.from([]);
40
+ }
41
+
42
+ get writeStream() {
33
43
  fs.mkdirSync(path.parse(this.path).dir, { recursive: true });
34
- fs.writeFileSync(this.path, contents);
44
+ return fs.createWriteStream(this.path);
45
+ }
46
+
47
+ write(contents: string | ReadableStream) {
48
+ fs.mkdirSync(path.parse(this.path).dir, { recursive: true });
49
+ if (typeof contents === 'string') return fs.writeFileSync(this.path, contents);
50
+ if (contents instanceof ReadableStream) return finished(Readable.from(contents).pipe(this.writeStream));
51
+ throw new Error(`Invalid content type: ${typeof contents}`);
35
52
  }
36
53
 
37
54
  /**
@@ -44,14 +61,6 @@ export class File {
44
61
  fs.appendFileSync(this.path, contents + '\n');
45
62
  }
46
63
 
47
- /**
48
- * @returns lines as strings, removes trailing '\n'
49
- */
50
- lines() {
51
- const contents = (this.read() || '').split('\n');
52
- return contents.slice(0, contents.length - 1);
53
- }
54
-
55
64
  static get FileType() {
56
65
  return FileType;
57
66
  }
@@ -83,32 +92,34 @@ export class File {
83
92
  }
84
93
  }
85
94
 
86
- export class FileType<T = string> {
95
+ /**
96
+ * A generic file adaptor, extended by specific file type implementations
97
+ */
98
+ export class FileType {
87
99
  file;
88
100
 
89
- constructor(filepath: string, contents?: T) {
101
+ constructor(filepath: string, contents?: string) {
90
102
  this.file = new File(filepath);
91
- if (contents) {
92
- if (typeof contents !== 'string') {
93
- throw new Error('File contents must be a string');
94
- }
95
- this.file.write(contents);
96
- }
103
+ if (contents) this.file.write(contents);
97
104
  }
98
105
 
99
106
  get exists() {
100
107
  return this.file.exists;
101
108
  }
102
109
 
103
- delete() {
104
- this.file.delete();
105
- }
106
-
107
110
  get path() {
108
111
  return this.file.path;
109
112
  }
113
+
114
+ delete() {
115
+ this.file.delete();
116
+ }
110
117
  }
111
118
 
119
+ /**
120
+ * A .json file that maintains data type when reading/writing.
121
+ * This is unsafe! Type is not checked at runtime, avoid using on files manipulated outside of your application.
122
+ */
112
123
  export class FileTypeJson<T> extends FileType {
113
124
  constructor(filepath: string, contents?: T) {
114
125
  super(filepath.endsWith('.json') ? filepath : filepath + '.json');
@@ -125,6 +136,10 @@ export class FileTypeJson<T> extends FileType {
125
136
  }
126
137
  }
127
138
 
139
+ /**
140
+ * New-line delimited json file (.ndjson)
141
+ * @see https://jsonltools.com/ndjson-format-specification
142
+ */
128
143
  export class FileTypeNdjson<T extends object> extends FileType {
129
144
  constructor(filepath: string, lines?: T | T[]) {
130
145
  super(filepath.endsWith('.ndjson') ? filepath : filepath + '.ndjson');
@@ -133,7 +148,7 @@ export class FileTypeNdjson<T extends object> extends FileType {
133
148
 
134
149
  append(lines: T | T[]) {
135
150
  this.file.append(
136
- Array.isArray(lines) ? lines.map((l) => JSON.stringify(snapshot(l))) : JSON.stringify(snapshot(lines))
151
+ Array.isArray(lines) ? lines.map((l) => JSON.stringify(snapshot(l))) : JSON.stringify(snapshot(lines)),
137
152
  );
138
153
  }
139
154
 
@@ -144,6 +159,10 @@ export class FileTypeNdjson<T extends object> extends FileType {
144
159
 
145
160
  type Key<T extends object> = keyof T;
146
161
 
162
+ /**
163
+ * Comma separated values (.csv).
164
+ * Input rows as objects, keys are used as column headers
165
+ */
147
166
  export class FileTypeCsv<Row extends object> extends FileType {
148
167
  constructor(filepath: string) {
149
168
  super(filepath.endsWith('.csv') ? filepath : filepath + '.csv');
@@ -160,36 +179,34 @@ export class FileTypeCsv<Row extends object> extends FileType {
160
179
  }
161
180
  const headers = Array.from(headerSet);
162
181
  const outRows = rows.map((row) => headers.map((key) => row[key]));
163
- const contents = await writeToString([headers, ...outRows]);
164
- this.file.write(contents);
182
+ return finished(writeToStream(this.file.writeStream, [headers, ...outRows]));
183
+ }
184
+
185
+ #parseVal(val: string) {
186
+ if (val.toLowerCase() === 'false') return false;
187
+ if (val.toLowerCase() === 'true') return true;
188
+ if (val.length === 0) return null;
189
+ if (/^[\.0-9]+$/.test(val)) return Number(val);
190
+ return val;
165
191
  }
166
192
 
167
193
  async read() {
168
194
  return new Promise<Row[]>((resolve, reject) => {
169
195
  const parsed: Row[] = [];
170
- const content = this.file.read();
171
- if (!content) return resolve(parsed);
172
- function parseVal(val: string) {
173
- if (val.toLowerCase() === 'false') return false;
174
- if (val.toLowerCase() === 'true') return true;
175
- if (val.length === 0) return null;
176
- if (/^[\.0-9]+$/.test(val)) return Number(val);
177
- return val;
178
- }
179
- parseString(content, { headers: true })
196
+ parseStream(this.file.readStream, { headers: true })
180
197
  .on('error', (e) => reject(e))
181
- .on('end', () => resolve(parsed))
182
198
  .on('data', (raw: Record<Key<Row>, string>) => {
183
199
  parsed.push(
184
200
  Object.entries(raw).reduce(
185
201
  (all, [key, val]) => ({
186
202
  ...all,
187
- [key]: parseVal(val as string),
203
+ [key]: this.#parseVal(val as string),
188
204
  }),
189
- {} as Row
190
- )
205
+ {} as Row,
206
+ ),
191
207
  );
192
- });
208
+ })
209
+ .on('end', () => resolve(parsed));
193
210
  });
194
211
  }
195
212
  }
package/src/index.ts CHANGED
@@ -2,7 +2,6 @@ export { Dir, TempDir, temp } from './Dir.js';
2
2
  export { Cache } from './Cache.js';
3
3
  export { Fetcher, type Route, type Query, type FetchOptions } from './Fetcher.js';
4
4
  export { File, FileType, FileTypeJson, FileTypeNdjson, FileTypeCsv } from './File.js';
5
- export { Jwt } from './Jwt.js';
6
5
  export { Log } from './Log.js';
7
6
  export { snapshot } from './snapshot.js';
8
7
  export { timeout } from './timeout.js';
package/dist/Jwt.d.ts DELETED
@@ -1,15 +0,0 @@
1
- import { type JwtPayload, type SignOptions } from 'jsonwebtoken';
2
- type JwtConfig = {
3
- payload: JwtPayload;
4
- options: SignOptions;
5
- seconds: number;
6
- key: string;
7
- };
8
- export declare class Jwt {
9
- #private;
10
- config: JwtConfig;
11
- constructor(config: JwtConfig);
12
- get now(): number;
13
- get token(): string;
14
- }
15
- export {};
package/dist/Jwt.js DELETED
@@ -1,29 +0,0 @@
1
- import { default as jsonwebtoken } from 'jsonwebtoken';
2
- import { merge } from 'lodash-es';
3
- export class Jwt {
4
- config;
5
- #saved;
6
- constructor(config) {
7
- this.config = config;
8
- this.#createToken();
9
- }
10
- get now() {
11
- return Math.floor(Date.now() / 1000);
12
- }
13
- #createToken() {
14
- const exp = this.now + this.config.seconds;
15
- const payload = merge({
16
- iat: this.now,
17
- exp,
18
- }, this.config.payload);
19
- const token = jsonwebtoken.sign(payload, this.config.key, this.config.options);
20
- this.#saved = { token, exp };
21
- return token;
22
- }
23
- get token() {
24
- if (this.#saved && this.#saved.exp > this.now) {
25
- return this.#saved.token;
26
- }
27
- return this.#createToken();
28
- }
29
- }
package/src/Jwt.test.ts DELETED
@@ -1,22 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert';
3
- import jsonwebtoken from 'jsonwebtoken';
4
- import { Jwt } from './Jwt.js';
5
-
6
- describe('Jwt', () => {
7
- it('Creates a valid JWT', () => {
8
- const key = 'test';
9
- const jwt = new Jwt({
10
- payload: {
11
- example: 'value',
12
- },
13
- options: {
14
- algorithm: 'HS256',
15
- },
16
- seconds: 60,
17
- key,
18
- });
19
- const result = jsonwebtoken.verify(jwt.token, key);
20
- assert(typeof result !== 'string' && result.example === 'value');
21
- });
22
- });
package/src/Jwt.ts DELETED
@@ -1,47 +0,0 @@
1
- import { default as jsonwebtoken, type JwtPayload, type SignOptions } from 'jsonwebtoken';
2
- import { merge } from 'lodash-es';
3
-
4
- type JwtConfig = {
5
- payload: JwtPayload;
6
- options: SignOptions;
7
- seconds: number;
8
- key: string;
9
- };
10
-
11
- export class Jwt {
12
- config;
13
- #saved?: {
14
- exp: number;
15
- token: string;
16
- };
17
-
18
- constructor(config: JwtConfig) {
19
- this.config = config;
20
- this.#createToken();
21
- }
22
-
23
- get now() {
24
- return Math.floor(Date.now() / 1000);
25
- }
26
-
27
- #createToken() {
28
- const exp = this.now + this.config.seconds;
29
- const payload: JwtPayload = merge(
30
- {
31
- iat: this.now,
32
- exp,
33
- },
34
- this.config.payload
35
- );
36
- const token = jsonwebtoken.sign(payload, this.config.key, this.config.options);
37
- this.#saved = { token, exp };
38
- return token;
39
- }
40
-
41
- get token() {
42
- if (this.#saved && this.#saved.exp > this.now) {
43
- return this.#saved.token;
44
- }
45
- return this.#createToken();
46
- }
47
- }