@foxtware/mineral 0.1.6 → 0.1.8

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/.creds.yml.sample CHANGED
@@ -23,4 +23,7 @@ yotpo:
23
23
  MERCHANT_ID: _________________________
24
24
 
25
25
  linear:
26
- API_KEY: _______________________________
26
+ API_KEY: _______________________________
27
+
28
+ slack:
29
+ BOT_TOKEN: xoxb________________________________
package/.env.sample ADDED
@@ -0,0 +1,2 @@
1
+ # Required by the requireHostedApiKey wrapper — requests must send this as the x-api-key header.
2
+ HOSTED_API_KEY=________________________________
package/README.md CHANGED
@@ -52,3 +52,8 @@ The [bedrock](https://github.com/GorgonFreeman/bedrock) middleware, refactored f
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
53
 
54
54
  For cloud deploy, workspaces use `hosting/.hosting.yml` and `npm run host` (same `--workspace` / `--api_dirs` flags as dev/serve). See `hosting/.hosting.yml.sample`.
55
+
56
+ ## What the thang do
57
+ - Server makes functions available from the api/ route, where an export matches the filename. Run `npm run serve`, and they're all curlable.
58
+ - .creds.yml is copied into .env when deploying, so creds can be accessed while hosted. Locally, it reads from the file directly.
59
+ - Cloud deploy reads `hosting/.hosting.yml` for per-function config — `before_wrappers` / `after_wrappers` like `requireHostedApiKey`, `max_instances`, schedules — and deploys each function to Google Cloud.
@@ -0,0 +1,206 @@
1
+ // https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens/set-up-session-tokens
2
+
3
+ const crypto = require('crypto');
4
+ const { credsFromPayload, ArgsWarden } = require('../utils');
5
+ const { credsValidator } = require('../validators');
6
+
7
+ const sessionTokenValidator = (sessionToken) => {
8
+ return typeof sessionToken === 'string' && Boolean(sessionToken.trim());
9
+ };
10
+
11
+ const argsWarden = new ArgsWarden([
12
+ ['credsPayload', credsValidator],
13
+ ['sessionToken', sessionTokenValidator],
14
+ ]);
15
+
16
+ const base64UrlDecodeJson = (input) => {
17
+ const base64 = input.replace(/-/g, '+').replace(/_/g, '/');
18
+ const padding = '='.repeat((4 - base64.length % 4) % 4);
19
+
20
+ return JSON.parse(Buffer.from(`${ base64 }${ padding }`, 'base64').toString('utf8'));
21
+ };
22
+
23
+ const normalizeSessionToken = (sessionToken) => {
24
+ const trimmedToken = sessionToken.trim();
25
+
26
+ if (trimmedToken.toLowerCase().startsWith('bearer ')) {
27
+ return trimmedToken.slice(7).trim();
28
+ }
29
+
30
+ return trimmedToken;
31
+ };
32
+
33
+ const verifySessionTokenSignature = (token, apiSecret) => {
34
+ const [encodedHeader, encodedPayload, signature] = token.split('.');
35
+
36
+ if (!encodedHeader || !encodedPayload || !signature) {
37
+ return false;
38
+ }
39
+
40
+ const message = `${ encodedHeader }.${ encodedPayload }`;
41
+ const computedSignature = crypto
42
+ .createHmac('sha256', apiSecret)
43
+ .update(message)
44
+ .digest('base64url');
45
+
46
+ return computedSignature === signature;
47
+ };
48
+
49
+ const validateSessionTokenClaims = (
50
+ decoded,
51
+ {
52
+ checkAudience = true,
53
+ clientId,
54
+ } = {},
55
+ ) => {
56
+ const {
57
+ iss,
58
+ dest,
59
+ aud,
60
+ exp,
61
+ nbf,
62
+ } = decoded;
63
+
64
+ const currentTime = Math.floor(Date.now() / 1000);
65
+ const expValid = exp > currentTime;
66
+ const nbfValid = nbf <= currentTime;
67
+ const domainsValid = dest?.includes('myshopify.com') && iss?.includes('myshopify.com');
68
+ const audValid = !checkAudience || aud === clientId;
69
+
70
+ return expValid && nbfValid && domainsValid && audValid;
71
+ };
72
+
73
+ const decodeSessionTokenPayload = (token) => {
74
+ const encodedPayload = token.split('.')[1];
75
+
76
+ if (!encodedPayload) {
77
+ return null;
78
+ }
79
+
80
+ try {
81
+ return base64UrlDecodeJson(encodedPayload);
82
+ } catch (error) {
83
+ return null;
84
+ }
85
+ };
86
+
87
+ const shopifyDecodeSessionToken = async (
88
+ credsPayload,
89
+ sessionToken,
90
+ {
91
+ checkAudience = true,
92
+ } = {},
93
+ ) => {
94
+
95
+ const rejectResponse = await argsWarden.responseIfRejectingArgs({
96
+ credsPayload,
97
+ sessionToken,
98
+ });
99
+ if (rejectResponse) {
100
+ return rejectResponse;
101
+ }
102
+
103
+ const creds = await credsFromPayload(credsPayload);
104
+ const {
105
+ API_SECRET,
106
+ CLIENT_ID,
107
+ } = creds;
108
+
109
+ if (!API_SECRET || !CLIENT_ID) {
110
+ return {
111
+ ok: false,
112
+ error: {
113
+ code: 'INVALID_CREDS',
114
+ message: 'API_SECRET and CLIENT_ID are required.',
115
+ },
116
+ };
117
+ }
118
+
119
+ const token = normalizeSessionToken(sessionToken);
120
+ const decoded = decodeSessionTokenPayload(token);
121
+
122
+ if (!decoded) {
123
+ return {
124
+ ok: false,
125
+ error: {
126
+ code: 'INVALID_SESSION_TOKEN',
127
+ message: 'Session token could not be decoded.',
128
+ },
129
+ };
130
+ }
131
+
132
+ if (!verifySessionTokenSignature(token, API_SECRET)) {
133
+ return {
134
+ ok: false,
135
+ error: {
136
+ code: 'INVALID_SESSION_TOKEN',
137
+ message: 'Session token signature is invalid.',
138
+ },
139
+ };
140
+ }
141
+
142
+ if (!validateSessionTokenClaims(decoded, {
143
+ checkAudience,
144
+ clientId: CLIENT_ID,
145
+ })) {
146
+ return {
147
+ ok: false,
148
+ error: {
149
+ code: 'INVALID_SESSION_TOKEN',
150
+ message: 'Session token claims are invalid.',
151
+ details: decoded,
152
+ },
153
+ };
154
+ }
155
+
156
+ return {
157
+ ok: true,
158
+ data: decoded,
159
+ };
160
+ };
161
+
162
+ const funcApiConfig = {
163
+ argsWarden,
164
+ };
165
+
166
+ module.exports = {
167
+ shopifyDecodeSessionToken,
168
+ funcApiConfig,
169
+ };
170
+
171
+ /*
172
+ curl -X POST "http://localhost:8000/shopifyDecodeSessionToken" \
173
+ -H "Content-Type: application/json" \
174
+ -d '{
175
+ "credsPayload": { "credsPath": "tender.prod" },
176
+ "sessionToken": "<jwt>"
177
+ }'
178
+ */
179
+
180
+ /*
181
+ Legacy usage:
182
+ const whichApp = req.headers['x-wf-app'];
183
+
184
+ const sessionToken = req?.headers?.authorization?.split(' ')?.pop();
185
+ if (!sessionToken) {
186
+ return respond(res, 401, {
187
+ error: 'Unauthorized: No session token',
188
+ });
189
+ }
190
+
191
+ const sessionTokenData = shopifyDecodeSessionToken(sessionToken, {
192
+ ...whichApp && { credsPath: whichApp },
193
+ });
194
+
195
+ const {
196
+ dest,
197
+ sub: customerGid,
198
+ } = sessionTokenData;
199
+
200
+ const config = domainToConfig(dest);
201
+ if (!config) {
202
+ return respond(res, 401, {
203
+ error: 'No config found for domain',
204
+ });
205
+ }
206
+ */
@@ -0,0 +1,5 @@
1
+ const SLACK_API_BASE_URL = 'https://slack.com/api';
2
+
3
+ module.exports = {
4
+ SLACK_API_BASE_URL,
5
+ };
@@ -0,0 +1,46 @@
1
+ const { SLACK_API_BASE_URL } = require('../slack/slack.constants');
2
+ const {
3
+ FetchClient,
4
+ Chain,
5
+ appendUrlToBase,
6
+ fetchClientCommonSteps,
7
+ } = require('../utils');
8
+
9
+ const addUrlAndAuthHeaders = async (state) => {
10
+ const { requestPayload, context } = state;
11
+ const { creds } = context;
12
+ const {
13
+ BOT_TOKEN,
14
+ } = creds;
15
+
16
+ return {
17
+ requestPayload: {
18
+ ...requestPayload,
19
+ method: requestPayload.method || 'post',
20
+ url: appendUrlToBase(SLACK_API_BASE_URL, requestPayload.url),
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ Authorization: `Bearer ${ BOT_TOKEN }`,
24
+ ...requestPayload.headers,
25
+ },
26
+ },
27
+ };
28
+ };
29
+
30
+
31
+ const slackClientRequestPreparer = new Chain([
32
+ addUrlAndAuthHeaders,
33
+ ]);
34
+
35
+ const slackClientResponseInterpreter = new Chain([
36
+ fetchClientCommonSteps.exitEarlyOnNotOk,
37
+ ]);
38
+
39
+ const slackClient = new FetchClient({
40
+ requestPreparer: slackClientRequestPreparer,
41
+ responseInterpreter: slackClientResponseInterpreter,
42
+ });
43
+
44
+ module.exports = {
45
+ slackClient,
46
+ };
@@ -0,0 +1,85 @@
1
+ // https://docs.slack.dev/reference/methods/chat.postmessage
2
+
3
+ const { credsFromPayload, objHasAny, ArgsWarden } = require('../utils');
4
+ const { credsValidator } = require('../validators');
5
+ const { slackClient } = require('../slack/slack.utils');
6
+
7
+ const channelIdentifierValidator = (channelIdentifier) => {
8
+ return objHasAny(channelIdentifier, ['channelName', 'channelId']);
9
+ };
10
+
11
+ const messagePayloadValidator = (messagePayload) => {
12
+ return objHasAny(messagePayload, ['text', 'blocks', 'markdownText']);
13
+ };
14
+
15
+ const argsWarden = new ArgsWarden([
16
+ ['credsPayload', credsValidator],
17
+ ['channelIdentifier', channelIdentifierValidator],
18
+ ['messagePayload', messagePayloadValidator],
19
+ ]);
20
+
21
+ const slackMessagePost = async (
22
+ credsPayload,
23
+ channelIdentifier,
24
+ messagePayload,
25
+ {
26
+ inspect = false,
27
+ } = {},
28
+ ) => {
29
+
30
+ const rejectResponse = await argsWarden.responseIfRejectingArgs({
31
+ credsPayload,
32
+ channelIdentifier,
33
+ messagePayload,
34
+ });
35
+ if (rejectResponse) {
36
+ return rejectResponse;
37
+ }
38
+
39
+ const creds = await credsFromPayload(credsPayload);
40
+
41
+ const {
42
+ channelName,
43
+ channelId,
44
+ } = channelIdentifier;
45
+
46
+ const {
47
+ text,
48
+ blocks,
49
+ markdownText,
50
+ } = messagePayload;
51
+
52
+ const response = await slackClient.fetch({
53
+ url: '/chat.postMessage',
54
+ method: 'post',
55
+ body: {
56
+ channel: channelId || channelName,
57
+ ...text && { text },
58
+ ...blocks && { blocks },
59
+ ...markdownText && { markdown_text: markdownText },
60
+ },
61
+ context: { creds },
62
+ inspect,
63
+ });
64
+
65
+ return response;
66
+ };
67
+
68
+ const funcApiConfig = {
69
+ argsWarden,
70
+ };
71
+
72
+ module.exports = {
73
+ slackMessagePost,
74
+ funcApiConfig,
75
+ };
76
+
77
+ /*
78
+ curl -X POST "http://localhost:8000/slackMessagePost" \
79
+ -H "Content-Type: application/json" \
80
+ -d '{
81
+ "credsPayload": { "credsPath": "slack" },
82
+ "channelIdentifier": { "channelName": "#hidden_testing" },
83
+ "messagePayload": { "text": "new number, who dis?" }
84
+ }'
85
+ */
package/api/utils.js CHANGED
@@ -146,14 +146,14 @@ const customFetch = async (url, {
146
146
  });
147
147
 
148
148
  const responseContentType = response.headers.get('content-type');
149
- console.log(responseContentType);
149
+ !HOSTED && console.log('responseContentType', responseContentType);
150
150
 
151
151
  if (!responseParser) {
152
152
  responseParser = getResponseParser(responseContentType);
153
153
  }
154
154
 
155
155
  const parsedResponse = await responseParser(response);
156
- logDeep({ parsedResponse });
156
+ !HOSTED && logDeep({ parsedResponse });
157
157
 
158
158
  if (response.ok) {
159
159
  return {
@@ -278,7 +278,6 @@ const pathAsArray = (path) => {
278
278
 
279
279
  const objectDigNodeAtPath = (obj, path) => {
280
280
  let nodes = pathAsArray(path);
281
- console.log(nodes);
282
281
 
283
282
  let output = obj;
284
283
  for (const node of nodes) {
@@ -1135,6 +1134,10 @@ class ArgsWarden {
1135
1134
  }
1136
1135
  }
1137
1136
 
1137
+ const gidToId = (gid) => {
1138
+ return gid.split('/').pop();
1139
+ };
1140
+
1138
1141
  module.exports = {
1139
1142
  wait,
1140
1143
  timeMs,
@@ -1161,7 +1164,7 @@ module.exports = {
1161
1164
  actionSingleOrMultiple,
1162
1165
  Processor,
1163
1166
  Getter,
1164
- capitaliseString,
1165
1167
  sentenceCaseString,
1166
1168
  ArgsWarden,
1169
+ gidToId,
1167
1170
  };
package/bin/mineral.js CHANGED
@@ -11,6 +11,12 @@ if (command === 'host') {
11
11
  return;
12
12
  }
13
13
 
14
+ if (command === 'hosting_preview') {
15
+ const { startHostingPreview } = require('../hosting/hostingPreview');
16
+ startHostingPreview();
17
+ return;
18
+ }
19
+
14
20
  const { startServer } = require('../server');
15
21
 
16
22
  startServer();
@@ -8,14 +8,14 @@ functions:
8
8
  exampleFunction:
9
9
  max_instances: 1
10
10
  timeout: 300s
11
- wrappers:
12
- - requireHostedApiKey
11
+ before_wrappers:
13
12
  - allowCrossOriginCallsAndHandleOptions
13
+ - requireHostedApiKey
14
14
  # entry_point: otherHandlerName
15
15
 
16
16
  pokemonPokeballThrow:
17
17
  max_instances: 1
18
- wrappers:
18
+ before_wrappers:
19
19
  - checkTrainer
20
20
 
21
21
  groups:
@@ -107,7 +107,8 @@ const deployFunction = async ({
107
107
  entry_point,
108
108
  schedules,
109
109
  groups,
110
- wrappers,
110
+ before_wrappers,
111
+ after_wrappers,
111
112
  source,
112
113
  env,
113
114
  ...gcloudArgs
@@ -1,19 +1,49 @@
1
1
  const fs = require('fs');
2
2
  const { wrapHostedFunction } = require('./hosting.utils');
3
3
 
4
+ const requirePathForHostedJs = (modulePath) => {
5
+ if (modulePath === './hosting/wrappers.js') {
6
+ return './wrappers.js';
7
+ }
8
+
9
+ if (modulePath.startsWith('./api/')) {
10
+ return `../${ modulePath.slice(2) }`;
11
+ }
12
+
13
+ return modulePath;
14
+ };
15
+
4
16
  const formatResolvedWrapperForHostedJs = ({ modulePath, wrapperName }) => (
5
- `require('${ modulePath }').${ wrapperName }`
17
+ `require('${ requirePathForHostedJs(modulePath) }').${ wrapperName }`
6
18
  );
7
19
 
8
- const formatWrappersArg = (resolvedWrappers = []) => {
20
+ const formatWrapperList = (resolvedWrappers = []) => {
9
21
  if (!resolvedWrappers.length) {
10
22
  return '';
11
23
  }
12
24
 
13
25
  const wrapperLines = resolvedWrappers.map((resolvedWrapper) => (
14
- ` ${ formatResolvedWrapperForHostedJs(resolvedWrapper) },`
26
+ ` ${ formatResolvedWrapperForHostedJs(resolvedWrapper) },`
15
27
  ));
16
- return `, [\n${ wrapperLines.join('\n') }\n ]`;
28
+
29
+ return `\n${ wrapperLines.join('\n') }\n `;
30
+ };
31
+
32
+ const formatWrappersArg = ({
33
+ resolvedBeforeWrappers = [],
34
+ resolvedAfterWrappers = [],
35
+ } = {}) => {
36
+ if (!resolvedBeforeWrappers.length && !resolvedAfterWrappers.length) {
37
+ return '';
38
+ }
39
+
40
+ const beforeWrappersBlock = formatWrapperList(resolvedBeforeWrappers);
41
+ const afterWrappersBlock = formatWrapperList(resolvedAfterWrappers);
42
+
43
+ return `, {
44
+ beforeWrappers: [${ beforeWrappersBlock }],
45
+ afterWrappers: [${ afterWrappersBlock }],
46
+ }`;
17
47
  };
18
48
 
19
49
  const generateHostedJs = ({
@@ -22,11 +52,20 @@ const generateHostedJs = ({
22
52
  const exportLines = [];
23
53
 
24
54
  for (const hostedHandler of hostedHandlers) {
25
- const { hostedName, handlerName, resolvedWrappers = [], requirePath } = hostedHandler;
26
- const wrappersArg = formatWrappersArg(resolvedWrappers);
55
+ const {
56
+ hostedName,
57
+ handlerName,
58
+ resolvedBeforeWrappers = [],
59
+ resolvedAfterWrappers = [],
60
+ requirePath,
61
+ } = hostedHandler;
62
+ const wrappersArg = formatWrappersArg({
63
+ resolvedBeforeWrappers,
64
+ resolvedAfterWrappers,
65
+ });
27
66
 
28
67
  exportLines.push(
29
- ` ${ hostedName }: wrapHostedFunction(() => require('${ requirePath }'), '${ handlerName }'${ wrappersArg }),`,
68
+ ` ${ hostedName }: wrapHostedFunction(() => require('${ requirePathForHostedJs(requirePath) }'), '${ handlerName }'${ wrappersArg }),`,
30
69
  );
31
70
  }
32
71
 
@@ -50,6 +89,14 @@ const writeHostedJs = ({
50
89
  });
51
90
 
52
91
  fs.mkdirSync(getHostingDir(workspace), { recursive: true });
92
+
93
+ if (fs.existsSync(hostedPath)) {
94
+ const existingContent = fs.readFileSync(hostedPath, 'utf8');
95
+ if (existingContent === content) {
96
+ return hostedPath;
97
+ }
98
+ }
99
+
53
100
  fs.writeFileSync(hostedPath, content);
54
101
  return hostedPath;
55
102
  };
@@ -71,13 +71,16 @@ const getHostedEntries = (functions = {}) => (
71
71
  Object.entries(functions).map(([hostedName, functionConfig = {}]) => ({
72
72
  hostedName,
73
73
  handlerName: functionConfig.entry_point || functionConfig.entryPoint || hostedName,
74
- wrappers: Array.isArray(functionConfig.wrappers) ? functionConfig.wrappers : [],
74
+ beforeWrappers: Array.isArray(functionConfig.before_wrappers) ? functionConfig.before_wrappers : [],
75
+ afterWrappers: Array.isArray(functionConfig.after_wrappers) ? functionConfig.after_wrappers : [],
75
76
  }))
76
77
  );
77
78
 
78
- const functionUsesWrapper = (functionConfig = {}, wrapperName) => (
79
- Array.isArray(functionConfig.wrappers) && functionConfig.wrappers.includes(wrapperName)
80
- );
79
+ const functionUsesWrapper = (functionConfig = {}, wrapperName) => {
80
+ const { before_wrappers = [], after_wrappers = [] } = functionConfig;
81
+
82
+ return before_wrappers.includes(wrapperName) || after_wrappers.includes(wrapperName);
83
+ };
81
84
 
82
85
  const getFuncApiConfig = ({
83
86
  moduleExports,
@@ -100,7 +103,10 @@ const getFuncApiConfig = ({
100
103
  }
101
104
  };
102
105
 
103
- const wrapHostedFunction = (loader, exportName, wrappers = []) => {
106
+ const wrapHostedFunction = (loader, exportName, {
107
+ beforeWrappers = [],
108
+ afterWrappers = [],
109
+ } = {}) => {
104
110
  let handler = null;
105
111
  let usesFuncApi = false;
106
112
 
@@ -129,7 +135,10 @@ const wrapHostedFunction = (loader, exportName, wrappers = []) => {
129
135
  : await handler(...args);
130
136
  };
131
137
 
132
- const wrappedHandler = wrapFunction(coreHandler, wrappers);
138
+ const wrappedHandler = wrapFunction(coreHandler, {
139
+ beforeWrappers,
140
+ afterWrappers,
141
+ });
133
142
 
134
143
  return async (req, res) => {
135
144
  try {
@@ -180,17 +189,6 @@ const readHostingYml = (workspace) => {
180
189
  return hostingConfig;
181
190
  };
182
191
 
183
- const getCredsJsonForDeploy = (workspace) => {
184
- const credsPath = `${ workspace }/.creds.yml`;
185
-
186
- if (!fs.existsSync(credsPath)) {
187
- throw new Error(`Missing .creds.yml in workspace: ${ workspace }`);
188
- }
189
-
190
- const credsText = fs.readFileSync(credsPath, 'utf8');
191
- return JSON.stringify(yaml.parse(credsText));
192
- };
193
-
194
192
  const ensureWorkspaceEnvForDeploy = (workspace) => {
195
193
  const { copyCredsToEnv } = require('./copyCredsToEnv');
196
194
  const envPath = `${ workspace.replace(/\/$/, '') }/.env`;
@@ -211,9 +209,16 @@ const resolveHostedHandlersForDeploy = ({
211
209
  const hostedEntries = getHostedEntries(functions);
212
210
 
213
211
  return hostedEntries.map((hostedEntry) => {
214
- const { handlerName, wrappers = [] } = hostedEntry;
212
+ const {
213
+ handlerName,
214
+ beforeWrappers = [],
215
+ afterWrappers = [],
216
+ } = hostedEntry;
215
217
 
216
- const resolvedWrappers = wrappers.map((wrapperName) => (
218
+ const resolvedBeforeWrappers = beforeWrappers.map((wrapperName) => (
219
+ resolveWrapperName(wrapperName, workspaceRequire)
220
+ ));
221
+ const resolvedAfterWrappers = afterWrappers.map((wrapperName) => (
217
222
  resolveWrapperName(wrapperName, workspaceRequire)
218
223
  ));
219
224
 
@@ -226,7 +231,8 @@ const resolveHostedHandlersForDeploy = ({
226
231
 
227
232
  return {
228
233
  ...hostedEntry,
229
- resolvedWrappers,
234
+ resolvedBeforeWrappers,
235
+ resolvedAfterWrappers,
230
236
  requirePath: getRequirePathForHandler(handler, workspace),
231
237
  };
232
238
  });
@@ -240,7 +246,6 @@ module.exports = {
240
246
  getFuncApiConfig,
241
247
  wrapHostedFunction,
242
248
  readHostingYml,
243
- getCredsJsonForDeploy,
244
249
  ensureWorkspaceEnvForDeploy,
245
250
  getHostedEntries,
246
251
  resolveHostedHandlersForDeploy,
@@ -0,0 +1,160 @@
1
+ const http = require('http');
2
+ const { createRequire } = require('module');
3
+ const { respondJson, errorToReadable } = require('../server.utils');
4
+ const { getWorkspace, setWorkspace, loadWorkspaceEnv, toAbsolutePath } = require('../api/workspace');
5
+ const { getApiDirs, readCliFlag } = require('../cli');
6
+ const { loadHandlers } = require('../server');
7
+ const {
8
+ readHostingYml,
9
+ resolveHostedHandlersForDeploy,
10
+ wrapHostedFunction,
11
+ } = require('./hosting.utils');
12
+ const { copyCredsToEnv } = require('./copyCredsToEnv');
13
+
14
+ const getConfig = (options = {}) => ({
15
+ port: Number(options.port ?? process.env.PORT ?? 8000),
16
+ workspace: toAbsolutePath(
17
+ options.workspace
18
+ ?? readCliFlag('--workspace')
19
+ ?? process.env.MINERAL_WORKSPACE
20
+ ?? process.cwd(),
21
+ ),
22
+ api_dirs: getApiDirs(options),
23
+ });
24
+
25
+ const handlerByRouteName = (handlers) => {
26
+ const byName = new Map();
27
+
28
+ for (const handler of handlers.values()) {
29
+ byName.set(handler.routeName, handler);
30
+ }
31
+
32
+ return byName;
33
+ };
34
+
35
+ const loadHostedRoutes = (config) => {
36
+ const { functions } = readHostingYml(config.workspace);
37
+ const handlers = loadHandlers({
38
+ ...config,
39
+ host_mode: true,
40
+ });
41
+ const handlersByName = handlerByRouteName(handlers);
42
+ const hostedHandlers = resolveHostedHandlersForDeploy({
43
+ functions,
44
+ workspace: config.workspace,
45
+ handlersByName,
46
+ });
47
+
48
+ const workspaceRequire = createRequire(`${ config.workspace.replace(/\/$/, '') }/package.json`);
49
+ const routes = new Map();
50
+
51
+ for (const hostedHandler of hostedHandlers) {
52
+ const {
53
+ hostedName,
54
+ handlerName,
55
+ resolvedBeforeWrappers = [],
56
+ resolvedAfterWrappers = [],
57
+ requirePath,
58
+ } = hostedHandler;
59
+
60
+ const beforeWrappers = resolvedBeforeWrappers.map(({ modulePath, wrapperName }) => (
61
+ workspaceRequire(modulePath)[wrapperName]
62
+ ));
63
+ const afterWrappers = resolvedAfterWrappers.map(({ modulePath, wrapperName }) => (
64
+ workspaceRequire(modulePath)[wrapperName]
65
+ ));
66
+
67
+ routes.set(
68
+ `/${ hostedName }`,
69
+ wrapHostedFunction(
70
+ () => workspaceRequire(requirePath),
71
+ handlerName,
72
+ {
73
+ beforeWrappers,
74
+ afterWrappers,
75
+ },
76
+ ),
77
+ );
78
+ }
79
+
80
+ return routes;
81
+ };
82
+
83
+ const createHostedPreviewServer = (handlers) => http.createServer(async (req, res) => {
84
+ if (req.method === 'GET' && req.url === '/') {
85
+ respondJson(res, 200, {
86
+ ok: true,
87
+ data: {
88
+ mode: 'hosting_preview',
89
+ routes: [...handlers.keys()].sort(),
90
+ workspace: getWorkspace(),
91
+ },
92
+ });
93
+ return;
94
+ }
95
+
96
+ const route = (req.url || '/').split('?')[0];
97
+ const handler = handlers.get(route);
98
+
99
+ if (!handler) {
100
+ respondJson(res, 404, {
101
+ ok: false,
102
+ error: {
103
+ code: 'NOT_FOUND',
104
+ message: `No hosted handler found for route ${ route }`,
105
+ },
106
+ });
107
+ return;
108
+ }
109
+
110
+ try {
111
+ await handler(req, res);
112
+ } catch (error) {
113
+ if (res.headersSent) {
114
+ return;
115
+ }
116
+
117
+ respondJson(res, 500, {
118
+ ok: false,
119
+ error: {
120
+ code: 'UNHANDLED_ERROR',
121
+ message: 'Unhandled server error.',
122
+ details: errorToReadable(error),
123
+ },
124
+ });
125
+ }
126
+ });
127
+
128
+ const startHostingPreview = (options = {}) => {
129
+ process.env.HOSTED = 'true';
130
+
131
+ const config = getConfig(options);
132
+
133
+ setWorkspace(config.workspace);
134
+ copyCredsToEnv(config.workspace);
135
+ loadWorkspaceEnv();
136
+
137
+ const handlers = loadHostedRoutes(config);
138
+ const server = createHostedPreviewServer(handlers);
139
+
140
+ server.listen(config.port, () => {
141
+ console.log(`Hosting preview running on port ${ config.port }`);
142
+ console.log(`Workspace: ${ config.workspace }`);
143
+ console.log('Hosted routes:');
144
+
145
+ for (const route of [...handlers.keys()].sort()) {
146
+ console.log(route);
147
+ }
148
+ });
149
+
150
+ return server;
151
+ };
152
+
153
+ module.exports = {
154
+ startHostingPreview,
155
+ loadHostedRoutes,
156
+ };
157
+
158
+ if (require.main === module) {
159
+ startHostingPreview();
160
+ }
@@ -22,7 +22,7 @@ const allowCrossOriginCallsAndHandleOptions = async (req, res) => {
22
22
 
23
23
  res.setHeader('Access-Control-Allow-Origin', origin || '*');
24
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');
25
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key, x-wf-token, x-wf-value, x-wf-app');
26
26
 
27
27
  if (req.method === 'OPTIONS') {
28
28
  res.writeHead(204);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxtware/mineral",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "bin": {
5
5
  "mineral": "bin/mineral.js"
6
6
  },
@@ -10,7 +10,9 @@
10
10
  },
11
11
  "scripts": {
12
12
  "creds_to_env": "node hosting/copyCredsToEnv.js",
13
- "dev": "node --watch --watch-path=./api --watch-path=./server.js --watch-path=./server.utils.js --watch-path=./.creds.yml server.js",
13
+ "dev": "node --watch --watch-path=./api --watch-path=./server.js --watch-path=./server.utils.js --watch-path=./hosting --watch-path=./.creds.yml server.js",
14
+ "hosting_preview": "node --watch --watch-path=./api --watch-path=./hosting/.hosting.yml --watch-path=./hosting/wrappers.js --watch-path=./.creds.yml hosting/hostingPreview.js",
15
+ "tunnel": "ngrok http http://localhost:8000",
14
16
  "new": "node _build_scripts/createNewFunction.js",
15
17
  "serve": "PORT=8100 node server.js",
16
18
  "start": "node server.js",
@@ -22,5 +24,8 @@
22
24
  "json-2-csv": "^5.5.9",
23
25
  "xml2js": "^0.6.2",
24
26
  "yaml": "^2.9.0"
27
+ },
28
+ "devDependencies": {
29
+ "ngrok": "^5.0.0-beta.2"
25
30
  }
26
31
  }
package/server.js CHANGED
@@ -78,8 +78,6 @@ const directoriesToScan = ({
78
78
  return [MINERAL_API_DIR, ...extraDirs];
79
79
  };
80
80
 
81
- const getFuncApiConfigFromModule = getFuncApiConfig;
82
-
83
81
  const addHandlerFromFile = (filePath, handlers) => {
84
82
  const moduleExports = require(filePath);
85
83
  if (!moduleExports || typeof moduleExports !== 'object') {
@@ -92,7 +90,7 @@ const addHandlerFromFile = (filePath, handlers) => {
92
90
  return;
93
91
  }
94
92
 
95
- const funcApiConfig = getFuncApiConfigFromModule({
93
+ const funcApiConfig = getFuncApiConfig({
96
94
  moduleExports,
97
95
  routeName,
98
96
  });
package/server.utils.js CHANGED
@@ -1,8 +1,9 @@
1
1
  const { logDeep } = require('./api/utils');
2
+ const { HOSTED } = require('./api/constants');
2
3
  const { StringDecoder } = require('string_decoder');
3
4
 
4
5
  const respondJson = (res, statusCode, payload) => {
5
- logDeep(payload);
6
+ !HOSTED && logDeep(payload);
6
7
  const body = JSON.stringify(payload);
7
8
  res.writeHead(statusCode, {
8
9
  'Content-Type': 'application/json',
@@ -120,15 +121,48 @@ const runRequestHandler = async (requestHandler, requestContext) => {
120
121
  return mergeRequestContext(requestContext, handlerOutput);
121
122
  };
122
123
 
123
- const wrapFunction = (func, wrappers = []) => async (req, res, ...rest) => {
124
- for (const wrapper of wrappers) {
125
- const rejected = await wrapper(req, res);
126
- if (rejected) {
127
- return rejected;
124
+ const wrapFunction = (func, {
125
+ beforeWrappers = [],
126
+ afterWrappers = [],
127
+ } = {}) => async (req, res, ...rest) => {
128
+ for (const beforeWrapper of beforeWrappers) {
129
+ const beforeResult = await beforeWrapper(req, res);
130
+ if (beforeResult?.handled) {
131
+ return;
132
+ }
133
+ if (beforeResult) {
134
+ return beforeResult;
135
+ }
136
+ }
137
+
138
+ if (!afterWrappers.length) {
139
+ return func(req, res, ...rest);
140
+ }
141
+
142
+ let result;
143
+
144
+ try {
145
+ result = await func(req, res, ...rest);
146
+ } catch (error) {
147
+ result = {
148
+ ok: false,
149
+ error: {
150
+ code: 'UNHANDLED_ERROR',
151
+ message: 'Unhandled server error.',
152
+ details: errorToReadable(error),
153
+ },
154
+ };
155
+ }
156
+
157
+ for (const afterWrapper of afterWrappers) {
158
+ try {
159
+ await afterWrapper(req, res, result);
160
+ } catch (error) {
161
+ console.log('wrapFunction afterWrapper error', error);
128
162
  }
129
163
  }
130
164
 
131
- return func(req, res, ...rest);
165
+ return result;
132
166
  };
133
167
 
134
168
  const statusCodeFromResult = (result) => {