UNPKG

8.99 kBJavaScriptView Raw
1const Config = require('webpack-chain');
2const merge = require('deepmerge');
3const Future = require('fluture');
4const mitt = require('mitt');
5const { join } = require('path');
6const {
7 defaultTo, is, map, omit, replace
8} = require('ramda');
9const { normalizePath, toArray, req, pathOptions } = require('./utils');
10
11class Api {
12 constructor(options) {
13 this.options = this.getOptions(options);
14 this.listeners = {};
15 this.emitter = mitt(this.listeners);
16 this.commands = {};
17 this.commandDescriptions = {};
18 this.config = new Config();
19 }
20
21 // getOptions :: Object? -> IO Object
22 getOptions(opts = {}) {
23 let moduleExtensions = new Set(['js', 'jsx', 'vue', 'ts', 'tsx', 'mjs']);
24 const options = merge.all([
25 {
26 env: {
27 NODE_ENV: 'development'
28 },
29 debug: false,
30 quiet: false
31 },
32 opts.mains ? { mains: opts.mains } : { mains: { index: 'index' } },
33 omit(['mains'], opts)
34 ]);
35
36 Object
37 .keys(options.env)
38 .forEach(env => process.env[env] = options.env[env]);
39
40 pathOptions.forEach(([path, defaultValue, getNormalizeBase]) => {
41 let value = defaultTo(defaultValue, options[path]);
42
43 Object.defineProperty(options, path, {
44 enumerable: true,
45 get() {
46 return normalizePath(getNormalizeBase(options), value);
47 },
48 set(newValue) {
49 value = defaultTo(defaultValue, newValue);
50 }
51 });
52 });
53
54 try {
55 options.packageJson = require(join(options.root, 'package.json')); // eslint-disable-line global-require
56 } catch (err) {
57 options.packageJson = null;
58 }
59
60 Object.defineProperty(options, 'extensions', {
61 enumerable: true,
62 get() {
63 return [...moduleExtensions];
64 },
65 set(extensions) {
66 moduleExtensions = new Set(extensions.map(replace('.', '')));
67 }
68 });
69
70 this.bindMainsOnOptions(options);
71
72 return options;
73 }
74
75 // mergeOptions :: (Object -> Object) -> Object
76 mergeOptions(options, newOptions) {
77 /* eslint-disable no-param-reassign */
78 const paths = pathOptions.map(([path]) => path);
79
80 Object
81 .keys(newOptions)
82 .forEach((key) => {
83 if (key === 'mains') {
84 this.bindMainsOnOptions(newOptions, options);
85 options.mains = newOptions.mains;
86 return;
87 }
88
89 const value = newOptions[key];
90
91 if (paths.includes(key)) {
92 options[key] = value;
93 return;
94 }
95
96 // Only merge values if there is an existing value to merge with,
97 // and if the value types match, and if the value types are both
98 // objects or both arrays. Otherwise just replace the old value
99 // with the new value.
100 if (
101 options[key] &&
102 (
103 is(Object, options[key]) && is(Object, value) ||
104 is(Array, options[key]) && is(Array, value)
105 )
106 ) {
107 options[key] = merge(options[key], newOptions[key]);
108 } else {
109 options[key] = newOptions[key];
110 }
111 });
112
113 /* eslint-enable no-param-reassign */
114 return options;
115 }
116
117 // bindMainsOnOptions :: (Object options -> Object? optionsSource) -> IO ()
118 bindMainsOnOptions(options, optionsSource) {
119 Object
120 .keys(options.mains)
121 .forEach(key => {
122 let value = options.mains[key];
123
124 Object.defineProperty(options.mains, key, {
125 enumerable: true,
126 get() {
127 const source = optionsSource && optionsSource.source || options.source;
128
129 return normalizePath(source, value);
130 },
131 set(newValue) {
132 value = newValue;
133 }
134 });
135 });
136
137 this.mainsProxy = new Proxy(options.mains, {
138 defineProperty: (target, prop, { value }) => {
139 let currentValue = value;
140
141 return Reflect.defineProperty(target, prop, {
142 enumerable: true,
143 get() {
144 const source = optionsSource && optionsSource.source || options.source;
145
146 return normalizePath(source, currentValue);
147 },
148 set(newValue) {
149 currentValue = newValue;
150 }
151 });
152 }
153 });
154 }
155
156 // regexFromExtensions :: Array extensions -> RegExp
157 regexFromExtensions(extensions = this.options.extensions) {
158 return new RegExp(String.raw`\.(${extensions.join('|')})$`);
159 }
160
161 // emit :: Any -> IO
162 emit(...args) {
163 return this.emitter.emit(...args);
164 }
165
166 // emit :: Any -> IO
167 on(...args) {
168 return this.emitter.on(...args);
169 }
170
171 // emit :: Any -> IO
172 off(...args) {
173 return this.emitter.off(...args);
174 }
175
176 // emitForAll :: String eventName -> Any payload -> Promise
177 emitForAll(eventName, payload) {
178 return Promise.all([
179 ...(this.listeners[eventName] || []).map(f => f(payload)),
180 ...(this.listeners['*'] || []).map(f => f(eventName, payload))
181 ]);
182 }
183
184 // register :: (String commandName -> Function handler -> String? description) -> Api
185 register(commandName, handler, description = '') {
186 this.commands[commandName] = handler;
187 this.commandDescriptions[commandName] = description;
188 return this;
189 }
190
191 // require :: String moduleId -> Any
192 require(moduleId, root = this.options.root) {
193 return req(moduleId, root);
194 }
195
196 // use :: Any middleware -> Object options -> IO Api
197 use(middleware, options) {
198 if (is(Function, middleware)) {
199 // If middleware is a function, invoke it with the provided options
200 middleware(this, options);
201 } else if (is(String, middleware)) {
202 // If middleware is a string, it's a module to require. Require it, then run the results back
203 // through .use() with the provided options
204 this.use(this.require(middleware, this.options.root), options);
205 } else if (is(Array, middleware)) {
206 // If middleware is an array, it's a pair of some other middleware type and options
207 this.use(...middleware);
208 } else if (is(Object, middleware)) {
209 // If middleware is an object, it could contain other middleware in its "use" property.
210 // Run every item in "use" prop back through .use(), plus set any options.
211 // The value of "env" will also be consumed as middleware, which will potentially load more middleware and
212 // options
213 if (middleware.options) {
214 this.options = this.mergeOptions(this.options, middleware.options);
215 }
216
217 if (middleware.env) {
218 const envMiddleware = Object
219 .keys(middleware.env)
220 .map((key) => {
221 const envValue = this.options.env[key] || process.env[key];
222 const env = middleware.env[key][envValue];
223
224 if (!env) {
225 return null;
226 }
227
228 if (!is(Object, env) || !env.options) {
229 return env;
230 }
231
232 this.options = this.mergeOptions(this.options, env.options);
233
234 return omit(['options'], env);
235 });
236
237 if (Array.isArray(middleware.use)) {
238 map(use => this.use(use), middleware.use);
239 } else {
240 this.use(middleware.use);
241 }
242
243 map(env => this.use(env), envMiddleware.filter(Boolean));
244 } else if (middleware.use) {
245 if (Array.isArray(middleware.use)) {
246 map(use => this.use(use), middleware.use);
247 } else {
248 this.use(middleware.use);
249 }
250 }
251 }
252
253 return this;
254 }
255
256 // call :: String commandName -> IO Any
257 call(commandName) {
258 const command = this.commands[commandName];
259
260 if (!command) {
261 throw new Error(`A command with the name "${commandName}" was not registered`);
262 }
263
264 return command(this.config.toConfig(), this);
265 }
266
267 // run :: String commandName -> Future
268 run(commandName) {
269 const emitForAll = this.emitForAll.bind(this);
270
271 return Future((reject, resolve) => (this.commands[commandName] ?
272 resolve() :
273 reject(new Error(`A command with the name "${commandName}" was not registered`))
274 ))
275 // Trigger all pre-events for the current command
276 .chain(() => Future.encaseP2(emitForAll, `pre${commandName}`, this.options.args))
277 // Trigger generic pre-event
278 .chain(() => Future.encaseP2(emitForAll, 'prerun', this.options.args))
279 // Execute the command
280 .chain(() => {
281 const result = this.commands[commandName](this.config.toConfig(), this);
282
283 return Future.isFuture(result) ?
284 result :
285 Future.tryP(() => Promise.resolve().then(() => result));
286 })
287 // Trigger all post-command events, resolving with the value of the command execution
288 .chain(value => Future
289 .encaseP2(emitForAll, commandName, this.options.args)
290 .chain(() => Future.of(value)))
291 // Trigger generic post-event, resolving with the value of the command execution
292 .chain(value => Future
293 .encaseP2(emitForAll, 'run', this.options.args)
294 .chain(() => Future.of(value)))
295 .mapRej(toArray);
296 }
297}
298
299module.exports = options => new Api(options);