@foxtware/mineral 0.1.0 → 0.1.2

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.
Files changed (68) hide show
  1. package/.creds.yml.sample +5 -0
  2. package/.gcloudignore +17 -0
  3. package/.hosting.yml.sample +23 -0
  4. package/README.md +3 -1
  5. package/_build_scripts/publish.js +105 -0
  6. package/_deploy_scripts/deployFromHostingYml.js +319 -0
  7. package/_deploy_scripts/execCommand.js +38 -0
  8. package/_deploy_scripts/generateHosted.js +44 -0
  9. package/_deploy_scripts/setEnvVarsGcloud.js +51 -0
  10. package/api/logiwa/logiwa.constants.js +8 -0
  11. package/api/logiwa/logiwa.utils.js +53 -0
  12. package/api/logiwa/logiwaAuthGet.js +86 -0
  13. package/api/logiwa/logiwaInventoriesGet.js +67 -0
  14. package/api/logiwa/logiwaOrderGet.js +54 -0
  15. package/api/logiwa/logiwaOrdersGet.js +92 -0
  16. package/api/logiwa/logiwaProductGet.js +54 -0
  17. package/api/logiwa/logiwaProductsGet.js +80 -0
  18. package/api/logiwa/logiwaReportGetAvailableToPromise.js +179 -0
  19. package/api/logiwa/logiwaReportGetInventoryCalculation.js +62 -0
  20. package/api/logiwa/logiwaReportGetInventorySnapshot.js +66 -0
  21. package/api/logiwa/logiwaReportGetTotalInventory.js +72 -0
  22. package/api/logiwa/logiwaWebhookStatusGet.js +54 -0
  23. package/api/logiwa/logiwaWebhookSubscribe.js +64 -0
  24. package/api/logiwa/logiwaWebhookUnsubscribe.js +54 -0
  25. package/api/logiwa/logiwaWebhooksGet.js +48 -0
  26. package/api/shopify/_example.get.js +46 -0
  27. package/api/shopify/shopifyAbandonedCheckoutsGet.js +46 -0
  28. package/api/shopify/shopifyArticlesGet.js +46 -0
  29. package/api/shopify/shopifyBlogsGet.js +46 -0
  30. package/api/shopify/shopifyBulkOperationsGet.js +46 -0
  31. package/api/shopify/shopifyCatalogsGet.js +46 -0
  32. package/api/shopify/shopifyChannelsGet.js +46 -0
  33. package/api/shopify/shopifyCheckoutAndAccountsConfigurationsGet.js +46 -0
  34. package/api/shopify/shopifyCollectionsGet.js +46 -0
  35. package/api/shopify/shopifyCustomersGet.js +2 -0
  36. package/api/shopify/shopifyDeliveryCustomizationsGet.js +46 -0
  37. package/api/shopify/shopifyDeliveryProfilesGet.js +46 -0
  38. package/api/shopify/shopifyDiscountNodesGet.js +46 -0
  39. package/api/shopify/shopifyDisputesGet.js +46 -0
  40. package/api/shopify/shopifyDraftOrdersGet.js +46 -0
  41. package/api/shopify/shopifyEventsGet.js +46 -0
  42. package/api/shopify/shopifyFilesGet.js +46 -0
  43. package/api/shopify/shopifyFulfillmentOrdersGet.js +46 -0
  44. package/api/shopify/shopifyGiftCardsGet.js +46 -0
  45. package/api/shopify/shopifyInventoryItemsGet.js +46 -0
  46. package/api/shopify/shopifyLocationsGet.js +46 -0
  47. package/api/shopify/shopifyMarketingEventsGet.js +46 -0
  48. package/api/shopify/shopifyMarketsGet.js +46 -0
  49. package/api/shopify/shopifyMenusGet.js +46 -0
  50. package/api/shopify/shopifyMetafieldDefinitionsGet.js +50 -0
  51. package/api/shopify/shopifyMetaobjectDefinitionsGet.js +46 -0
  52. package/api/shopify/shopifyMetaobjectsGet.js +50 -0
  53. package/api/shopify/shopifyPagesGet.js +46 -0
  54. package/api/shopify/shopifyPaymentCustomizationsGet.js +46 -0
  55. package/api/shopify/shopifyProductFeedsGet.js +46 -0
  56. package/api/shopify/shopifyProductVariantsGet.js +46 -0
  57. package/api/shopify/shopifyProductsGet.js +46 -0
  58. package/api/shopify/shopifyPublicationsGet.js +46 -0
  59. package/api/shopify/shopifySegmentsGet.js +46 -0
  60. package/api/shopify/shopifyUrlRedirectsGet.js +46 -0
  61. package/api/shopify/shopifyWebhookSubscriptionsGet.js +46 -0
  62. package/api/utils.js +4 -3
  63. package/api/workspace.js +43 -0
  64. package/cli.js +42 -0
  65. package/hosting.utils.js +232 -0
  66. package/package.json +5 -3
  67. package/server.js +157 -107
  68. package/server.utils.js +40 -0
@@ -0,0 +1,43 @@
1
+ const fs = require('fs');
2
+ const dotenv = require('dotenv');
3
+
4
+ let workspace = process.cwd();
5
+
6
+ // Turn ".", "geode", or "/full/path" into an absolute directory.
7
+ const toAbsolutePath = (dir) => {
8
+ if (!dir || dir === '.') {
9
+ return process.cwd();
10
+ }
11
+
12
+ if (dir.startsWith('/')) {
13
+ return dir;
14
+ }
15
+
16
+ return `${ process.cwd() }/${ dir }`;
17
+ };
18
+
19
+ const getWorkspace = () => workspace;
20
+
21
+ const setWorkspace = (dir) => {
22
+ workspace = toAbsolutePath(dir);
23
+ process.env.MINERAL_WORKSPACE = workspace;
24
+ };
25
+
26
+ const loadWorkspaceEnv = () => {
27
+ const envFile = `${ workspace }/.env`;
28
+ if (!fs.existsSync(envFile)) {
29
+ return;
30
+ }
31
+
32
+ dotenv.config({
33
+ path: envFile,
34
+ override: true,
35
+ });
36
+ };
37
+
38
+ module.exports = {
39
+ getWorkspace,
40
+ setWorkspace,
41
+ loadWorkspaceEnv,
42
+ toAbsolutePath,
43
+ };
package/cli.js ADDED
@@ -0,0 +1,42 @@
1
+ const splitCommaList = (value = '') => value
2
+ .split(',')
3
+ .map((item) => item.trim())
4
+ .filter(Boolean);
5
+
6
+ const readCliFlag = (flag) => {
7
+ const args = process.argv.slice(2);
8
+ const equalsPrefix = `${ flag }=`;
9
+
10
+ for (let index = 0; index < args.length; index++) {
11
+ if (args[index].startsWith(equalsPrefix)) {
12
+ return args[index].slice(equalsPrefix.length);
13
+ }
14
+
15
+ if (args[index] === flag) {
16
+ return args[index + 1];
17
+ }
18
+ }
19
+ };
20
+
21
+ const getApiDirs = (options = {}) => {
22
+ if (options.api_dirs) {
23
+ return options.api_dirs;
24
+ }
25
+
26
+ const fromCli = readCliFlag('--api_dirs');
27
+ if (fromCli) {
28
+ return splitCommaList(fromCli);
29
+ }
30
+
31
+ if (process.env.MINERAL_API_DIRS) {
32
+ return splitCommaList(process.env.MINERAL_API_DIRS);
33
+ }
34
+
35
+ return [];
36
+ };
37
+
38
+ module.exports = {
39
+ splitCommaList,
40
+ readCliFlag,
41
+ getApiDirs,
42
+ };
@@ -0,0 +1,232 @@
1
+ const fs = require('fs');
2
+ const { createRequire } = require('module');
3
+ const yaml = require('yaml');
4
+ const {
5
+ respondJson,
6
+ errorToReadable,
7
+ getRequestBody,
8
+ argsFromBody,
9
+ funcApi,
10
+ wrapFunction,
11
+ requireHostedApiKey,
12
+ statusCodeFromResult,
13
+ } = require('./server.utils');
14
+
15
+ const wrappersByName = {
16
+ requireHostedApiKey,
17
+ };
18
+
19
+ const resolveWrappers = (wrapperNames = []) => (
20
+ wrapperNames.map((wrapperName) => {
21
+ const wrapper = wrappersByName[wrapperName];
22
+ if (!wrapper) {
23
+ throw new Error(`Unknown wrapper: ${ wrapperName }`);
24
+ }
25
+ return wrapper;
26
+ })
27
+ );
28
+
29
+ const getHostedEntries = (functions = {}) => {
30
+ const byEntryPoint = new Map();
31
+
32
+ for (const [functionName, functionConfig] of Object.entries(functions)) {
33
+ const entryPoint = functionConfig.entry_point || functionConfig.entryPoint || functionName;
34
+ const existing = byEntryPoint.get(entryPoint) || {
35
+ entryPoint,
36
+ wrappers: [],
37
+ };
38
+
39
+ if (Array.isArray(functionConfig.wrappers)) {
40
+ existing.wrappers.push(...functionConfig.wrappers);
41
+ existing.wrappers = [...new Set(existing.wrappers)];
42
+ }
43
+
44
+ if (functionConfig.source) {
45
+ existing.source = functionConfig.source;
46
+ }
47
+
48
+ byEntryPoint.set(entryPoint, existing);
49
+ }
50
+
51
+ return [...byEntryPoint.values()];
52
+ };
53
+
54
+ const functionUsesWrapper = (functionConfig = {}, wrapperName) => (
55
+ Array.isArray(functionConfig.wrappers) && functionConfig.wrappers.includes(wrapperName)
56
+ );
57
+
58
+ const getFuncApiConfig = ({
59
+ moduleExports,
60
+ routeName,
61
+ }) => {
62
+ const { funcApiConfig } = moduleExports;
63
+ if (!funcApiConfig || typeof funcApiConfig !== 'object') {
64
+ return undefined;
65
+ }
66
+
67
+ if (funcApiConfig[routeName]) {
68
+ return funcApiConfig[routeName];
69
+ }
70
+
71
+ const exportNames = Object.keys(moduleExports).filter((key) => key !== 'funcApiConfig');
72
+ const configIsShared = !exportNames.some((name) => funcApiConfig[name]);
73
+
74
+ if (configIsShared) {
75
+ return funcApiConfig;
76
+ }
77
+ };
78
+
79
+ const wrapHostedFunction = (loader, exportName, wrapperNames = []) => {
80
+ let handler = null;
81
+ let usesFuncApi = false;
82
+ const wrappers = resolveWrappers(wrapperNames);
83
+
84
+ const coreHandler = async (req, res) => {
85
+ if (!handler) {
86
+ const moduleExports = loader();
87
+ const handlerFn = moduleExports[exportName];
88
+
89
+ if (typeof handlerFn !== 'function') {
90
+ throw new Error(`Hosted export not found: ${ exportName }`);
91
+ }
92
+
93
+ const funcApiConfig = getFuncApiConfig({
94
+ moduleExports,
95
+ routeName: exportName,
96
+ });
97
+
98
+ usesFuncApi = Boolean(funcApiConfig);
99
+ handler = usesFuncApi ? funcApi(handlerFn, funcApiConfig) : handlerFn;
100
+ }
101
+
102
+ const body = await getRequestBody(req);
103
+ const args = argsFromBody(body);
104
+ return usesFuncApi
105
+ ? await handler({ req, res, body, args })
106
+ : await handler(...args);
107
+ };
108
+
109
+ const wrappedHandler = wrapFunction(coreHandler, wrappers);
110
+
111
+ return async (req, res) => {
112
+ try {
113
+ const result = await wrappedHandler(req, res);
114
+
115
+ if (res.headersSent) {
116
+ return;
117
+ }
118
+
119
+ if (result === undefined) {
120
+ respondJson(res, 200, { ok: true });
121
+ return;
122
+ }
123
+
124
+ const statusCode = statusCodeFromResult(result);
125
+ respondJson(res, statusCode, result);
126
+ } catch (error) {
127
+ if (res.headersSent) {
128
+ return;
129
+ }
130
+
131
+ respondJson(res, 500, {
132
+ ok: false,
133
+ error: {
134
+ code: 'UNHANDLED_ERROR',
135
+ message: 'Unhandled server error.',
136
+ details: errorToReadable(error),
137
+ },
138
+ });
139
+ }
140
+ };
141
+ };
142
+
143
+ const readHostingYml = (workspace) => {
144
+ const hostingPath = `${ workspace }/.hosting.yml`;
145
+
146
+ if (!fs.existsSync(hostingPath)) {
147
+ throw new Error(`Missing .hosting.yml in workspace: ${ workspace }`);
148
+ }
149
+
150
+ const hostingText = fs.readFileSync(hostingPath, 'utf8');
151
+ const hostingConfig = yaml.parse(hostingText);
152
+
153
+ if (!hostingConfig || typeof hostingConfig !== 'object' || Array.isArray(hostingConfig)) {
154
+ throw new Error('Invalid .hosting.yml');
155
+ }
156
+
157
+ return hostingConfig;
158
+ };
159
+
160
+ const getCredsJsonForDeploy = (workspace) => {
161
+ const credsPath = `${ workspace }/.creds.yml`;
162
+
163
+ if (!fs.existsSync(credsPath)) {
164
+ throw new Error(`Missing .creds.yml in workspace: ${ workspace }`);
165
+ }
166
+
167
+ const credsText = fs.readFileSync(credsPath, 'utf8');
168
+ return JSON.stringify(yaml.parse(credsText));
169
+ };
170
+
171
+ const getHostedApiKeyForDeploy = (workspace) => {
172
+ const envPath = `${ workspace }/.env`;
173
+
174
+ if (!fs.existsSync(envPath)) {
175
+ return '';
176
+ }
177
+
178
+ const envText = fs.readFileSync(envPath, 'utf8');
179
+ const match = envText.match(/^HOSTED_API_KEY=(.*)$/m);
180
+ return match ? match[1].trim() : '';
181
+ };
182
+
183
+ const resolveHostedHandlersForDeploy = ({
184
+ functions = {},
185
+ workspace,
186
+ handlersByName,
187
+ }) => {
188
+ const workspaceRequire = createRequire(`${ workspace.replace(/\/$/, '') }/package.json`);
189
+ const hostedEntries = getHostedEntries(functions);
190
+
191
+ return hostedEntries.map((hostedEntry) => {
192
+ const { entryPoint, source } = hostedEntry;
193
+
194
+ if (source) {
195
+ workspaceRequire.resolve(source);
196
+ const moduleExports = workspaceRequire(source);
197
+ if (typeof moduleExports[entryPoint] !== 'function') {
198
+ throw new Error(`Hosted export not found: ${ entryPoint } in ${ source }`);
199
+ }
200
+
201
+ return {
202
+ ...hostedEntry,
203
+ requirePath: source,
204
+ };
205
+ }
206
+
207
+ const handler = handlersByName.get(entryPoint);
208
+ if (!handler) {
209
+ throw new Error(
210
+ `entry_point "${ entryPoint }" not found in workspace handlers — add api/ handler or source in .hosting.yml`,
211
+ );
212
+ }
213
+
214
+ return {
215
+ ...hostedEntry,
216
+ requirePath: `./${ handler.filePath.slice(workspace.length + 1) }`,
217
+ };
218
+ });
219
+ };
220
+
221
+ // TODO: support credsPayload in google_cloud_info instead of full workspace .creds.yml
222
+
223
+ module.exports = {
224
+ getFuncApiConfig,
225
+ wrapHostedFunction,
226
+ readHostingYml,
227
+ getCredsJsonForDeploy,
228
+ getHostedApiKeyForDeploy,
229
+ getHostedEntries,
230
+ resolveHostedHandlersForDeploy,
231
+ functionUsesWrapper,
232
+ };
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@foxtware/mineral",
3
- "version": "0.1.0",
4
- "bin": { "mineral": "bin/mineral.js" },
3
+ "version": "0.1.2",
4
+ "bin": {
5
+ "mineral": "bin/mineral.js"
6
+ },
5
7
  "main": "server.js",
6
8
  "publishConfig": {
7
9
  "access": "public"
@@ -12,7 +14,7 @@
12
14
  "new": "node _build_scripts/createNewFunction.js",
13
15
  "serve": "PORT=8100 node server.js",
14
16
  "start": "node server.js",
15
- "npm:publish": "npm publish"
17
+ "npm:publish": "node _build_scripts/publish.js"
16
18
  },
17
19
  "dependencies": {
18
20
  "csvtojson": "^2.0.14",
package/server.js CHANGED
@@ -1,45 +1,64 @@
1
- require('dotenv').config();
2
-
3
1
  const fs = require('fs');
4
- const path = require('path');
5
2
  const http = require('http');
6
- const { respondJson, errorToReadable, getRequestBody, argsFromBody, funcApi } = require('./server.utils');
3
+ const { respondJson, errorToReadable, getRequestBody, argsFromBody, funcApi, statusCodeFromResult } = require('./server.utils');
4
+ const { getWorkspace, setWorkspace, loadWorkspaceEnv, toAbsolutePath } = require('./api/workspace');
5
+ const { getApiDirs, readCliFlag } = require('./cli');
6
+ const { getFuncApiConfig } = require('./hosting.utils');
7
+
8
+ const MINERAL_API_DIR = `${ __dirname }/api`;
9
+
10
+ const getConfig = (options = {}) => ({
11
+ port: Number(options.port ?? process.env.PORT ?? 8000),
12
+ workspace: toAbsolutePath(
13
+ options.workspace
14
+ ?? readCliFlag('--workspace')
15
+ ?? process.env.MINERAL_WORKSPACE
16
+ ?? process.cwd(),
17
+ ),
18
+ api_dirs: getApiDirs(options),
19
+ host_mode: Boolean(options.host_mode),
20
+ });
7
21
 
8
- const apiDirectory = path.join(__dirname, 'api');
22
+ // --- Handler discovery ---
9
23
 
10
- const shouldSkipApiFile = (fileName) => {
24
+ const isHandlerFile = (fileName) => {
11
25
  if (!fileName.endsWith('.js')) {
12
- return true;
26
+ return false;
13
27
  }
14
28
 
15
29
  if (fileName.startsWith('_')) {
16
- return true;
30
+ return false;
17
31
  }
18
32
 
19
33
  if (fileName.endsWith('.utils.js')) {
20
- return true;
34
+ return false;
21
35
  }
22
36
 
23
- const skippedFiles = new Set([
24
- 'utils.js',
25
- 'validators.js',
26
- ]);
37
+ return true;
38
+ };
27
39
 
28
- return skippedFiles.has(fileName);
40
+ const routeNameFromFile = (filePath) => {
41
+ const fileName = filePath.split('/').pop();
42
+ return fileName.slice(0, -3);
29
43
  };
30
44
 
31
- const walkFilesRecursive = (directory) => {
32
- const entries = fs.readdirSync(directory, { withFileTypes: true });
45
+ const listJsFiles = (directory) => {
46
+ if (!fs.existsSync(directory)) {
47
+ return [];
48
+ }
49
+
33
50
  const files = [];
51
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
34
52
 
35
53
  for (const entry of entries) {
36
- const entryPath = path.join(directory, entry.name);
54
+ const entryPath = `${ directory }/${ entry.name }`;
55
+
37
56
  if (entry.isDirectory()) {
38
- files.push(...walkFilesRecursive(entryPath));
57
+ files.push(...listJsFiles(entryPath));
39
58
  continue;
40
59
  }
41
60
 
42
- if (entry.isFile()) {
61
+ if (entry.isFile() && isHandlerFile(entry.name)) {
43
62
  files.push(entryPath);
44
63
  }
45
64
  }
@@ -47,92 +66,91 @@ const walkFilesRecursive = (directory) => {
47
66
  return files;
48
67
  };
49
68
 
50
- const loadHandlers = () => {
51
- const files = walkFilesRecursive(apiDirectory).filter((filePath) => !shouldSkipApiFile(path.basename(filePath)));
52
- const routeToHandler = new Map();
69
+ const directoriesToScan = ({
70
+ workspace,
71
+ api_dirs,
72
+ host_mode = false,
73
+ }) => {
74
+ const extraDirs = api_dirs.map((dir) => (
75
+ dir.startsWith('/')
76
+ ? dir
77
+ : `${ workspace }/${ dir }`
78
+ ));
79
+
80
+ if (host_mode && api_dirs.length) {
81
+ return extraDirs;
82
+ }
53
83
 
54
- for (const filePath of files) {
55
- const moduleExports = require(filePath);
56
- if (!moduleExports || typeof moduleExports !== 'object') {
57
- continue;
58
- }
84
+ return [MINERAL_API_DIR, ...extraDirs];
85
+ };
59
86
 
60
- const fileBaseName = path.basename(filePath, '.js');
61
- const exportedValue = moduleExports[fileBaseName];
87
+ const getFuncApiConfigFromModule = getFuncApiConfig;
62
88
 
63
- if (typeof exportedValue !== 'function') {
64
- continue;
65
- }
89
+ const addHandlerFromFile = (filePath, handlers) => {
90
+ const moduleExports = require(filePath);
91
+ if (!moduleExports || typeof moduleExports !== 'object') {
92
+ return;
93
+ }
66
94
 
67
- const {
68
- funcApiConfig,
69
- } = moduleExports;
70
- const functionExportNames = Object.entries(moduleExports)
71
- .filter(([exportName, exportValue]) => exportName !== 'funcApiConfig' && typeof exportValue === 'function')
72
- .map(([exportName]) => exportName);
73
-
74
- const getFuncApiConfigForExport = (exportName) => {
75
- if (!funcApiConfig || typeof funcApiConfig !== 'object') {
76
- return undefined;
77
- }
78
-
79
- const configByExportName = funcApiConfig[exportName];
80
- if (configByExportName && typeof configByExportName === 'object' && !Array.isArray(configByExportName)) {
81
- return configByExportName;
82
- }
83
-
84
- // If config isn't keyed by function names and this export matches the filename,
85
- // treat funcApiConfig as the config for that function.
86
- const configIsKeyedByFunctionName = functionExportNames.some((functionExportName) => funcApiConfig[functionExportName] !== undefined);
87
- if (!configIsKeyedByFunctionName && exportName === fileBaseName) {
88
- return funcApiConfig;
89
- }
90
- };
91
-
92
- const exportFuncApiConfig = getFuncApiConfigForExport(fileBaseName);
93
- const handler = exportFuncApiConfig
94
- ? funcApi(exportedValue, exportFuncApiConfig)
95
- : exportedValue;
96
-
97
- const routePath = `/${ fileBaseName }`;
98
- if (routeToHandler.has(routePath)) {
99
- const existing = routeToHandler.get(routePath);
100
- throw new Error(`Duplicate handler route '${ routePath }' from ${ filePath } and ${ existing.filePath }`);
101
- }
95
+ const routeName = routeNameFromFile(filePath);
96
+ const handlerFn = moduleExports[routeName];
97
+ if (typeof handlerFn !== 'function') {
98
+ return;
99
+ }
102
100
 
103
- routeToHandler.set(routePath, {
104
- filePath,
105
- exportName: fileBaseName,
106
- handler,
107
- usesFuncApi: Boolean(exportFuncApiConfig),
108
- });
101
+ const funcApiConfig = getFuncApiConfigFromModule({
102
+ moduleExports,
103
+ routeName,
104
+ });
105
+
106
+ const route = `/${ routeName }`;
107
+ if (handlers.has(route)) {
108
+ const existing = handlers.get(route);
109
+ throw new Error(`Duplicate route '${ route }' from ${ filePath } and ${ existing.filePath }`);
110
+ }
111
+
112
+ handlers.set(route, {
113
+ filePath,
114
+ routeName,
115
+ handler: funcApiConfig ? funcApi(handlerFn, funcApiConfig) : handlerFn,
116
+ usesFuncApi: Boolean(funcApiConfig),
117
+ });
118
+ };
119
+
120
+ const loadHandlers = (config) => {
121
+ const handlers = new Map();
122
+
123
+ for (const directory of directoriesToScan(config)) {
124
+ for (const filePath of listJsFiles(directory)) {
125
+ addHandlerFromFile(filePath, handlers);
126
+ }
109
127
  }
110
128
 
111
- return routeToHandler;
129
+ return handlers;
112
130
  };
113
131
 
114
- const handlers = loadHandlers();
132
+ // --- HTTP ---
115
133
 
116
- const server = http.createServer(async (req, res) => {
134
+ const createServer = (handlers) => http.createServer(async (req, res) => {
117
135
  if (req.method === 'GET' && req.url === '/') {
118
- const routes = Array.from(handlers.keys()).sort();
119
136
  respondJson(res, 200, {
120
137
  ok: true,
121
138
  data: {
122
- routes,
139
+ routes: [...handlers.keys()].sort(),
140
+ workspace: getWorkspace(),
123
141
  },
124
142
  });
125
143
  return;
126
144
  }
127
145
 
128
- const pathOnly = (req.url || '/').split('?')[0];
129
- const matchedHandler = handlers.get(pathOnly);
130
- if (!matchedHandler) {
146
+ const route = (req.url || '/').split('?')[0];
147
+ const handler = handlers.get(route);
148
+ if (!handler) {
131
149
  respondJson(res, 404, {
132
150
  ok: false,
133
151
  error: {
134
152
  code: 'NOT_FOUND',
135
- message: `No handler found for route ${ pathOnly }`,
153
+ message: `No handler found for route ${ route }`,
136
154
  },
137
155
  });
138
156
  return;
@@ -141,23 +159,16 @@ const server = http.createServer(async (req, res) => {
141
159
  try {
142
160
  const body = await getRequestBody(req);
143
161
  const args = argsFromBody(body);
144
- const result = matchedHandler.usesFuncApi
145
- ? await matchedHandler.handler({
146
- req,
147
- res,
148
- body,
149
- args,
150
- })
151
- : await matchedHandler.handler(...args);
162
+ const result = handler.usesFuncApi
163
+ ? await handler.handler({ req, res, body, args })
164
+ : await handler.handler(...args);
152
165
 
153
166
  if (result === undefined) {
154
- respondJson(res, 200, {
155
- ok: true,
156
- });
167
+ respondJson(res, 200, { ok: true });
157
168
  return;
158
169
  }
159
170
 
160
- respondJson(res, 200, result);
171
+ respondJson(res, statusCodeFromResult(result), result);
161
172
  } catch (error) {
162
173
  respondJson(res, 500, {
163
174
  ok: false,
@@ -170,25 +181,64 @@ const server = http.createServer(async (req, res) => {
170
181
  }
171
182
  });
172
183
 
173
- const startServer = ({
174
- port = process.env.PORT || 8000,
175
- } = {}) => {
176
- server.listen(port, () => {
177
- console.log(`Mineral server running on port ${ port }`);
178
- console.log('Registered routes:');
179
- for (const route of Array.from(handlers.keys()).sort()) {
180
- console.log(route);
181
- }
182
- });
184
+ // --- Start ---
185
+
186
+ let server = null;
187
+ let handlers = null;
188
+
189
+ const logStartup = ({
190
+ port,
191
+ workspace,
192
+ api_dirs,
193
+ handlers: routeHandlers,
194
+ }) => {
195
+ console.log(`Mineral server running on port ${ port }`);
196
+ console.log(`Workspace: ${ workspace }`);
197
+
198
+ if (api_dirs.length) {
199
+ console.log('Extra API dirs:', api_dirs.join(', '));
200
+ }
201
+
202
+ console.log('Registered routes:');
203
+ for (const route of [...routeHandlers.keys()].sort()) {
204
+ console.log(route);
205
+ }
206
+ };
207
+
208
+ const startServer = (options = {}) => {
209
+ const config = getConfig(options);
210
+
211
+ setWorkspace(config.workspace);
212
+ loadWorkspaceEnv();
213
+
214
+ handlers = loadHandlers(config);
215
+ server = createServer(handlers);
216
+
217
+ server.listen(config.port, () => logStartup({
218
+ ...config,
219
+ handlers,
220
+ }));
183
221
 
184
222
  return server;
185
223
  };
186
224
 
187
225
  module.exports = {
188
- server,
189
- handlers,
190
- loadHandlers,
191
226
  startServer,
227
+ loadHandlers,
228
+ get server() {
229
+ if (!server) {
230
+ startServer();
231
+ }
232
+
233
+ return server;
234
+ },
235
+ get handlers() {
236
+ if (!handlers) {
237
+ startServer();
238
+ }
239
+
240
+ return handlers;
241
+ },
192
242
  };
193
243
 
194
244
  if (require.main === module) {