@foxtware/mineral 0.1.1 → 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.
package/.gcloudignore ADDED
@@ -0,0 +1,17 @@
1
+ # This file specifies files that are *not* uploaded to Google Cloud
2
+ # using gcloud. It follows the same syntax as .gitignore, with the addition of
3
+ # "#!include" directives (which insert the entries of the given .gitignore-style
4
+ # file at that point).
5
+ #
6
+ # For more information, run:
7
+ # $ gcloud topic gcloudignore
8
+ #
9
+ .gcloudignore
10
+ # If you would like to upload your .git directory, .gitignore file or files
11
+ # from your .gitignore file, remove the corresponding line
12
+ # below:
13
+ .git
14
+ .gitignore
15
+
16
+ node_modules
17
+ #!include:.gitignore
@@ -0,0 +1,23 @@
1
+ google_cloud_info:
2
+ project:
3
+ region:
4
+
5
+ # TODO: consider credsPayload in google_cloud_info instead of workspace .creds.yml
6
+
7
+ functions:
8
+ exampleFunction:
9
+ max_instances: 1
10
+ timeout: 300s
11
+ wrappers:
12
+ - requireHostedApiKey
13
+ # entry_point: otherExportName
14
+
15
+ packagedFunction:
16
+ source: '@foxtware/mineral/api/pokemon/pokemonPokeballThrow.js'
17
+ max_instances: 1
18
+ wrappers:
19
+ - checkTrainer
20
+
21
+ groups:
22
+ example_group:
23
+ - exampleFunction
package/README.md CHANGED
@@ -50,3 +50,5 @@ The [bedrock](https://github.com/GorgonFreeman/bedrock) middleware, refactored f
50
50
  ```
51
51
  - **Monorepo structure**
52
52
  Mineral gets pushed to from a larger repo that can also contain private functions. Mineral should be strictly useful stuff for the public, and can be used standalone, but needs to be instantiated for serving, setting stuff like which creds file to use. This allows it to be used as part of another repo in the same HTTP/curl way as by itself. Pass `--workspace` to locate `.creds.yml` and `--api_dirs` to serve additional function directories.
53
+
54
+ For cloud deploy, workspaces use `.hosting.yml` and `npm run host` (same `--workspace` / `--api_dirs` flags as dev/serve). See `.hosting.yml.sample`.
@@ -12,8 +12,25 @@ const writePackageJson = (packageJson) => {
12
12
  fs.writeFileSync(packageJsonPath, `${ JSON.stringify(packageJson, null, 2) }\n`);
13
13
  };
14
14
 
15
+ const parseVersion = (version) => version.split('.').map((part) => Number(part) || 0);
16
+
17
+ const isVersionGreater = (left, right) => {
18
+ const [leftMajor, leftMinor, leftPatch] = parseVersion(left);
19
+ const [rightMajor, rightMinor, rightPatch] = parseVersion(right);
20
+
21
+ if (leftMajor !== rightMajor) {
22
+ return leftMajor > rightMajor;
23
+ }
24
+
25
+ if (leftMinor !== rightMinor) {
26
+ return leftMinor > rightMinor;
27
+ }
28
+
29
+ return leftPatch > rightPatch;
30
+ };
31
+
15
32
  const bumpPatch = (version) => {
16
- const [major, minor, patch] = version.split('.').map((part) => Number(part) || 0);
33
+ const [major, minor, patch] = parseVersion(version);
17
34
  return [major, minor, patch + 1].join('.');
18
35
  };
19
36
 
@@ -63,14 +80,17 @@ const publish = async () => {
63
80
 
64
81
  let version = localVersion;
65
82
 
66
- if (localVersion === publishedVersion) {
67
- const suggestedVersion = bumpPatch(localVersion);
68
- version = await askWithDefault('What version should we use?', suggestedVersion);
83
+ const localIsAhead = publishedVersion && isVersionGreater(localVersion, publishedVersion);
69
84
 
70
- packageJson.version = version;
71
- writePackageJson(packageJson);
85
+ if (!localIsAhead) {
86
+ const latestVersion = publishedVersion || localVersion;
87
+ const suggestedVersion = bumpPatch(latestVersion);
88
+ version = await askWithDefault('What version should we use?', suggestedVersion);
72
89
  }
73
90
 
91
+ packageJson.version = version;
92
+ writePackageJson(packageJson);
93
+
74
94
  execSync('npm publish', {
75
95
  cwd: mineralRoot,
76
96
  stdio: 'inherit',
@@ -0,0 +1,319 @@
1
+ const readline = require('readline');
2
+ const { toAbsolutePath, setWorkspace } = require('../api/workspace');
3
+ const { getApiDirs, readCliFlag } = require('../cli');
4
+ const { loadHandlers } = require('../server');
5
+ const { readHostingYml, getCredsJsonForDeploy, getHostedApiKeyForDeploy, resolveHostedHandlersForDeploy, functionUsesWrapper } = require('../hosting.utils');
6
+ const { writeHostedJs } = require('./generateHosted');
7
+ const { execCommand } = require('./execCommand');
8
+ const { formatSetEnvVarsForGcloud, shellQuoteSingle } = require('./setEnvVarsGcloud');
9
+
10
+ const GCLOUD_NODEJS_MAX_MAJOR = 24;
11
+
12
+ const gcloudRuntimeFromCurrentNode = () => {
13
+ const localMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
14
+ const effective = Number.isNaN(localMajor) ? GCLOUD_NODEJS_MAX_MAJOR : localMajor;
15
+ const major = Math.min(effective, GCLOUD_NODEJS_MAX_MAJOR);
16
+ return `nodejs${ major }`;
17
+ };
18
+
19
+ const getHostConfig = (options = {}) => ({
20
+ workspace: toAbsolutePath(
21
+ options.workspace
22
+ ?? readCliFlag('--workspace')
23
+ ?? process.env.MINERAL_WORKSPACE
24
+ ?? process.cwd(),
25
+ ),
26
+ api_dirs: getApiDirs(options),
27
+ });
28
+
29
+ const chooseOption = async (prompt, options) => new Promise((resolve) => {
30
+ const rl = readline.createInterface({
31
+ input: process.stdin,
32
+ output: process.stdout,
33
+ });
34
+
35
+ console.log(prompt);
36
+ options.forEach((option, index) => {
37
+ console.log(` ${ index + 1 }. ${ option }`);
38
+ });
39
+
40
+ rl.question('> ', (answer) => {
41
+ rl.close();
42
+ const choice = Number(answer) - 1;
43
+ resolve(options[choice] ?? options[0]);
44
+ });
45
+ });
46
+
47
+ const anyHostedEntryUsesWrapper = (hostedEntries, wrapperName) => (
48
+ hostedEntries.some((hostedEntry) => hostedEntry.wrappers?.includes(wrapperName))
49
+ );
50
+
51
+ const getDeployArgs = () => {
52
+ const args = process.argv.slice(2);
53
+ const deployArgs = [];
54
+
55
+ for (let index = 0; index < args.length; index++) {
56
+ const arg = args[index];
57
+
58
+ if (arg === '--workspace' || arg === '--api_dirs') {
59
+ index++;
60
+ continue;
61
+ }
62
+
63
+ if (arg.startsWith('--workspace=') || arg.startsWith('--api_dirs=')) {
64
+ continue;
65
+ }
66
+
67
+ deployArgs.push(arg);
68
+ }
69
+
70
+ return deployArgs;
71
+ };
72
+
73
+ const handlerByRouteName = (handlers) => {
74
+ const byName = new Map();
75
+
76
+ for (const handler of handlers.values()) {
77
+ byName.set(handler.routeName, handler);
78
+ }
79
+
80
+ return byName;
81
+ };
82
+
83
+ const deployFunction = async ({
84
+ functionName,
85
+ functionConfig,
86
+ googleCloudInfo,
87
+ workspace,
88
+ credsJson,
89
+ hostedApiKey,
90
+ }) => {
91
+ const config = {
92
+ ...googleCloudInfo,
93
+ ...functionConfig,
94
+ };
95
+
96
+ let {
97
+ project,
98
+ region,
99
+ trigger = 'http',
100
+ runtime = gcloudRuntimeFromCurrentNode(),
101
+ allow_unauthenticated: allowUnauthenticated = true,
102
+ gen2 = true,
103
+ set_env_vars: extraSetEnvVars,
104
+ entry_point: entryPoint,
105
+ schedules,
106
+ groups,
107
+ wrappers,
108
+ source,
109
+ ...gcloudArgs
110
+ } = config;
111
+
112
+ const resolvedEntryPoint = entryPoint || functionName;
113
+ const envParts = [
114
+ 'HOSTED=true',
115
+ `CREDS=${ credsJson }`,
116
+ ];
117
+
118
+ if (hostedApiKey) {
119
+ envParts.push(`HOSTED_API_KEY=${ hostedApiKey }`);
120
+ }
121
+
122
+ if (extraSetEnvVars) {
123
+ envParts.push(extraSetEnvVars);
124
+ }
125
+
126
+ const rawSetEnvVars = envParts.join(',');
127
+ const setEnvVarsForGcloud = formatSetEnvVarsForGcloud(rawSetEnvVars);
128
+
129
+ const deployCommand = [
130
+ `gcloud functions deploy ${ functionName }`,
131
+ `--project ${ project }`,
132
+ `--region ${ region }`,
133
+ `--source ${ shellQuoteSingle(workspace) }`,
134
+ `--entry-point ${ resolvedEntryPoint }`,
135
+ `--trigger-${ trigger }`,
136
+ `--runtime ${ runtime }`,
137
+ `--set-env-vars ${ shellQuoteSingle(setEnvVarsForGcloud) }`,
138
+ ...(allowUnauthenticated ? ['--allow-unauthenticated'] : []),
139
+ ...(gen2 ? ['--gen2'] : []),
140
+ ...Object.entries(gcloudArgs).map(([key, value]) => `--${ key.replaceAll('_', '-') } ${ value }`),
141
+ ].join(' ');
142
+
143
+ console.log(deployCommand);
144
+
145
+ try {
146
+ await execCommand(deployCommand);
147
+ } catch (error) {
148
+ console.error(`Error deploying function ${ functionName }:`, error);
149
+ return;
150
+ }
151
+
152
+ if (!schedules?.length) {
153
+ return;
154
+ }
155
+
156
+ const requiresHostedApiKey = functionUsesWrapper(functionConfig, 'requireHostedApiKey');
157
+
158
+ for (const schedule of schedules) {
159
+ const {
160
+ name: jobName,
161
+ schedule: jobSchedule,
162
+ http_method: jobHttpMethod = 'POST',
163
+ headers: jobHeaders = '',
164
+ message_body: jobMessageBody,
165
+ ...schedulerArgs
166
+ } = schedule;
167
+
168
+ let schedulerHeaders = jobHeaders;
169
+ if (requiresHostedApiKey && hostedApiKey) {
170
+ schedulerHeaders = schedulerHeaders ? `${ schedulerHeaders },` : '';
171
+ schedulerHeaders += `x-api-key=${ hostedApiKey }`;
172
+ }
173
+
174
+ try {
175
+ const checkCommand = `gcloud scheduler jobs describe ${ jobName } --project=${ project } --location=${ region } 2>/dev/null || echo "NOT_FOUND"`;
176
+ const checkResult = await execCommand(checkCommand);
177
+ const jobExists = !checkResult.stdout.includes('NOT_FOUND');
178
+
179
+ const schedulerCommand = [
180
+ jobExists ? `gcloud scheduler jobs update http ${ jobName }` : `gcloud scheduler jobs create http ${ jobName }`,
181
+ `--schedule="${ jobSchedule }"`,
182
+ `--uri="https://${ region }-${ project }.cloudfunctions.net/${ functionName }"`,
183
+ `--http-method=${ jobHttpMethod }`,
184
+ `--project=${ project }`,
185
+ `--location=${ region }`,
186
+ jobExists ? `--update-headers ${ schedulerHeaders }` : `--headers ${ schedulerHeaders }`,
187
+ ...(jobMessageBody ? [`--message-body '${ jobMessageBody }'`] : []),
188
+ ...Object.entries(schedulerArgs).map(([key, value]) => `--${ key.replaceAll('_', '-') } ${ value }`),
189
+ ].join(' ');
190
+
191
+ console.log(schedulerCommand);
192
+ await execCommand(schedulerCommand);
193
+ } catch (error) {
194
+ console.error(`Error handling scheduler job ${ jobName }:`, error);
195
+ }
196
+ }
197
+ };
198
+
199
+ const deployFromHostingYml = async (options = {}) => {
200
+ const config = getHostConfig(options);
201
+ setWorkspace(config.workspace);
202
+
203
+ const hostingConfig = readHostingYml(config.workspace);
204
+ const {
205
+ google_cloud_info: googleCloudInfo,
206
+ functions = {},
207
+ groups = {},
208
+ } = hostingConfig;
209
+
210
+ if (!googleCloudInfo?.project || !googleCloudInfo?.region) {
211
+ throw new Error('.hosting.yml requires google_cloud_info.project and google_cloud_info.region');
212
+ }
213
+
214
+ const handlers = loadHandlers({
215
+ ...config,
216
+ host_mode: true,
217
+ });
218
+ const handlersByName = handlerByRouteName(handlers);
219
+ const hostedHandlers = resolveHostedHandlersForDeploy({
220
+ functions,
221
+ workspace: config.workspace,
222
+ handlersByName,
223
+ });
224
+
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
+ }
231
+
232
+ writeHostedJs({
233
+ workspace: config.workspace,
234
+ hostedHandlers,
235
+ });
236
+
237
+ const credsJson = getCredsJsonForDeploy(config.workspace);
238
+ const hostedApiKey = getHostedApiKeyForDeploy(config.workspace);
239
+ const deployArgs = getDeployArgs();
240
+
241
+ const deployOne = async (functionName) => {
242
+ if (!functions[functionName]) {
243
+ console.log(`Function ${ functionName } not found in .hosting.yml, skipping`);
244
+ return;
245
+ }
246
+
247
+ await deployFunction({
248
+ functionName,
249
+ functionConfig: functions[functionName],
250
+ googleCloudInfo,
251
+ workspace: config.workspace,
252
+ credsJson,
253
+ hostedApiKey,
254
+ });
255
+ };
256
+
257
+ if (deployArgs.includes('group')) {
258
+ const groupNames = Object.keys(groups);
259
+ if (!groupNames.length) {
260
+ console.log('No groups defined in .hosting.yml');
261
+ return;
262
+ }
263
+
264
+ const selectedGroup = await chooseOption('Which group would you like to deploy?', groupNames);
265
+ for (const functionName of groups[selectedGroup] || []) {
266
+ await deployOne(functionName);
267
+ }
268
+ return;
269
+ }
270
+
271
+ if (deployArgs.includes('function')) {
272
+ const functionNames = Object.keys(functions);
273
+ if (!functionNames.length) {
274
+ console.log('No functions defined in .hosting.yml');
275
+ return;
276
+ }
277
+
278
+ const selectedFunction = await chooseOption('Which function would you like to deploy?', functionNames);
279
+ await deployOne(selectedFunction);
280
+ return;
281
+ }
282
+
283
+ if (deployArgs.includes('all')) {
284
+ for (const functionName of Object.keys(functions)) {
285
+ await deployOne(functionName);
286
+ }
287
+ return;
288
+ }
289
+
290
+ console.log(`
291
+ 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
295
+ `);
296
+ };
297
+
298
+ if (require.main === module) {
299
+ deployFromHostingYml().catch((error) => {
300
+ console.error(error);
301
+ process.exit(1);
302
+ });
303
+ }
304
+
305
+ module.exports = {
306
+ deployFromHostingYml,
307
+ getHostConfig,
308
+ };
309
+
310
+ /*
311
+ Deploy everything:
312
+ npm run host all
313
+
314
+ Deploy a single function:
315
+ npm run host function
316
+
317
+ Deploy a group of functions:
318
+ npm run host group
319
+ */
@@ -0,0 +1,38 @@
1
+ const { spawn } = require('child_process');
2
+
3
+ const execCommand = (command) => new Promise((resolve, reject) => {
4
+ const childProcess = spawn(command, [], {
5
+ stdio: 'pipe',
6
+ shell: true,
7
+ });
8
+
9
+ let stdout = '';
10
+ let stderr = '';
11
+
12
+ childProcess.stdout.on('data', (data) => {
13
+ stdout += data.toString();
14
+ process.stdout.write(data);
15
+ });
16
+
17
+ childProcess.stderr.on('data', (data) => {
18
+ stderr += data.toString();
19
+ process.stderr.write(data);
20
+ });
21
+
22
+ childProcess.on('close', (code) => {
23
+ if (code === 0) {
24
+ resolve({ stdout, stderr, command });
25
+ return;
26
+ }
27
+
28
+ reject({ stdout, stderr, command, code });
29
+ });
30
+
31
+ childProcess.on('error', (error) => {
32
+ reject({ error, command });
33
+ });
34
+ });
35
+
36
+ module.exports = {
37
+ execCommand,
38
+ };
@@ -0,0 +1,44 @@
1
+ const fs = require('fs');
2
+ const { wrapHostedFunction } = require('../hosting.utils');
3
+
4
+ const generateHostedJs = ({
5
+ hostedHandlers,
6
+ }) => {
7
+ const exportLines = [];
8
+ const hostingUtilsRequire = '@foxtware/mineral/hosting.utils';
9
+
10
+ for (const hostedHandler of hostedHandlers) {
11
+ const { entryPoint, wrappers = [], requirePath } = hostedHandler;
12
+ const wrappersArg = wrappers.length ? `, ${ JSON.stringify(wrappers) }` : '';
13
+
14
+ exportLines.push(
15
+ ` ${ entryPoint }: wrapHostedFunction(() => require('${ requirePath }'), '${ entryPoint }'${ wrappersArg }),`,
16
+ );
17
+ }
18
+
19
+ return `// Generated by mineral — do not edit
20
+ const { wrapHostedFunction } = require('${ hostingUtilsRequire }');
21
+
22
+ module.exports = {
23
+ ${ exportLines.join('\n') }
24
+ };
25
+ `;
26
+ };
27
+
28
+ const writeHostedJs = ({
29
+ workspace,
30
+ hostedHandlers,
31
+ }) => {
32
+ const hostedPath = `${ workspace.replace(/\/$/, '') }/hosted.js`;
33
+ const content = generateHostedJs({
34
+ hostedHandlers,
35
+ });
36
+
37
+ fs.writeFileSync(hostedPath, content);
38
+ return hostedPath;
39
+ };
40
+
41
+ module.exports = {
42
+ generateHostedJs,
43
+ writeHostedJs,
44
+ };
@@ -0,0 +1,51 @@
1
+ function splitSetEnvVarsPairs(combined) {
2
+ const segments = combined.split(/,(?=[A-Za-z_][A-Za-z0-9_]*=)/);
3
+ return segments.map((segment) => {
4
+ const eq = segment.indexOf('=');
5
+ if (eq === -1) {
6
+ throw new Error(`Invalid set_env_vars segment (no =): ${ segment }`);
7
+ }
8
+
9
+ return {
10
+ key: segment.slice(0, eq).trim(),
11
+ value: segment.slice(eq + 1),
12
+ };
13
+ });
14
+ }
15
+
16
+ function pickDelimiter(pairs) {
17
+ const blob = pairs.map((pair) => `${ pair.key }=${ pair.value }`).join('');
18
+
19
+ for (let count = 3; count < 64; count++) {
20
+ const delimiter = '#'.repeat(count);
21
+ if (!blob.includes(delimiter)) {
22
+ return delimiter;
23
+ }
24
+ }
25
+
26
+ throw new Error('Could not find a delimiter for gcloud --set-env-vars');
27
+ }
28
+
29
+ function formatSetEnvVarsForGcloud(combined) {
30
+ const pairs = splitSetEnvVarsPairs(combined);
31
+ if (pairs.length === 0) {
32
+ return combined;
33
+ }
34
+
35
+ const needsDelimiter = pairs.length > 1 || pairs.some((pair) => pair.value.includes(','));
36
+ if (!needsDelimiter) {
37
+ return combined;
38
+ }
39
+
40
+ const delimiter = pickDelimiter(pairs);
41
+ return `^${ delimiter }^${ pairs.map((pair) => `${ pair.key }=${ pair.value }`).join(delimiter) }`;
42
+ }
43
+
44
+ function shellQuoteSingle(value) {
45
+ return `'${ String(value).replace(/'/g, `'\\''`) }'`;
46
+ }
47
+
48
+ module.exports = {
49
+ formatSetEnvVarsForGcloud,
50
+ shellQuoteSingle,
51
+ };
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxtware/mineral",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "bin": {
5
5
  "mineral": "bin/mineral.js"
6
6
  },
package/server.js CHANGED
@@ -1,49 +1,12 @@
1
1
  const fs = require('fs');
2
2
  const http = require('http');
3
- const { respondJson, errorToReadable, getRequestBody, argsFromBody, funcApi } = require('./server.utils');
3
+ const { respondJson, errorToReadable, getRequestBody, argsFromBody, funcApi, statusCodeFromResult } = require('./server.utils');
4
4
  const { getWorkspace, setWorkspace, loadWorkspaceEnv, toAbsolutePath } = require('./api/workspace');
5
+ const { getApiDirs, readCliFlag } = require('./cli');
6
+ const { getFuncApiConfig } = require('./hosting.utils');
5
7
 
6
8
  const MINERAL_API_DIR = `${ __dirname }/api`;
7
9
 
8
- // --- Config ---
9
-
10
- const splitCommaList = (value = '') => value
11
- .split(',')
12
- .map((item) => item.trim())
13
- .filter(Boolean);
14
-
15
- const readCliFlag = (flag) => {
16
- const args = process.argv.slice(2);
17
- const equalsPrefix = `${ flag }=`;
18
-
19
- for (let index = 0; index < args.length; index++) {
20
- if (args[index].startsWith(equalsPrefix)) {
21
- return args[index].slice(equalsPrefix.length);
22
- }
23
-
24
- if (args[index] === flag) {
25
- return args[index + 1];
26
- }
27
- }
28
- };
29
-
30
- const getApiDirs = (options = {}) => {
31
- if (options.api_dirs) {
32
- return options.api_dirs;
33
- }
34
-
35
- const fromCli = readCliFlag('--api_dirs');
36
- if (fromCli) {
37
- return splitCommaList(fromCli);
38
- }
39
-
40
- if (process.env.MINERAL_API_DIRS) {
41
- return splitCommaList(process.env.MINERAL_API_DIRS);
42
- }
43
-
44
- return [];
45
- };
46
-
47
10
  const getConfig = (options = {}) => ({
48
11
  port: Number(options.port ?? process.env.PORT ?? 8000),
49
12
  workspace: toAbsolutePath(
@@ -53,6 +16,7 @@ const getConfig = (options = {}) => ({
53
16
  ?? process.cwd(),
54
17
  ),
55
18
  api_dirs: getApiDirs(options),
19
+ host_mode: Boolean(options.host_mode),
56
20
  });
57
21
 
58
22
  // --- Handler discovery ---
@@ -105,6 +69,7 @@ const listJsFiles = (directory) => {
105
69
  const directoriesToScan = ({
106
70
  workspace,
107
71
  api_dirs,
72
+ host_mode = false,
108
73
  }) => {
109
74
  const extraDirs = api_dirs.map((dir) => (
110
75
  dir.startsWith('/')
@@ -112,30 +77,15 @@ const directoriesToScan = ({
112
77
  : `${ workspace }/${ dir }`
113
78
  ));
114
79
 
115
- return [MINERAL_API_DIR, ...extraDirs];
116
- };
117
-
118
- const getFuncApiConfig = ({
119
- moduleExports,
120
- routeName,
121
- }) => {
122
- const { funcApiConfig } = moduleExports;
123
- if (!funcApiConfig || typeof funcApiConfig !== 'object') {
124
- return undefined;
80
+ if (host_mode && api_dirs.length) {
81
+ return extraDirs;
125
82
  }
126
83
 
127
- if (funcApiConfig[routeName]) {
128
- return funcApiConfig[routeName];
129
- }
130
-
131
- const exportNames = Object.keys(moduleExports).filter((key) => key !== 'funcApiConfig');
132
- const configIsShared = !exportNames.some((name) => funcApiConfig[name]);
133
-
134
- if (configIsShared) {
135
- return funcApiConfig;
136
- }
84
+ return [MINERAL_API_DIR, ...extraDirs];
137
85
  };
138
86
 
87
+ const getFuncApiConfigFromModule = getFuncApiConfig;
88
+
139
89
  const addHandlerFromFile = (filePath, handlers) => {
140
90
  const moduleExports = require(filePath);
141
91
  if (!moduleExports || typeof moduleExports !== 'object') {
@@ -148,7 +98,7 @@ const addHandlerFromFile = (filePath, handlers) => {
148
98
  return;
149
99
  }
150
100
 
151
- const funcApiConfig = getFuncApiConfig({
101
+ const funcApiConfig = getFuncApiConfigFromModule({
152
102
  moduleExports,
153
103
  routeName,
154
104
  });
@@ -218,7 +168,7 @@ const createServer = (handlers) => http.createServer(async (req, res) => {
218
168
  return;
219
169
  }
220
170
 
221
- respondJson(res, 200, result);
171
+ respondJson(res, statusCodeFromResult(result), result);
222
172
  } catch (error) {
223
173
  respondJson(res, 500, {
224
174
  ok: false,
package/server.utils.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { logDeep } = require('./api/utils');
2
2
  const { StringDecoder } = require('string_decoder');
3
+ const { HOSTED } = require('./api/constants');
3
4
 
4
5
  const respondJson = (res, statusCode, payload) => {
5
6
  logDeep(payload);
@@ -116,6 +117,42 @@ const runRequestHandler = async (requestHandler, requestContext) => {
116
117
  return mergeRequestContext(requestContext, handlerOutput);
117
118
  };
118
119
 
120
+ const wrapFunction = (func, wrappers = []) => async (req, res, ...rest) => {
121
+ for (const wrapper of wrappers) {
122
+ const rejected = await wrapper(req, res);
123
+ if (rejected) {
124
+ return rejected;
125
+ }
126
+ }
127
+
128
+ return func(req, res, ...rest);
129
+ };
130
+
131
+ const requireHostedApiKey = async (req) => {
132
+ if (!HOSTED) {
133
+ return;
134
+ }
135
+
136
+ if (req.headers['x-api-key'] !== process.env.HOSTED_API_KEY) {
137
+ return {
138
+ ok: false,
139
+ error: {
140
+ code: 'UNAUTHORIZED',
141
+ message: 'Unauthorized',
142
+ statusCode: 401,
143
+ },
144
+ };
145
+ }
146
+ };
147
+
148
+ const statusCodeFromResult = (result) => {
149
+ if (result?.ok === false) {
150
+ return result?.error?.statusCode ?? 400;
151
+ }
152
+
153
+ return 200;
154
+ };
155
+
119
156
  const funcApi = (func, config = {}) => {
120
157
  const {
121
158
  requestHandler,
@@ -224,5 +261,8 @@ module.exports = {
224
261
  errorToReadable,
225
262
  getRequestBody,
226
263
  argsFromBody,
264
+ wrapFunction,
265
+ requireHostedApiKey,
266
+ statusCodeFromResult,
227
267
  funcApi,
228
268
  };