@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.0
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/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/src/functions.mjs
CHANGED
|
@@ -1,313 +1,313 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
|
-
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { zipSync } from 'fflate';
|
|
6
|
-
|
|
7
|
-
// App functions (`functions:invoke`): functions/<name>.ts (or .js/.mts/.mjs) next to hawk-app.json, declared under
|
|
8
|
-
// "functions" in the manifest, bundled with esbuild into one index.mjs per function and deployed with each version
|
|
9
|
-
// as an AWS Lambda version. Same limits as the platform, which validates again on deploy.
|
|
10
|
-
export const FUNCTIONS_DIR = 'functions';
|
|
11
|
-
export const FUNCTIONS_SCOPE = 'functions:invoke';
|
|
12
|
-
export const BRIDGE_PACKAGE = '@bridgeline-digital/hawk-app-bridge';
|
|
13
|
-
export const MAX_FUNCTIONS = 10;
|
|
14
|
-
export const MAX_NAME_LENGTH = 40;
|
|
15
|
-
export const MAX_SECRETS = 20;
|
|
16
|
-
export const TIMEOUT = { min: 1, max: 25, default: 10 };
|
|
17
|
-
/** Background jobs ("job": true): started with app.functions.start, not called, so they may run longer. */
|
|
18
|
-
export const JOB_TIMEOUT = { min: 1, max: 900, default: 300 };
|
|
19
|
-
export const MEMORY = { min: 128, max: 1024, default: 256 };
|
|
20
|
-
export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
|
|
21
|
-
export const MAX_SOURCE_BYTES = 5 * 1024 * 1024;
|
|
22
|
-
export const ENTRY_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
|
|
23
|
-
|
|
24
|
-
export const FUNCTION_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
|
-
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
26
|
-
const CONFIG_KEYS = ['job', 'timeoutSeconds', 'memoryMb', 'secrets', 'clientSecrets'];
|
|
27
|
-
const MAX_CLIENT_SECRETS = 10;
|
|
28
|
-
/** Fixed zip entry time, so a zip's bytes (and the Lambda codeSha256) only change when the code does. */
|
|
29
|
-
const ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0);
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Checks the manifest's "functions" block (names, settings, the functions:invoke scope).
|
|
33
|
-
* @returns {string[]} errors
|
|
34
|
-
*/
|
|
35
|
-
export function validateFunctionsManifest(manifest) {
|
|
36
|
-
const errors = [];
|
|
37
|
-
const functions = manifest?.functions;
|
|
38
|
-
if (functions === undefined) return errors;
|
|
39
|
-
if (!functions || typeof functions !== 'object' || Array.isArray(functions)) {
|
|
40
|
-
errors.push('functions must be an object: { "<name>": { "timeoutSeconds": 10, "memoryMb": 256, "secrets": [] } }.');
|
|
41
|
-
return errors;
|
|
42
|
-
}
|
|
43
|
-
const names = Object.keys(functions);
|
|
44
|
-
if (names.length > MAX_FUNCTIONS) errors.push(`functions: at most ${MAX_FUNCTIONS} functions per app (found ${names.length}).`);
|
|
45
|
-
for (const name of names) {
|
|
46
|
-
const where = `functions.${name}`;
|
|
47
|
-
if (!FUNCTION_NAME.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
48
|
-
errors.push(`${where}: function names must be 1-${MAX_NAME_LENGTH} lowercase letters, digits and single dashes (e.g. "sync-feed").`);
|
|
49
|
-
}
|
|
50
|
-
const config = functions[name];
|
|
51
|
-
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
52
|
-
errors.push(`${where} must be an object, e.g. { "timeoutSeconds": 10, "memoryMb": 256, "secrets": [] } (or {} for the defaults).`);
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
const unknown = Object.keys(config).filter((key) => !CONFIG_KEYS.includes(key));
|
|
56
|
-
if (unknown.length) errors.push(`${where}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${CONFIG_KEYS.join(', ')}).`);
|
|
57
|
-
if (config.job !== undefined && typeof config.job !== 'boolean') errors.push(`${where}.job must be true or false.`);
|
|
58
|
-
const timeout = config.job === true ? JOB_TIMEOUT : TIMEOUT;
|
|
59
|
-
if (config.timeoutSeconds !== undefined && !isIntIn(config.timeoutSeconds, timeout)) {
|
|
60
|
-
const hint = config.job === true ? '' : `; up to ${JOB_TIMEOUT.max} with "job": true`;
|
|
61
|
-
errors.push(`${where}.timeoutSeconds must be a whole number from ${timeout.min} to ${timeout.max} (default ${timeout.default}${hint}).`);
|
|
62
|
-
}
|
|
63
|
-
if (config.memoryMb !== undefined && !isIntIn(config.memoryMb, MEMORY)) {
|
|
64
|
-
errors.push(`${where}.memoryMb must be a whole number from ${MEMORY.min} to ${MEMORY.max} (default ${MEMORY.default}).`);
|
|
65
|
-
}
|
|
66
|
-
if (config.secrets !== undefined) {
|
|
67
|
-
const secrets = config.secrets;
|
|
68
|
-
if (!Array.isArray(secrets)) {
|
|
69
|
-
errors.push(`${where}.secrets must be a list of names, e.g. ["FEED_API_KEY"].`);
|
|
70
|
-
} else {
|
|
71
|
-
const bad = secrets.filter((s) => typeof s !== 'string' || !SECRET_NAME.test(s));
|
|
72
|
-
if (bad.length) errors.push(`${where}.secrets: ${bad.map((s) => JSON.stringify(s)).join(', ')} must be UPPER_SNAKE_CASE names (a letter, then letters, digits or _; up to 64).`);
|
|
73
|
-
const duplicates = [...new Set(secrets.filter((s, i) => secrets.indexOf(s) !== i))];
|
|
74
|
-
if (duplicates.length) errors.push(`${where}.secrets lists ${duplicates.join(', ')} more than once.`);
|
|
75
|
-
if (secrets.length > MAX_SECRETS) errors.push(`${where}.secrets: at most ${MAX_SECRETS} secrets per function.`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (config.clientSecrets !== undefined) {
|
|
79
|
-
const names = config.clientSecrets;
|
|
80
|
-
if (!Array.isArray(names)) {
|
|
81
|
-
errors.push(`${where}.clientSecrets must be a list of names, e.g. ["MAILCHIMP_API_KEY"].`);
|
|
82
|
-
} else {
|
|
83
|
-
const bad = names.filter((s) => typeof s !== 'string' || !SECRET_NAME.test(s));
|
|
84
|
-
if (bad.length) errors.push(`${where}.clientSecrets: ${bad.map((s) => JSON.stringify(s)).join(', ')} must be UPPER_SNAKE_CASE names (a letter, then letters, digits or _; up to 64).`);
|
|
85
|
-
const duplicates = [...new Set(names.filter((s, i) => names.indexOf(s) !== i))];
|
|
86
|
-
if (duplicates.length) errors.push(`${where}.clientSecrets lists ${duplicates.join(', ')} more than once.`);
|
|
87
|
-
if (names.length > MAX_CLIENT_SECRETS) errors.push(`${where}.clientSecrets: at most ${MAX_CLIENT_SECRETS} client secrets per function.`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (names.length && !(manifest.scopes ?? []).includes(FUNCTIONS_SCOPE)) {
|
|
92
|
-
errors.push(`functions need the "${FUNCTIONS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
93
|
-
}
|
|
94
|
-
return errors;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const isIntIn = (value, { min, max }) => Number.isInteger(value) && value >= min && value <= max;
|
|
98
|
-
|
|
99
|
-
/** A function's settings with the defaults filled in, as sent to the platform. */
|
|
100
|
-
export function functionConfig(config = {}) {
|
|
101
|
-
const job = config.job === true;
|
|
102
|
-
return {
|
|
103
|
-
job,
|
|
104
|
-
timeoutSeconds: config.timeoutSeconds ?? (job ? JOB_TIMEOUT : TIMEOUT).default,
|
|
105
|
-
memoryMb: config.memoryMb ?? MEMORY.default,
|
|
106
|
-
secrets: config.secrets ?? [],
|
|
107
|
-
clientSecrets: config.clientSecrets ?? [],
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Matches the manifest's functions with the entry files in `<projectDir>/functions`. Null when the app has neither.
|
|
113
|
-
* Every declared function needs functions/<name>.(ts|mts|js|mjs), and every entry file there needs a manifest entry
|
|
114
|
-
* (shared code goes in subfolders such as functions/lib/).
|
|
115
|
-
* @returns {Promise<{ functions: { name: string, entry: string, config: object }[], errors: string[] } | null>}
|
|
116
|
-
*/
|
|
117
|
-
export async function loadFunctions(projectDir, manifest) {
|
|
118
|
-
const declared = manifest?.functions && typeof manifest.functions === 'object' && !Array.isArray(manifest.functions) ? manifest.functions : {};
|
|
119
|
-
const dir = join(projectDir, FUNCTIONS_DIR);
|
|
120
|
-
const hasDir = existsSync(dir);
|
|
121
|
-
if (!hasDir && !Object.keys(declared).length) return null;
|
|
122
|
-
|
|
123
|
-
const errors = [];
|
|
124
|
-
const entries = new Map();
|
|
125
|
-
if (hasDir) {
|
|
126
|
-
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
127
|
-
if (!entry.isFile() || entry.name.startsWith('.') || entry.name.endsWith('.d.ts')) continue;
|
|
128
|
-
const ext = ENTRY_EXTENSIONS.find((e) => entry.name.endsWith(e));
|
|
129
|
-
if (!ext) continue;
|
|
130
|
-
const name = entry.name.slice(0, -ext.length);
|
|
131
|
-
if (entries.has(name)) {
|
|
132
|
-
errors.push(`${FUNCTIONS_DIR}/: ${name} has more than one entry file (${entries.get(name).file} and ${entry.name}); keep one.`);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
entries.set(name, { file: entry.name, full: join(dir, entry.name) });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const functions = [];
|
|
140
|
-
for (const name of Object.keys(declared).sort()) {
|
|
141
|
-
const entry = entries.get(name);
|
|
142
|
-
if (!entry) {
|
|
143
|
-
errors.push(`functions.${name} has no entry file: add ${FUNCTIONS_DIR}/${name}.ts (or .js/.mts/.mjs) exporting \`handler\`.`);
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
functions.push({ name, entry: entry.full, config: functionConfig(declared[name]) });
|
|
147
|
-
}
|
|
148
|
-
for (const [name, entry] of entries) {
|
|
149
|
-
if (!(name in declared)) {
|
|
150
|
-
errors.push(`${FUNCTIONS_DIR}/${entry.file} is not declared in hawk-app.json: add "${name}": {} under "functions", or move shared code into a subfolder (e.g. ${FUNCTIONS_DIR}/lib/).`);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (hasDir && !entries.size && !Object.keys(declared).length) {
|
|
154
|
-
errors.push(`${FUNCTIONS_DIR}/ has no functions: add ${FUNCTIONS_DIR}/<name>.ts and declare it under "functions" in hawk-app.json, or remove the folder.`);
|
|
155
|
-
}
|
|
156
|
-
return { functions, errors };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Checks that the app has a bridge with the `/server` entry (the wrapper imports `toLambdaHandler` from it).
|
|
161
|
-
* @returns {string | null} an error message, or null when it's fine
|
|
162
|
-
*/
|
|
163
|
-
export function checkBridge(projectDir) {
|
|
164
|
-
let packageFile;
|
|
165
|
-
try {
|
|
166
|
-
packageFile = createRequire(join(projectDir, 'package.json')).resolve(`${BRIDGE_PACKAGE}/package.json`);
|
|
167
|
-
} catch {
|
|
168
|
-
return `Functions need ${BRIDGE_PACKAGE} in the app: run \`npm install ${BRIDGE_PACKAGE}\`.`;
|
|
169
|
-
}
|
|
170
|
-
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'));
|
|
171
|
-
if (!pkg.exports?.['./server']) {
|
|
172
|
-
return `${BRIDGE_PACKAGE} ${pkg.version} has no "/server" entry: update it (\`npm install ${BRIDGE_PACKAGE}@latest\`).`;
|
|
173
|
-
}
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** The generated entry: the user's module wrapped as the Lambda handler. */
|
|
178
|
-
export function wrapperSource(entry) {
|
|
179
|
-
const userModule = entry.split(sep).join('/');
|
|
180
|
-
return [
|
|
181
|
-
`import { toLambdaHandler } from '${BRIDGE_PACKAGE}/server';`,
|
|
182
|
-
`import { handler as userHandler } from ${JSON.stringify(userModule)};`,
|
|
183
|
-
'export const handler = toLambdaHandler(userHandler);',
|
|
184
|
-
'',
|
|
185
|
-
].join('\n');
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Bundles one function (esbuild: node22, ESM, every dependency included) and packs what the platform needs:
|
|
190
|
-
* `bundle.zip` (index.mjs exporting `handler`), `source.zip` (every file under functions/, for review) and
|
|
191
|
-
* `deps.json` ([{ name, version }] of the packages in the bundle).
|
|
192
|
-
* @returns {Promise<{ name: string, files: { path: string, size: number, data: Uint8Array }[], bundleBytes: number, deps: { name: string, version: string }[] }>}
|
|
193
|
-
*/
|
|
194
|
-
export async function buildFunction({ projectDir, name, entry, sourceZip }) {
|
|
195
|
-
const problem = checkBridge(projectDir);
|
|
196
|
-
if (problem) throw new Error(problem);
|
|
197
|
-
const esbuild = await import('esbuild');
|
|
198
|
-
let result;
|
|
199
|
-
try {
|
|
200
|
-
result = await esbuild.build({
|
|
201
|
-
stdin: { contents: wrapperSource(entry), resolveDir: projectDir, sourcefile: `hawk-function-${name}.mjs`, loader: 'js' },
|
|
202
|
-
absWorkingDir: projectDir,
|
|
203
|
-
bundle: true,
|
|
204
|
-
platform: 'node',
|
|
205
|
-
target: 'node22',
|
|
206
|
-
format: 'esm',
|
|
207
|
-
outfile: 'index.mjs',
|
|
208
|
-
write: false,
|
|
209
|
-
metafile: true,
|
|
210
|
-
logLevel: 'silent',
|
|
211
|
-
legalComments: 'none',
|
|
212
|
-
// CommonJS dependencies that call require() need it in an ES module.
|
|
213
|
-
banner: { js: "import { createRequire as __hawkCreateRequire } from 'node:module';\nconst require = __hawkCreateRequire(import.meta.url);" },
|
|
214
|
-
});
|
|
215
|
-
} catch (error) {
|
|
216
|
-
const messages = (error.errors ?? []).map((e) => ` ${e.location ? `${e.location.file}:${e.location.line}: ` : ''}${e.text}`);
|
|
217
|
-
const missingHandler = (error.errors ?? []).some((e) => /No matching export .* for import "handler"/.test(e.text));
|
|
218
|
-
throw new Error(
|
|
219
|
-
`Could not bundle function "${name}"${missingHandler ? ` (${FUNCTIONS_DIR}/${name} must export \`handler\`)` : ''}:\n${messages.join('\n') || ` ${error.message}`}`,
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
const code = result.outputFiles.find((f) => f.path.endsWith('index.mjs')).contents;
|
|
223
|
-
const bundle = zip({ 'index.mjs': code });
|
|
224
|
-
if (bundle.length > MAX_BUNDLE_BYTES) {
|
|
225
|
-
throw new Error(`Function "${name}" is ${formatMb(bundle.length)} zipped; the limit is ${formatMb(MAX_BUNDLE_BYTES)}. Drop large dependencies.`);
|
|
226
|
-
}
|
|
227
|
-
const deps = dependencies(result.metafile, projectDir);
|
|
228
|
-
const depsJson = new TextEncoder().encode(JSON.stringify(deps, null, 2) + '\n');
|
|
229
|
-
const source = sourceZip ?? (await zipSources(projectDir));
|
|
230
|
-
const prefix = `${FUNCTIONS_DIR}/${name}`;
|
|
231
|
-
return {
|
|
232
|
-
name,
|
|
233
|
-
bundleBytes: bundle.length,
|
|
234
|
-
deps,
|
|
235
|
-
files: [
|
|
236
|
-
{ path: `${prefix}/bundle.zip`, size: bundle.length, data: bundle },
|
|
237
|
-
{ path: `${prefix}/source.zip`, size: source.length, data: source },
|
|
238
|
-
{ path: `${prefix}/deps.json`, size: depsJson.length, data: depsJson },
|
|
239
|
-
],
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Zips every file under functions/ (no node_modules or hidden files), with paths like functions/lib/feed.ts. */
|
|
244
|
-
export async function zipSources(projectDir) {
|
|
245
|
-
const root = join(projectDir, FUNCTIONS_DIR);
|
|
246
|
-
const files = {};
|
|
247
|
-
async function walk(dir) {
|
|
248
|
-
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
249
|
-
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
250
|
-
const full = join(dir, entry.name);
|
|
251
|
-
if (entry.isDirectory()) await walk(full);
|
|
252
|
-
else if (entry.isFile()) files[relative(projectDir, full).split(sep).join('/')] = new Uint8Array(await readFile(full));
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
if (existsSync(root)) await walk(root);
|
|
256
|
-
const zipped = zip(files);
|
|
257
|
-
if (zipped.length > MAX_SOURCE_BYTES) {
|
|
258
|
-
throw new Error(`${FUNCTIONS_DIR}/ is ${formatMb(zipped.length)} zipped; the limit is ${formatMb(MAX_SOURCE_BYTES)}. Keep data files and builds out of it.`);
|
|
259
|
-
}
|
|
260
|
-
return zipped;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** A zip with sorted entries and fixed timestamps: the same files always give the same bytes. */
|
|
264
|
-
export function zip(files) {
|
|
265
|
-
const sorted = {};
|
|
266
|
-
for (const path of Object.keys(files).sort()) sorted[path] = [files[path], { mtime: ZIP_TIME }];
|
|
267
|
-
return zipSync(sorted, { level: 9, mtime: ZIP_TIME });
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** The packages in the bundle, from the esbuild metafile: every input outside the app's own code, by its package.json. */
|
|
271
|
-
export function dependencies(metafile, projectDir) {
|
|
272
|
-
const found = new Map();
|
|
273
|
-
const appModules = join(projectDir, 'node_modules');
|
|
274
|
-
for (const input of Object.keys(metafile.inputs)) {
|
|
275
|
-
// <stdin> is the generated wrapper; "ns:…" inputs come from other esbuild namespaces, not files.
|
|
276
|
-
if (input.startsWith('<') || /^[a-z-]{2,}:/i.test(input)) continue;
|
|
277
|
-
const full = resolve(projectDir, input);
|
|
278
|
-
const inApp = !relative(projectDir, full).startsWith('..') && !full.startsWith(appModules + sep);
|
|
279
|
-
if (inApp) continue;
|
|
280
|
-
const pkg = nearestPackage(dirname(full));
|
|
281
|
-
if (pkg?.name && !found.has(pkg.name)) found.set(pkg.name, pkg.version ?? 'unknown');
|
|
282
|
-
}
|
|
283
|
-
return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => ({ name, version }));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function nearestPackage(dir) {
|
|
287
|
-
for (let current = dir; ; current = dirname(current)) {
|
|
288
|
-
const file = join(current, 'package.json');
|
|
289
|
-
if (existsSync(file)) {
|
|
290
|
-
try {
|
|
291
|
-
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
292
|
-
// Nested package.json files that only set "type" (e.g. dist/esm/package.json) aren't the package.
|
|
293
|
-
if (pkg.name) return pkg;
|
|
294
|
-
} catch {
|
|
295
|
-
// Unreadable: keep looking up.
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (dirname(current) === current) return null;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
export function describeFunctions(count) {
|
|
303
|
-
return `${count} function${count === 1 ? '' : 's'}`;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** "20 s, 512 MB, secrets FEED_API_KEY" ("job, 300 s, …" for a background job) */
|
|
307
|
-
export function describeConfig(config) {
|
|
308
|
-
return `${config.job ? 'job, ' : ''}${config.timeoutSeconds} s, ${config.memoryMb} MB${config.secrets.length ? `, secrets ${config.secrets.join(' ')}` : ''}${config.clientSecrets?.length ? `, client secrets ${config.clientSecrets.join(' ')}` : ''}`;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function formatMb(bytes) {
|
|
312
|
-
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
313
|
-
}
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { zipSync } from 'fflate';
|
|
6
|
+
|
|
7
|
+
// App functions (`functions:invoke`): functions/<name>.ts (or .js/.mts/.mjs) next to hawk-app.json, declared under
|
|
8
|
+
// "functions" in the manifest, bundled with esbuild into one index.mjs per function and deployed with each version
|
|
9
|
+
// as an AWS Lambda version. Same limits as the platform, which validates again on deploy.
|
|
10
|
+
export const FUNCTIONS_DIR = 'functions';
|
|
11
|
+
export const FUNCTIONS_SCOPE = 'functions:invoke';
|
|
12
|
+
export const BRIDGE_PACKAGE = '@bridgeline-digital/hawk-app-bridge';
|
|
13
|
+
export const MAX_FUNCTIONS = 10;
|
|
14
|
+
export const MAX_NAME_LENGTH = 40;
|
|
15
|
+
export const MAX_SECRETS = 20;
|
|
16
|
+
export const TIMEOUT = { min: 1, max: 25, default: 10 };
|
|
17
|
+
/** Background jobs ("job": true): started with app.functions.start, not called, so they may run longer. */
|
|
18
|
+
export const JOB_TIMEOUT = { min: 1, max: 900, default: 300 };
|
|
19
|
+
export const MEMORY = { min: 128, max: 1024, default: 256 };
|
|
20
|
+
export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
|
|
21
|
+
export const MAX_SOURCE_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
export const ENTRY_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
|
|
23
|
+
|
|
24
|
+
export const FUNCTION_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
26
|
+
const CONFIG_KEYS = ['job', 'timeoutSeconds', 'memoryMb', 'secrets', 'clientSecrets'];
|
|
27
|
+
const MAX_CLIENT_SECRETS = 10;
|
|
28
|
+
/** Fixed zip entry time, so a zip's bytes (and the Lambda codeSha256) only change when the code does. */
|
|
29
|
+
const ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks the manifest's "functions" block (names, settings, the functions:invoke scope).
|
|
33
|
+
* @returns {string[]} errors
|
|
34
|
+
*/
|
|
35
|
+
export function validateFunctionsManifest(manifest) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
const functions = manifest?.functions;
|
|
38
|
+
if (functions === undefined) return errors;
|
|
39
|
+
if (!functions || typeof functions !== 'object' || Array.isArray(functions)) {
|
|
40
|
+
errors.push('functions must be an object: { "<name>": { "timeoutSeconds": 10, "memoryMb": 256, "secrets": [] } }.');
|
|
41
|
+
return errors;
|
|
42
|
+
}
|
|
43
|
+
const names = Object.keys(functions);
|
|
44
|
+
if (names.length > MAX_FUNCTIONS) errors.push(`functions: at most ${MAX_FUNCTIONS} functions per app (found ${names.length}).`);
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const where = `functions.${name}`;
|
|
47
|
+
if (!FUNCTION_NAME.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
48
|
+
errors.push(`${where}: function names must be 1-${MAX_NAME_LENGTH} lowercase letters, digits and single dashes (e.g. "sync-feed").`);
|
|
49
|
+
}
|
|
50
|
+
const config = functions[name];
|
|
51
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
52
|
+
errors.push(`${where} must be an object, e.g. { "timeoutSeconds": 10, "memoryMb": 256, "secrets": [] } (or {} for the defaults).`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const unknown = Object.keys(config).filter((key) => !CONFIG_KEYS.includes(key));
|
|
56
|
+
if (unknown.length) errors.push(`${where}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${CONFIG_KEYS.join(', ')}).`);
|
|
57
|
+
if (config.job !== undefined && typeof config.job !== 'boolean') errors.push(`${where}.job must be true or false.`);
|
|
58
|
+
const timeout = config.job === true ? JOB_TIMEOUT : TIMEOUT;
|
|
59
|
+
if (config.timeoutSeconds !== undefined && !isIntIn(config.timeoutSeconds, timeout)) {
|
|
60
|
+
const hint = config.job === true ? '' : `; up to ${JOB_TIMEOUT.max} with "job": true`;
|
|
61
|
+
errors.push(`${where}.timeoutSeconds must be a whole number from ${timeout.min} to ${timeout.max} (default ${timeout.default}${hint}).`);
|
|
62
|
+
}
|
|
63
|
+
if (config.memoryMb !== undefined && !isIntIn(config.memoryMb, MEMORY)) {
|
|
64
|
+
errors.push(`${where}.memoryMb must be a whole number from ${MEMORY.min} to ${MEMORY.max} (default ${MEMORY.default}).`);
|
|
65
|
+
}
|
|
66
|
+
if (config.secrets !== undefined) {
|
|
67
|
+
const secrets = config.secrets;
|
|
68
|
+
if (!Array.isArray(secrets)) {
|
|
69
|
+
errors.push(`${where}.secrets must be a list of names, e.g. ["FEED_API_KEY"].`);
|
|
70
|
+
} else {
|
|
71
|
+
const bad = secrets.filter((s) => typeof s !== 'string' || !SECRET_NAME.test(s));
|
|
72
|
+
if (bad.length) errors.push(`${where}.secrets: ${bad.map((s) => JSON.stringify(s)).join(', ')} must be UPPER_SNAKE_CASE names (a letter, then letters, digits or _; up to 64).`);
|
|
73
|
+
const duplicates = [...new Set(secrets.filter((s, i) => secrets.indexOf(s) !== i))];
|
|
74
|
+
if (duplicates.length) errors.push(`${where}.secrets lists ${duplicates.join(', ')} more than once.`);
|
|
75
|
+
if (secrets.length > MAX_SECRETS) errors.push(`${where}.secrets: at most ${MAX_SECRETS} secrets per function.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (config.clientSecrets !== undefined) {
|
|
79
|
+
const names = config.clientSecrets;
|
|
80
|
+
if (!Array.isArray(names)) {
|
|
81
|
+
errors.push(`${where}.clientSecrets must be a list of names, e.g. ["MAILCHIMP_API_KEY"].`);
|
|
82
|
+
} else {
|
|
83
|
+
const bad = names.filter((s) => typeof s !== 'string' || !SECRET_NAME.test(s));
|
|
84
|
+
if (bad.length) errors.push(`${where}.clientSecrets: ${bad.map((s) => JSON.stringify(s)).join(', ')} must be UPPER_SNAKE_CASE names (a letter, then letters, digits or _; up to 64).`);
|
|
85
|
+
const duplicates = [...new Set(names.filter((s, i) => names.indexOf(s) !== i))];
|
|
86
|
+
if (duplicates.length) errors.push(`${where}.clientSecrets lists ${duplicates.join(', ')} more than once.`);
|
|
87
|
+
if (names.length > MAX_CLIENT_SECRETS) errors.push(`${where}.clientSecrets: at most ${MAX_CLIENT_SECRETS} client secrets per function.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (names.length && !(manifest.scopes ?? []).includes(FUNCTIONS_SCOPE)) {
|
|
92
|
+
errors.push(`functions need the "${FUNCTIONS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
93
|
+
}
|
|
94
|
+
return errors;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isIntIn = (value, { min, max }) => Number.isInteger(value) && value >= min && value <= max;
|
|
98
|
+
|
|
99
|
+
/** A function's settings with the defaults filled in, as sent to the platform. */
|
|
100
|
+
export function functionConfig(config = {}) {
|
|
101
|
+
const job = config.job === true;
|
|
102
|
+
return {
|
|
103
|
+
job,
|
|
104
|
+
timeoutSeconds: config.timeoutSeconds ?? (job ? JOB_TIMEOUT : TIMEOUT).default,
|
|
105
|
+
memoryMb: config.memoryMb ?? MEMORY.default,
|
|
106
|
+
secrets: config.secrets ?? [],
|
|
107
|
+
clientSecrets: config.clientSecrets ?? [],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Matches the manifest's functions with the entry files in `<projectDir>/functions`. Null when the app has neither.
|
|
113
|
+
* Every declared function needs functions/<name>.(ts|mts|js|mjs), and every entry file there needs a manifest entry
|
|
114
|
+
* (shared code goes in subfolders such as functions/lib/).
|
|
115
|
+
* @returns {Promise<{ functions: { name: string, entry: string, config: object }[], errors: string[] } | null>}
|
|
116
|
+
*/
|
|
117
|
+
export async function loadFunctions(projectDir, manifest) {
|
|
118
|
+
const declared = manifest?.functions && typeof manifest.functions === 'object' && !Array.isArray(manifest.functions) ? manifest.functions : {};
|
|
119
|
+
const dir = join(projectDir, FUNCTIONS_DIR);
|
|
120
|
+
const hasDir = existsSync(dir);
|
|
121
|
+
if (!hasDir && !Object.keys(declared).length) return null;
|
|
122
|
+
|
|
123
|
+
const errors = [];
|
|
124
|
+
const entries = new Map();
|
|
125
|
+
if (hasDir) {
|
|
126
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
127
|
+
if (!entry.isFile() || entry.name.startsWith('.') || entry.name.endsWith('.d.ts')) continue;
|
|
128
|
+
const ext = ENTRY_EXTENSIONS.find((e) => entry.name.endsWith(e));
|
|
129
|
+
if (!ext) continue;
|
|
130
|
+
const name = entry.name.slice(0, -ext.length);
|
|
131
|
+
if (entries.has(name)) {
|
|
132
|
+
errors.push(`${FUNCTIONS_DIR}/: ${name} has more than one entry file (${entries.get(name).file} and ${entry.name}); keep one.`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
entries.set(name, { file: entry.name, full: join(dir, entry.name) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const functions = [];
|
|
140
|
+
for (const name of Object.keys(declared).sort()) {
|
|
141
|
+
const entry = entries.get(name);
|
|
142
|
+
if (!entry) {
|
|
143
|
+
errors.push(`functions.${name} has no entry file: add ${FUNCTIONS_DIR}/${name}.ts (or .js/.mts/.mjs) exporting \`handler\`.`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
functions.push({ name, entry: entry.full, config: functionConfig(declared[name]) });
|
|
147
|
+
}
|
|
148
|
+
for (const [name, entry] of entries) {
|
|
149
|
+
if (!(name in declared)) {
|
|
150
|
+
errors.push(`${FUNCTIONS_DIR}/${entry.file} is not declared in hawk-app.json: add "${name}": {} under "functions", or move shared code into a subfolder (e.g. ${FUNCTIONS_DIR}/lib/).`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (hasDir && !entries.size && !Object.keys(declared).length) {
|
|
154
|
+
errors.push(`${FUNCTIONS_DIR}/ has no functions: add ${FUNCTIONS_DIR}/<name>.ts and declare it under "functions" in hawk-app.json, or remove the folder.`);
|
|
155
|
+
}
|
|
156
|
+
return { functions, errors };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Checks that the app has a bridge with the `/server` entry (the wrapper imports `toLambdaHandler` from it).
|
|
161
|
+
* @returns {string | null} an error message, or null when it's fine
|
|
162
|
+
*/
|
|
163
|
+
export function checkBridge(projectDir) {
|
|
164
|
+
let packageFile;
|
|
165
|
+
try {
|
|
166
|
+
packageFile = createRequire(join(projectDir, 'package.json')).resolve(`${BRIDGE_PACKAGE}/package.json`);
|
|
167
|
+
} catch {
|
|
168
|
+
return `Functions need ${BRIDGE_PACKAGE} in the app: run \`npm install ${BRIDGE_PACKAGE}\`.`;
|
|
169
|
+
}
|
|
170
|
+
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'));
|
|
171
|
+
if (!pkg.exports?.['./server']) {
|
|
172
|
+
return `${BRIDGE_PACKAGE} ${pkg.version} has no "/server" entry: update it (\`npm install ${BRIDGE_PACKAGE}@latest\`).`;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The generated entry: the user's module wrapped as the Lambda handler. */
|
|
178
|
+
export function wrapperSource(entry) {
|
|
179
|
+
const userModule = entry.split(sep).join('/');
|
|
180
|
+
return [
|
|
181
|
+
`import { toLambdaHandler } from '${BRIDGE_PACKAGE}/server';`,
|
|
182
|
+
`import { handler as userHandler } from ${JSON.stringify(userModule)};`,
|
|
183
|
+
'export const handler = toLambdaHandler(userHandler);',
|
|
184
|
+
'',
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Bundles one function (esbuild: node22, ESM, every dependency included) and packs what the platform needs:
|
|
190
|
+
* `bundle.zip` (index.mjs exporting `handler`), `source.zip` (every file under functions/, for review) and
|
|
191
|
+
* `deps.json` ([{ name, version }] of the packages in the bundle).
|
|
192
|
+
* @returns {Promise<{ name: string, files: { path: string, size: number, data: Uint8Array }[], bundleBytes: number, deps: { name: string, version: string }[] }>}
|
|
193
|
+
*/
|
|
194
|
+
export async function buildFunction({ projectDir, name, entry, sourceZip }) {
|
|
195
|
+
const problem = checkBridge(projectDir);
|
|
196
|
+
if (problem) throw new Error(problem);
|
|
197
|
+
const esbuild = await import('esbuild');
|
|
198
|
+
let result;
|
|
199
|
+
try {
|
|
200
|
+
result = await esbuild.build({
|
|
201
|
+
stdin: { contents: wrapperSource(entry), resolveDir: projectDir, sourcefile: `hawk-function-${name}.mjs`, loader: 'js' },
|
|
202
|
+
absWorkingDir: projectDir,
|
|
203
|
+
bundle: true,
|
|
204
|
+
platform: 'node',
|
|
205
|
+
target: 'node22',
|
|
206
|
+
format: 'esm',
|
|
207
|
+
outfile: 'index.mjs',
|
|
208
|
+
write: false,
|
|
209
|
+
metafile: true,
|
|
210
|
+
logLevel: 'silent',
|
|
211
|
+
legalComments: 'none',
|
|
212
|
+
// CommonJS dependencies that call require() need it in an ES module.
|
|
213
|
+
banner: { js: "import { createRequire as __hawkCreateRequire } from 'node:module';\nconst require = __hawkCreateRequire(import.meta.url);" },
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const messages = (error.errors ?? []).map((e) => ` ${e.location ? `${e.location.file}:${e.location.line}: ` : ''}${e.text}`);
|
|
217
|
+
const missingHandler = (error.errors ?? []).some((e) => /No matching export .* for import "handler"/.test(e.text));
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Could not bundle function "${name}"${missingHandler ? ` (${FUNCTIONS_DIR}/${name} must export \`handler\`)` : ''}:\n${messages.join('\n') || ` ${error.message}`}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const code = result.outputFiles.find((f) => f.path.endsWith('index.mjs')).contents;
|
|
223
|
+
const bundle = zip({ 'index.mjs': code });
|
|
224
|
+
if (bundle.length > MAX_BUNDLE_BYTES) {
|
|
225
|
+
throw new Error(`Function "${name}" is ${formatMb(bundle.length)} zipped; the limit is ${formatMb(MAX_BUNDLE_BYTES)}. Drop large dependencies.`);
|
|
226
|
+
}
|
|
227
|
+
const deps = dependencies(result.metafile, projectDir);
|
|
228
|
+
const depsJson = new TextEncoder().encode(JSON.stringify(deps, null, 2) + '\n');
|
|
229
|
+
const source = sourceZip ?? (await zipSources(projectDir));
|
|
230
|
+
const prefix = `${FUNCTIONS_DIR}/${name}`;
|
|
231
|
+
return {
|
|
232
|
+
name,
|
|
233
|
+
bundleBytes: bundle.length,
|
|
234
|
+
deps,
|
|
235
|
+
files: [
|
|
236
|
+
{ path: `${prefix}/bundle.zip`, size: bundle.length, data: bundle },
|
|
237
|
+
{ path: `${prefix}/source.zip`, size: source.length, data: source },
|
|
238
|
+
{ path: `${prefix}/deps.json`, size: depsJson.length, data: depsJson },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Zips every file under functions/ (no node_modules or hidden files), with paths like functions/lib/feed.ts. */
|
|
244
|
+
export async function zipSources(projectDir) {
|
|
245
|
+
const root = join(projectDir, FUNCTIONS_DIR);
|
|
246
|
+
const files = {};
|
|
247
|
+
async function walk(dir) {
|
|
248
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
249
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
250
|
+
const full = join(dir, entry.name);
|
|
251
|
+
if (entry.isDirectory()) await walk(full);
|
|
252
|
+
else if (entry.isFile()) files[relative(projectDir, full).split(sep).join('/')] = new Uint8Array(await readFile(full));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (existsSync(root)) await walk(root);
|
|
256
|
+
const zipped = zip(files);
|
|
257
|
+
if (zipped.length > MAX_SOURCE_BYTES) {
|
|
258
|
+
throw new Error(`${FUNCTIONS_DIR}/ is ${formatMb(zipped.length)} zipped; the limit is ${formatMb(MAX_SOURCE_BYTES)}. Keep data files and builds out of it.`);
|
|
259
|
+
}
|
|
260
|
+
return zipped;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A zip with sorted entries and fixed timestamps: the same files always give the same bytes. */
|
|
264
|
+
export function zip(files) {
|
|
265
|
+
const sorted = {};
|
|
266
|
+
for (const path of Object.keys(files).sort()) sorted[path] = [files[path], { mtime: ZIP_TIME }];
|
|
267
|
+
return zipSync(sorted, { level: 9, mtime: ZIP_TIME });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The packages in the bundle, from the esbuild metafile: every input outside the app's own code, by its package.json. */
|
|
271
|
+
export function dependencies(metafile, projectDir) {
|
|
272
|
+
const found = new Map();
|
|
273
|
+
const appModules = join(projectDir, 'node_modules');
|
|
274
|
+
for (const input of Object.keys(metafile.inputs)) {
|
|
275
|
+
// <stdin> is the generated wrapper; "ns:…" inputs come from other esbuild namespaces, not files.
|
|
276
|
+
if (input.startsWith('<') || /^[a-z-]{2,}:/i.test(input)) continue;
|
|
277
|
+
const full = resolve(projectDir, input);
|
|
278
|
+
const inApp = !relative(projectDir, full).startsWith('..') && !full.startsWith(appModules + sep);
|
|
279
|
+
if (inApp) continue;
|
|
280
|
+
const pkg = nearestPackage(dirname(full));
|
|
281
|
+
if (pkg?.name && !found.has(pkg.name)) found.set(pkg.name, pkg.version ?? 'unknown');
|
|
282
|
+
}
|
|
283
|
+
return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => ({ name, version }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function nearestPackage(dir) {
|
|
287
|
+
for (let current = dir; ; current = dirname(current)) {
|
|
288
|
+
const file = join(current, 'package.json');
|
|
289
|
+
if (existsSync(file)) {
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
292
|
+
// Nested package.json files that only set "type" (e.g. dist/esm/package.json) aren't the package.
|
|
293
|
+
if (pkg.name) return pkg;
|
|
294
|
+
} catch {
|
|
295
|
+
// Unreadable: keep looking up.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (dirname(current) === current) return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function describeFunctions(count) {
|
|
303
|
+
return `${count} function${count === 1 ? '' : 's'}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** "20 s, 512 MB, secrets FEED_API_KEY" ("job, 300 s, …" for a background job) */
|
|
307
|
+
export function describeConfig(config) {
|
|
308
|
+
return `${config.job ? 'job, ' : ''}${config.timeoutSeconds} s, ${config.memoryMb} MB${config.secrets.length ? `, secrets ${config.secrets.join(' ')}` : ''}${config.clientSecrets?.length ? `, client secrets ${config.clientSecrets.join(' ')}` : ''}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function formatMb(bytes) {
|
|
312
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
313
|
+
}
|