@foxtware/mineral 0.1.3 → 0.1.5

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.
@@ -4,16 +4,19 @@ google_cloud_info:
4
4
 
5
5
  # TODO: consider credsPayload in google_cloud_info instead of workspace .creds.yml
6
6
 
7
+ env:
8
+ - HOSTED_API_KEY
9
+
7
10
  functions:
8
11
  exampleFunction:
9
12
  max_instances: 1
10
13
  timeout: 300s
11
14
  wrappers:
12
- - requireHostedApiKey
13
15
  # entry_point: otherExportName
16
+ - requireHostedApiKey
17
+ - allowCrossOriginCallsAndHandleOptions
14
18
 
15
- packagedFunction:
16
- source: '@foxtware/mineral/api/pokemon/pokemonPokeballThrow.js'
19
+ pokemonPokeballThrow:
17
20
  max_instances: 1
18
21
  wrappers:
19
22
  - checkTrainer
@@ -2,7 +2,13 @@ const readline = require('readline');
2
2
  const { toAbsolutePath, setWorkspace } = require('../api/workspace');
3
3
  const { getApiDirs, readCliFlag } = require('../cli');
4
4
  const { loadHandlers } = require('../server');
5
- const { readHostingYml, getCredsJsonForDeploy, getHostedApiKeyForDeploy, resolveHostedHandlersForDeploy, functionUsesWrapper } = require('../hosting.utils');
5
+ const {
6
+ readHostingYml,
7
+ getCredsJsonForDeploy,
8
+ validateEnvVarsForDeploy,
9
+ resolveHostedHandlersForDeploy,
10
+ functionUsesWrapper,
11
+ } = require('../hosting.utils');
6
12
  const { writeHostedJs } = require('./generateHosted');
7
13
  const { execCommand } = require('./execCommand');
8
14
  const { formatSetEnvVarsForGcloud, shellQuoteSingle } = require('./setEnvVarsGcloud');
@@ -44,10 +50,6 @@ const chooseOption = async (prompt, options) => new Promise((resolve) => {
44
50
  });
45
51
  });
46
52
 
47
- const anyHostedEntryUsesWrapper = (hostedEntries, wrapperName) => (
48
- hostedEntries.some((hostedEntry) => hostedEntry.wrappers?.includes(wrapperName))
49
- );
50
-
51
53
  const getDeployArgs = () => {
52
54
  const args = process.argv.slice(2);
53
55
  const deployArgs = [];
@@ -55,6 +57,10 @@ const getDeployArgs = () => {
55
57
  for (let index = 0; index < args.length; index++) {
56
58
  const arg = args[index];
57
59
 
60
+ if (arg === 'host') {
61
+ continue;
62
+ }
63
+
58
64
  if (arg === '--workspace' || arg === '--api_dirs') {
59
65
  index++;
60
66
  continue;
@@ -86,7 +92,7 @@ const deployFunction = async ({
86
92
  googleCloudInfo,
87
93
  workspace,
88
94
  credsJson,
89
- hostedApiKey,
95
+ deployEnvVars = {},
90
96
  }) => {
91
97
  const config = {
92
98
  ...googleCloudInfo,
@@ -113,12 +119,9 @@ const deployFunction = async ({
113
119
  const envParts = [
114
120
  'HOSTED=true',
115
121
  `CREDS=${ credsJson }`,
122
+ ...Object.entries(deployEnvVars).map(([envName, envValue]) => `${ envName }=${ envValue }`),
116
123
  ];
117
124
 
118
- if (hostedApiKey) {
119
- envParts.push(`HOSTED_API_KEY=${ hostedApiKey }`);
120
- }
121
-
122
125
  if (extraSetEnvVars) {
123
126
  envParts.push(extraSetEnvVars);
124
127
  }
@@ -154,6 +157,7 @@ const deployFunction = async ({
154
157
  }
155
158
 
156
159
  const requiresHostedApiKey = functionUsesWrapper(functionConfig, 'requireHostedApiKey');
160
+ const hostedApiKey = deployEnvVars.HOSTED_API_KEY;
157
161
 
158
162
  for (const schedule of schedules) {
159
163
  const {
@@ -203,6 +207,7 @@ const deployFromHostingYml = async (options = {}) => {
203
207
  const hostingConfig = readHostingYml(config.workspace);
204
208
  const {
205
209
  google_cloud_info: googleCloudInfo,
210
+ env: envNames = [],
206
211
  functions = {},
207
212
  groups = {},
208
213
  } = hostingConfig;
@@ -222,12 +227,7 @@ const deployFromHostingYml = async (options = {}) => {
222
227
  handlersByName,
223
228
  });
224
229
 
225
- if (anyHostedEntryUsesWrapper(hostedHandlers, 'requireHostedApiKey')) {
226
- const hostedApiKey = getHostedApiKeyForDeploy(config.workspace);
227
- if (!hostedApiKey) {
228
- throw new Error('HOSTED_API_KEY is required in workspace .env when using requireHostedApiKey wrapper');
229
- }
230
- }
230
+ const deployEnvVars = validateEnvVarsForDeploy(config.workspace, envNames);
231
231
 
232
232
  writeHostedJs({
233
233
  workspace: config.workspace,
@@ -235,7 +235,6 @@ const deployFromHostingYml = async (options = {}) => {
235
235
  });
236
236
 
237
237
  const credsJson = getCredsJsonForDeploy(config.workspace);
238
- const hostedApiKey = getHostedApiKeyForDeploy(config.workspace);
239
238
  const deployArgs = getDeployArgs();
240
239
 
241
240
  const deployOne = async (functionName) => {
@@ -250,7 +249,7 @@ const deployFromHostingYml = async (options = {}) => {
250
249
  googleCloudInfo,
251
250
  workspace: config.workspace,
252
251
  credsJson,
253
- hostedApiKey,
252
+ deployEnvVars,
254
253
  });
255
254
  };
256
255
 
@@ -289,9 +288,9 @@ const deployFromHostingYml = async (options = {}) => {
289
288
 
290
289
  console.log(`
291
290
  Usage (from workspace repo):
292
- npm run host --workspace . --api_dirs api all
293
- npm run host --workspace . --api_dirs api function
294
- npm run host --workspace . --api_dirs api group
291
+ mineral host --workspace . --api_dirs api all
292
+ mineral host --workspace . --api_dirs api function
293
+ mineral host --workspace . --api_dirs api group
295
294
  `);
296
295
  };
297
296
 
@@ -309,11 +308,11 @@ module.exports = {
309
308
 
310
309
  /*
311
310
  Deploy everything:
312
- npm run host all
311
+ mineral host all
313
312
 
314
313
  Deploy a single function:
315
- npm run host function
314
+ mineral host function
316
315
 
317
316
  Deploy a group of functions:
318
- npm run host group
317
+ mineral host group
319
318
  */
@@ -1,23 +1,37 @@
1
1
  const fs = require('fs');
2
2
  const { wrapHostedFunction } = require('../hosting.utils');
3
3
 
4
+ const formatResolvedWrapperForHostedJs = ({ modulePath, wrapperName }) => (
5
+ `require('${ modulePath }').${ wrapperName }`
6
+ );
7
+
8
+ const formatWrappersArg = (resolvedWrappers = []) => {
9
+ if (!resolvedWrappers.length) {
10
+ return '';
11
+ }
12
+
13
+ const wrapperLines = resolvedWrappers.map((resolvedWrapper) => (
14
+ ` ${ formatResolvedWrapperForHostedJs(resolvedWrapper) },`
15
+ ));
16
+ return `, [\n${ wrapperLines.join('\n') }\n ]`;
17
+ };
18
+
4
19
  const generateHostedJs = ({
5
20
  hostedHandlers,
6
21
  }) => {
7
22
  const exportLines = [];
8
- const hostingUtilsRequire = '@foxtware/mineral/hosting.utils';
9
23
 
10
24
  for (const hostedHandler of hostedHandlers) {
11
- const { entryPoint, wrappers = [], requirePath } = hostedHandler;
12
- const wrappersArg = wrappers.length ? `, ${ JSON.stringify(wrappers) }` : '';
25
+ const { hostedName, handlerName, resolvedWrappers = [], requirePath } = hostedHandler;
26
+ const wrappersArg = formatWrappersArg(resolvedWrappers);
13
27
 
14
28
  exportLines.push(
15
- ` ${ entryPoint }: wrapHostedFunction(() => require('${ requirePath }'), '${ entryPoint }'${ wrappersArg }),`,
29
+ ` ${ hostedName }: wrapHostedFunction(() => require('${ requirePath }'), '${ handlerName }'${ wrappersArg }),`,
16
30
  );
17
31
  }
18
32
 
19
33
  return `// Generated by mineral — do not edit
20
- const { wrapHostedFunction } = require('${ hostingUtilsRequire }');
34
+ const { wrapHostedFunction } = require('@foxtware/mineral/hosting.utils');
21
35
 
22
36
  module.exports = {
23
37
  ${ exportLines.join('\n') }
package/bin/mineral.js CHANGED
@@ -1,5 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const command = process.argv[2];
4
+
5
+ if (command === 'host') {
6
+ const { deployFromHostingYml } = require('../_deploy_scripts/deployFromHostingYml');
7
+ deployFromHostingYml().catch((error) => {
8
+ console.error(error);
9
+ process.exit(1);
10
+ });
11
+ return;
12
+ }
13
+
3
14
  const { startServer } = require('../server');
4
15
 
5
16
  startServer();
@@ -0,0 +1,23 @@
1
+ const MINERAL_ROOT = __dirname;
2
+ const MINERAL_API_DIR = `${ MINERAL_ROOT }/api`;
3
+
4
+ const getRequirePathForHandler = (handler, workspace) => {
5
+ const normalizedWorkspace = workspace.replace(/\/$/, '');
6
+ const { filePath } = handler;
7
+
8
+ if (filePath.startsWith(`${ normalizedWorkspace }/`)) {
9
+ return `./${ filePath.slice(normalizedWorkspace.length + 1) }`;
10
+ }
11
+
12
+ if (filePath.startsWith(`${ MINERAL_API_DIR }/`)) {
13
+ const relativePath = filePath.slice(MINERAL_API_DIR.length + 1);
14
+ return `@foxtware/mineral/api/${ relativePath }`;
15
+ }
16
+
17
+ throw new Error(`Handler "${ handler.routeName }" is not in workspace or mineral api dirs`);
18
+ };
19
+
20
+ module.exports = {
21
+ MINERAL_API_DIR,
22
+ getRequirePathForHandler,
23
+ };
package/hosting.utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const { createRequire } = require('module');
3
3
  const yaml = require('yaml');
4
+ const { getRequirePathForHandler } = require('./handlerPaths');
4
5
  const {
5
6
  respondJson,
6
7
  errorToReadable,
@@ -8,49 +9,64 @@ const {
8
9
  argsFromBody,
9
10
  funcApi,
10
11
  wrapFunction,
11
- requireHostedApiKey,
12
12
  statusCodeFromResult,
13
13
  } = require('./server.utils');
14
14
 
15
- const wrappersByName = {
16
- requireHostedApiKey,
17
- };
15
+ const MINERAL_WRAPPERS_MODULE = '@foxtware/mineral/wrappers.js';
16
+ const WORKSPACE_WRAPPERS_MODULE = './wrappers.js';
18
17
 
19
- const resolveWrappers = (wrapperNames = []) => (
20
- wrapperNames.map((wrapperName) => {
21
- const wrapper = wrappersByName[wrapperName];
22
- if (!wrapper) {
23
- throw new Error(`Unknown wrapper: ${ wrapperName }`);
18
+ const getMineralWrappers = (workspaceRequire) => {
19
+ try {
20
+ return workspaceRequire(MINERAL_WRAPPERS_MODULE);
21
+ } catch (error) {
22
+ if (error.code !== 'MODULE_NOT_FOUND') {
23
+ throw error;
24
24
  }
25
- return wrapper;
26
- })
27
- );
28
25
 
29
- const getHostedEntries = (functions = {}) => {
30
- const byEntryPoint = new Map();
26
+ return require('./wrappers.js');
27
+ }
28
+ };
31
29
 
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: [],
30
+ const resolveWrapperName = (wrapperName, workspaceRequire) => {
31
+ if (typeof wrapperName !== 'string' || !wrapperName.trim()) {
32
+ throw new Error(`Invalid wrapper name: ${ wrapperName }`);
33
+ }
34
+
35
+ const mineralWrappers = getMineralWrappers(workspaceRequire);
36
+ if (typeof mineralWrappers[wrapperName] === 'function') {
37
+ return {
38
+ wrapperName,
39
+ modulePath: MINERAL_WRAPPERS_MODULE,
37
40
  };
41
+ }
38
42
 
39
- if (Array.isArray(functionConfig.wrappers)) {
40
- existing.wrappers.push(...functionConfig.wrappers);
41
- existing.wrappers = [...new Set(existing.wrappers)];
43
+ try {
44
+ const workspaceWrappers = workspaceRequire(WORKSPACE_WRAPPERS_MODULE);
45
+ if (typeof workspaceWrappers[wrapperName] === 'function') {
46
+ return {
47
+ wrapperName,
48
+ modulePath: WORKSPACE_WRAPPERS_MODULE,
49
+ };
42
50
  }
43
-
44
- if (functionConfig.source) {
45
- existing.source = functionConfig.source;
51
+ } catch (error) {
52
+ if (error.code !== 'MODULE_NOT_FOUND') {
53
+ throw error;
46
54
  }
47
-
48
- byEntryPoint.set(entryPoint, existing);
49
55
  }
50
56
 
51
- return [...byEntryPoint.values()];
57
+ throw new Error(
58
+ `Wrapper "${ wrapperName }" not found in ${ MINERAL_WRAPPERS_MODULE } or ${ WORKSPACE_WRAPPERS_MODULE }`,
59
+ );
52
60
  };
53
61
 
62
+ const getHostedEntries = (functions = {}) => (
63
+ Object.entries(functions).map(([hostedName, functionConfig = {}]) => ({
64
+ hostedName,
65
+ handlerName: functionConfig.entry_point || functionConfig.entryPoint || hostedName,
66
+ wrappers: Array.isArray(functionConfig.wrappers) ? functionConfig.wrappers : [],
67
+ }))
68
+ );
69
+
54
70
  const functionUsesWrapper = (functionConfig = {}, wrapperName) => (
55
71
  Array.isArray(functionConfig.wrappers) && functionConfig.wrappers.includes(wrapperName)
56
72
  );
@@ -76,10 +92,9 @@ const getFuncApiConfig = ({
76
92
  }
77
93
  };
78
94
 
79
- const wrapHostedFunction = (loader, exportName, wrapperNames = []) => {
95
+ const wrapHostedFunction = (loader, exportName, wrappers = []) => {
80
96
  let handler = null;
81
97
  let usesFuncApi = false;
82
- const wrappers = resolveWrappers(wrapperNames);
83
98
 
84
99
  const coreHandler = async (req, res) => {
85
100
  if (!handler) {
@@ -168,7 +183,7 @@ const getCredsJsonForDeploy = (workspace) => {
168
183
  return JSON.stringify(yaml.parse(credsText));
169
184
  };
170
185
 
171
- const getHostedApiKeyForDeploy = (workspace) => {
186
+ const getEnvValueForDeploy = (workspace, envName) => {
172
187
  const envPath = `${ workspace }/.env`;
173
188
 
174
189
  if (!fs.existsSync(envPath)) {
@@ -176,10 +191,27 @@ const getHostedApiKeyForDeploy = (workspace) => {
176
191
  }
177
192
 
178
193
  const envText = fs.readFileSync(envPath, 'utf8');
179
- const match = envText.match(/^HOSTED_API_KEY=(.*)$/m);
194
+ const match = envText.match(new RegExp(`^${ envName }=(.*)$`, 'm'));
180
195
  return match ? match[1].trim() : '';
181
196
  };
182
197
 
198
+ const getEnvVarsForDeploy = (workspace, envNames = []) => (
199
+ Object.fromEntries(
200
+ envNames.map((envName) => [envName, getEnvValueForDeploy(workspace, envName)]),
201
+ )
202
+ );
203
+
204
+ const validateEnvVarsForDeploy = (workspace, envNames = []) => {
205
+ const envVars = getEnvVarsForDeploy(workspace, envNames);
206
+ const missing = envNames.filter((envName) => !envVars[envName]);
207
+
208
+ if (missing.length) {
209
+ throw new Error(`Missing required .env values: ${ missing.join(', ') }`);
210
+ }
211
+
212
+ return envVars;
213
+ };
214
+
183
215
  const resolveHostedHandlersForDeploy = ({
184
216
  functions = {},
185
217
  workspace,
@@ -189,31 +221,23 @@ const resolveHostedHandlersForDeploy = ({
189
221
  const hostedEntries = getHostedEntries(functions);
190
222
 
191
223
  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
- }
224
+ const { handlerName, wrappers = [] } = hostedEntry;
200
225
 
201
- return {
202
- ...hostedEntry,
203
- requirePath: source,
204
- };
205
- }
226
+ const resolvedWrappers = wrappers.map((wrapperName) => (
227
+ resolveWrapperName(wrapperName, workspaceRequire)
228
+ ));
206
229
 
207
- const handler = handlersByName.get(entryPoint);
230
+ const handler = handlersByName.get(handlerName);
208
231
  if (!handler) {
209
232
  throw new Error(
210
- `entry_point "${ entryPoint }" not found in workspace handlers add api/ handler or source in .hosting.yml`,
233
+ `Function "${ handlerName }" not found add a handler in workspace api dirs or mineral api`,
211
234
  );
212
235
  }
213
236
 
214
237
  return {
215
238
  ...hostedEntry,
216
- requirePath: `./${ handler.filePath.slice(workspace.length + 1) }`,
239
+ resolvedWrappers,
240
+ requirePath: getRequirePathForHandler(handler, workspace),
217
241
  };
218
242
  });
219
243
  };
@@ -221,12 +245,16 @@ const resolveHostedHandlersForDeploy = ({
221
245
  // TODO: support credsPayload in google_cloud_info instead of full workspace .creds.yml
222
246
 
223
247
  module.exports = {
248
+ resolveWrapperName,
224
249
  getFuncApiConfig,
225
250
  wrapHostedFunction,
226
251
  readHostingYml,
227
252
  getCredsJsonForDeploy,
228
- getHostedApiKeyForDeploy,
253
+ getEnvVarsForDeploy,
254
+ validateEnvVarsForDeploy,
229
255
  getHostedEntries,
230
256
  resolveHostedHandlersForDeploy,
231
257
  functionUsesWrapper,
258
+ MINERAL_WRAPPERS_MODULE,
259
+ WORKSPACE_WRAPPERS_MODULE,
232
260
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxtware/mineral",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "bin": {
5
5
  "mineral": "bin/mineral.js"
6
6
  },
package/server.js CHANGED
@@ -4,8 +4,7 @@ const { respondJson, errorToReadable, getRequestBody, argsFromBody, funcApi, sta
4
4
  const { getWorkspace, setWorkspace, loadWorkspaceEnv, toAbsolutePath } = require('./api/workspace');
5
5
  const { getApiDirs, readCliFlag } = require('./cli');
6
6
  const { getFuncApiConfig } = require('./hosting.utils');
7
-
8
- const MINERAL_API_DIR = `${ __dirname }/api`;
7
+ const { MINERAL_API_DIR, getRequirePathForHandler } = require('./handlerPaths');
9
8
 
10
9
  const getConfig = (options = {}) => ({
11
10
  port: Number(options.port ?? process.env.PORT ?? 8000),
@@ -69,7 +68,6 @@ const listJsFiles = (directory) => {
69
68
  const directoriesToScan = ({
70
69
  workspace,
71
70
  api_dirs,
72
- host_mode = false,
73
71
  }) => {
74
72
  const extraDirs = api_dirs.map((dir) => (
75
73
  dir.startsWith('/')
@@ -77,10 +75,6 @@ const directoriesToScan = ({
77
75
  : `${ workspace }/${ dir }`
78
76
  ));
79
77
 
80
- if (host_mode && api_dirs.length) {
81
- return extraDirs;
82
- }
83
-
84
78
  return [MINERAL_API_DIR, ...extraDirs];
85
79
  };
86
80
 
@@ -225,6 +219,8 @@ const startServer = (options = {}) => {
225
219
  module.exports = {
226
220
  startServer,
227
221
  loadHandlers,
222
+ getRequirePathForHandler,
223
+ MINERAL_API_DIR,
228
224
  get server() {
229
225
  if (!server) {
230
226
  startServer();
package/server.utils.js CHANGED
@@ -1,6 +1,5 @@
1
1
  const { logDeep } = require('./api/utils');
2
2
  const { StringDecoder } = require('string_decoder');
3
- const { HOSTED } = require('./api/constants');
4
3
 
5
4
  const respondJson = (res, statusCode, payload) => {
6
5
  logDeep(payload);
@@ -132,23 +131,6 @@ const wrapFunction = (func, wrappers = []) => async (req, res, ...rest) => {
132
131
  return func(req, res, ...rest);
133
132
  };
134
133
 
135
- const requireHostedApiKey = async (req) => {
136
- if (!HOSTED) {
137
- return;
138
- }
139
-
140
- if (req.headers['x-api-key'] !== process.env.HOSTED_API_KEY) {
141
- return {
142
- ok: false,
143
- error: {
144
- code: 'UNAUTHORIZED',
145
- message: 'Unauthorized',
146
- statusCode: 401,
147
- },
148
- };
149
- }
150
- };
151
-
152
134
  const statusCodeFromResult = (result) => {
153
135
  if (result?.ok === false) {
154
136
  return result?.error?.statusCode ?? 400;
@@ -266,7 +248,6 @@ module.exports = {
266
248
  getRequestBody,
267
249
  argsFromBody,
268
250
  wrapFunction,
269
- requireHostedApiKey,
270
251
  statusCodeFromResult,
271
252
  funcApi,
272
253
  };
package/wrappers.js ADDED
@@ -0,0 +1,37 @@
1
+ const { HOSTED } = require('./api/constants');
2
+
3
+ const requireHostedApiKey = async (req) => {
4
+ if (!HOSTED) {
5
+ return;
6
+ }
7
+
8
+ if (req.headers['x-api-key'] !== process.env.HOSTED_API_KEY) {
9
+ return {
10
+ ok: false,
11
+ error: {
12
+ code: 'UNAUTHORIZED',
13
+ message: 'Unauthorized',
14
+ statusCode: 401,
15
+ },
16
+ };
17
+ }
18
+ };
19
+
20
+ const allowCrossOriginCallsAndHandleOptions = async (req, res) => {
21
+ const { origin } = req.headers;
22
+
23
+ res.setHeader('Access-Control-Allow-Origin', origin || '*');
24
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
25
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key, x-wf-token, x-wf-value');
26
+
27
+ if (req.method === 'OPTIONS') {
28
+ res.writeHead(204);
29
+ res.end();
30
+ return { handled: true };
31
+ }
32
+ };
33
+
34
+ module.exports = {
35
+ requireHostedApiKey,
36
+ allowCrossOriginCallsAndHandleOptions,
37
+ };