@coderich/sandman 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/index.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./src/app');
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@coderich/sandman",
3
+ "version": "0.0.1",
4
+ "main": "index.js",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "files": [
9
+ "src",
10
+ "!__mocks__"
11
+ ],
12
+ "scripts": {
13
+ "test": "jest",
14
+ "lint": "eslint ./",
15
+ "dev": "coderich-dev"
16
+ },
17
+ "dependencies": {
18
+ "@coderich/config": "2.2.4",
19
+ "@coderich/util": "2.1.3",
20
+ "chokidar": "4.0.3",
21
+ "lodash.merge": "4.6.2"
22
+ },
23
+ "devDependencies": {
24
+ "@coderich/dev": "0.5.3"
25
+ }
26
+ }
@@ -0,0 +1,25 @@
1
+ const FS = require('fs');
2
+ const Path = require('path');
3
+ const Config = require('@coderich/config');
4
+
5
+ module.exports = class ConfigClient extends Config {
6
+ mergeConfigDir(dir) {
7
+ const ignored = (parsed) => {
8
+ if (parsed.name.startsWith('.')) return true;
9
+ const stat = FS.statSync(Path.join(parsed.dir, `${parsed.name}${parsed.ext}`));
10
+ if (stat?.isDirectory()) return false;
11
+ return !['.yml', '.yaml'].includes(parsed.ext.toLowerCase());
12
+ };
13
+
14
+ const arr = Config.dirPaths(dir, ignored);
15
+
16
+ const yaml = arr.reduce((prev, { paths, data }) => {
17
+ const path = paths.join('.');
18
+ if (!path.length) return prev.concat(data);
19
+ const indented = data.split('\n').map(line => (line.trim() ? ` ${line}` : line)).join('\n');
20
+ return prev.concat(`${path}:\n${indented}`);
21
+ }, '');
22
+
23
+ return this.merge(Config.parseYaml(yaml));
24
+ }
25
+ };
@@ -0,0 +1,57 @@
1
+ const Merge = require('lodash.merge');
2
+
3
+ exports.fetch = (req) => {
4
+ return new Promise((resolve, reject) => {
5
+ const { url, ...params } = exports.normalizeRequest({ ...req });
6
+
7
+ fetch(url, params).then(async (res) => {
8
+ const ct = res.headers.get('content-type') || '';
9
+ const data = await (ct.includes('application/json') ? res.json() : res.text());
10
+ return { res, data };
11
+ }).then(resolve).catch(reject);
12
+ });
13
+ };
14
+
15
+ exports.normalizeRequest = (req) => {
16
+ req.method ??= 'get'; req.headers ??= {}; req.params ??= {};
17
+ req.url = Object.entries(req.params).reduce((url, [key, value]) => { url.searchParams.append(key, value); return url; }, new URL(req.url)).toString();
18
+ req.headers = Object.entries(req.headers).reduce((prev, [key, value]) => Object.assign(prev, { [key.toLowerCase()]: value }), {});
19
+ const [contentType] = req.headers['content-type']?.split(';') || [];
20
+ const { data } = req; delete req.data;
21
+ if (data == null) return req;
22
+
23
+ switch (contentType) {
24
+ case 'application/json': {
25
+ req.body = JSON.stringify(data);
26
+ break;
27
+ }
28
+ case 'application/x-www-form-urlencoded': {
29
+ req.body = new URLSearchParams(data).toString();
30
+ break;
31
+ }
32
+ case 'multipart/form-data': {
33
+ delete req.headers['content-type'];
34
+ // req.duplex = 'half';
35
+ req.body = Object.entries(data).reduce((form, [key, value]) => {
36
+ form.append(key, value);
37
+ return form;
38
+ }, new FormData());
39
+ break;
40
+ }
41
+ default: {
42
+ req.body = data;
43
+ break;
44
+ }
45
+ }
46
+
47
+ return req;
48
+ };
49
+
50
+ exports.decorateRequest = (mergeData, key, request) => {
51
+ const toMerge = key.split('.').reduce((prev, k, i, arr) => {
52
+ const $key = arr.slice(0, i).join('.');
53
+ return Merge(prev, mergeData[$key]?.request);
54
+ }, { ...mergeData.request });
55
+
56
+ return Merge(toMerge, request);
57
+ };
package/src/Sandman.js ADDED
@@ -0,0 +1,197 @@
1
+ const Path = require('path');
2
+ const Readline = require('readline');
3
+ const Chokidar = require('chokidar');
4
+ const EventEmitter = require('events');
5
+ const { get, flatten } = require('@coderich/util');
6
+ const FetchService = require('./FetchService');
7
+ const ConfigClient = require('./ConfigClient');
8
+
9
+ module.exports = class Sandman extends EventEmitter {
10
+ #configClient; #configDir; #options; #watcher; #readline; #mergeData = {}; #cli;
11
+
12
+ constructor(configDir, options) {
13
+ super();
14
+ this.#configDir = configDir;
15
+ this.#options = options;
16
+ this.#configClient = new ConfigClient().merge(ConfigClient.parseDir(configDir, arg => this.#ignore(arg)));
17
+ this.#createInterface();
18
+ this.#createWatcher();
19
+ this.#createCLI();
20
+ this.#prompt();
21
+
22
+ this.#readline.on('line', async (line) => {
23
+ if (!line) return this.#readline.prompt();
24
+ const [cmd, ...args] = line.trim().split(' ');
25
+ const info = this.#cli[cmd] ? { cmd, args } : { cmd: 'run', args: [cmd, ...args] };
26
+ const value = await Promise.resolve(this.#cli[info.cmd](...info.args)).catch(e => e);
27
+ return this.emit(cmd, value);
28
+ });
29
+ }
30
+
31
+ cli() {
32
+ return this.#cli;
33
+ }
34
+
35
+ #run(key) {
36
+ const api = this.#get(key, {});
37
+ if (!api?.request) return this.emit('error', { key, error: `Request "${key}" Not Found` });
38
+ this.emit('request', { key, api });
39
+
40
+ return FetchService.fetch(api.request).then(({ res, data }) => {
41
+ this.emit('response', { key, api, res, data });
42
+ return { key, api, res, data };
43
+ }).catch((error) => {
44
+ this.emit('error', { key, api, error });
45
+ return Promise.reject(error);
46
+ }).then((results) => {
47
+ return results.res.ok ? results : Promise.reject(results);
48
+ });
49
+ }
50
+
51
+ #prompt() {
52
+ this.#readline.setPrompt(this.#configClient.get('prompt'));
53
+ this.#readline.prompt();
54
+ return this;
55
+ }
56
+
57
+ #get(key, ...rest) {
58
+ const { config } = this.#configClient.toObject();
59
+ const value = get(config, key);
60
+
61
+ if (value?.request) {
62
+ const request = FetchService.decorateRequest(this.#mergeData, key, value.request);
63
+ this.#configClient.merge({ [key]: { request } });
64
+ }
65
+
66
+ return this.#configClient.get(key, ...rest);
67
+ }
68
+
69
+ #createCLI() {
70
+ const self = this;
71
+
72
+ this.#cli = new Proxy({
73
+ run: (...args) => this.#run(...args),
74
+ get: (...args) => this.#get(...args),
75
+ set: (key = '', value = null) => this.#configClient.set(key, value),
76
+ del: (key = '') => this.#configClient.del(key),
77
+ quit: () => process.exit(),
78
+ }, {
79
+ get(obj, prop, receiver) {
80
+ const value = Reflect.get(obj, prop, receiver);
81
+
82
+ if (typeof value === 'function') {
83
+ return (...args) => {
84
+ const result = value(...args);
85
+ setImmediate(() => self.#prompt());
86
+ return result;
87
+ };
88
+ }
89
+
90
+ return value;
91
+ },
92
+ });
93
+ }
94
+
95
+ #createInterface() {
96
+ this.#readline = Readline.createInterface({
97
+ input: process.stdin,
98
+ output: process.stdout,
99
+ terminal: true,
100
+ completer: (line) => {
101
+ // Show available CLI commands
102
+ if (!line) return [Object.keys(this.#cli), line];
103
+
104
+ const tokens = line.split(' ');
105
+ const lastToken = tokens.at(-1);
106
+ const paths = lastToken.split('.');
107
+ const path = paths.at(-1);
108
+
109
+ // Specific request data selector
110
+ if (lastToken.startsWith('.')) {
111
+ const api = this.#get(tokens.at(-2));
112
+ if (!api?.request) return [[], path];
113
+ const dataPath = ['request'].concat(paths.slice(1, -1)).join('.');
114
+ const data = get(api, dataPath, {});
115
+ return [Object.keys(data).filter(k => k.toLowerCase().startsWith(path.toLowerCase())), path];
116
+ }
117
+
118
+ //
119
+ const flatKeys = Object.keys(flatten(this.#configClient.get()));
120
+
121
+ // These keys follow the typing of the user
122
+ const startsWithCandidates = Array.from(new Set(flatKeys.map((flatKey) => {
123
+ return flatKey.split('.').slice(0, paths.length).join('.'); // So we can pluck off the last one
124
+ }))).filter((c) => {
125
+ return c.toLowerCase().startsWith(lastToken.toLowerCase());
126
+ }).map(p => p.split('.').at(-1)); // Here!
127
+
128
+ // These are shortcut keys to requests
129
+ const requestKeyCandidates = Array.from(new Set(flatKeys.map((flatKey) => {
130
+ const keys = flatKey.split('.');
131
+ const index = keys.indexOf('request');
132
+ const typedPath = keys.slice(0, paths.length - 1).join('.');
133
+ const autocompletePath = keys.slice(paths.length - 1, index).join('.');
134
+ return index > 0 && lastToken.toLowerCase().startsWith(typedPath.toLowerCase()) && autocompletePath;
135
+ }).filter(Boolean))).filter((c) => {
136
+ return c.toLowerCase().includes(path.toLowerCase());
137
+ });
138
+
139
+ const candidates = Array.from(new Set(startsWithCandidates.concat(requestKeyCandidates)));
140
+
141
+ return [candidates, path];
142
+ },
143
+ });
144
+
145
+ process.stdin.on('keypress', (ch, key) => {
146
+ if (key && key.name === 'escape') {
147
+ this.#readline.line = '';
148
+ Readline.cursorTo(process.stdout, 0);
149
+ Readline.clearLine(process.stdout, 0);
150
+ this.#readline.prompt();
151
+ }
152
+ });
153
+ }
154
+
155
+ #createWatcher() {
156
+ this.#watcher = Chokidar.watch(this.#configDir, {
157
+ awaitWriteFinish: true,
158
+ ignoreInitial: true,
159
+ ignored: filepath => this.#ignore(this.#normalizeWatcherPath(filepath)),
160
+ });
161
+
162
+ this.#watcher.on('all', (event, path) => {
163
+ const { key } = this.#normalizeWatcherPath(path);
164
+
165
+ if (['add', 'change'].includes(event)) {
166
+ const api = ConfigClient.parseFile(path);
167
+ if (key) this.#configClient.set(key, api);
168
+ else this.#configClient.merge(api);
169
+ if (api.request) this.emit('save', { key, api });
170
+ } else if (['unlink', 'unlinkDir'].includes(event)) {
171
+ this.#configClient.del(key);
172
+ }
173
+ });
174
+ }
175
+
176
+ #ignore({ name, filepath, paths }) {
177
+ if (name.startsWith('.')) return true;
178
+
179
+ if (name.startsWith('+')) {
180
+ const request = ConfigClient.parseFile(filepath);
181
+ const key = paths.slice(0, -1).join('.');
182
+ if (key) this.#mergeData[key] = { request };
183
+ else this.#mergeData.request = request;
184
+ return true;
185
+ }
186
+
187
+ return false;
188
+ }
189
+
190
+ #normalizeWatcherPath = (filepath) => {
191
+ const parsed = Path.parse(filepath);
192
+ const folder = filepath.substring(this.#configDir.length + 1, filepath.length - parsed.ext.length);
193
+ const paths = folder.split('/').filter(el => el && el !== 'index');
194
+ const key = paths.join('.');
195
+ return { ...parsed, filepath, paths, key };
196
+ };
197
+ };
package/src/app.js ADDED
@@ -0,0 +1,5 @@
1
+ const Sandman = require('./Sandman');
2
+
3
+ module.exports = (configDir, options) => {
4
+ return new Sandman(configDir, options);
5
+ };