@alzulejos/laranja-scanner 0.2.4
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/dist/ast-utils.d.ts +34 -0
- package/dist/ast-utils.js +112 -0
- package/dist/ast-utils.js.map +1 -0
- package/dist/detect.d.ts +3 -0
- package/dist/detect.js +14 -0
- package/dist/detect.js.map +1 -0
- package/dist/dev-scan.d.ts +1 -0
- package/dist/dev-scan.js +19 -0
- package/dist/dev-scan.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/nest-routes.d.ts +9 -0
- package/dist/nest-routes.js +67 -0
- package/dist/nest-routes.js.map +1 -0
- package/dist/resource-types.d.ts +24 -0
- package/dist/resource-types.js +105 -0
- package/dist/resource-types.js.map +1 -0
- package/dist/scan.d.ts +12 -0
- package/dist/scan.js +799 -0
- package/dist/scan.js.map +1 -0
- package/package.json +24 -0
package/dist/scan.js
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { Project, Node } from "ts-morph";
|
|
4
|
+
import { assertSchedule, ENV_NAME_PATTERN, intervalToSchedule, isValidEnvName, } from "@alzulejos/laranja-core";
|
|
5
|
+
import { getPropertyInitializer, literalValue, readDecoratorArg, resolveScheduleNode } from "./ast-utils.js";
|
|
6
|
+
import { detectFramework } from "./detect.js";
|
|
7
|
+
import { collectNestRoutes } from "./nest-routes.js";
|
|
8
|
+
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "all", "options", "head"]);
|
|
9
|
+
/**
|
|
10
|
+
* Statically scans the user's project and produces the Infra IR.
|
|
11
|
+
* No user code is executed — this is pure AST analysis.
|
|
12
|
+
*/
|
|
13
|
+
export function scan({ projectDir, config }) {
|
|
14
|
+
const framework = config.framework ?? detectFramework(projectDir);
|
|
15
|
+
const project = new Project({
|
|
16
|
+
// Skip type-checking deps: we only need syntax, so the user's node_modules
|
|
17
|
+
// don't have to be installed for a scan to work. allowJs lets plain JS apps
|
|
18
|
+
// use the same code-first markers (cron/queue/http) and route discovery.
|
|
19
|
+
skipAddingFilesFromTsConfig: true,
|
|
20
|
+
compilerOptions: { allowJs: true },
|
|
21
|
+
});
|
|
22
|
+
// TS and JS, every module flavour. Skip the obvious non-source trees so the
|
|
23
|
+
// root `**` fallback doesn't drag in node_modules / our own generated entries.
|
|
24
|
+
const ext = "{ts,tsx,mts,cts,js,jsx,mjs,cjs}";
|
|
25
|
+
const ignore = [
|
|
26
|
+
`!${path.join(projectDir, "**/node_modules/**")}`,
|
|
27
|
+
`!${path.join(projectDir, "**/.laranja/**")}`,
|
|
28
|
+
`!${path.join(projectDir, "**/dist/**")}`,
|
|
29
|
+
`!${path.join(projectDir, "**/cdk.out/**")}`,
|
|
30
|
+
];
|
|
31
|
+
project.addSourceFilesAtPaths([
|
|
32
|
+
path.join(projectDir, `src/**/*.${ext}`),
|
|
33
|
+
...ignore,
|
|
34
|
+
]);
|
|
35
|
+
if (project.getSourceFiles().length === 0) {
|
|
36
|
+
project.addSourceFilesAtPaths([
|
|
37
|
+
path.join(projectDir, `**/*.${ext}`),
|
|
38
|
+
...ignore,
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
const crons = [];
|
|
42
|
+
const queues = [];
|
|
43
|
+
const routes = [];
|
|
44
|
+
const httpMarkers = [];
|
|
45
|
+
const workerMarkers = [];
|
|
46
|
+
const envKeys = new Set();
|
|
47
|
+
// Project-wide index of class name -> declarations, used to walk a workers()
|
|
48
|
+
// module's DI provider graph when disambiguating multiple roots.
|
|
49
|
+
const classIndex = new Map();
|
|
50
|
+
for (const sf of project.getSourceFiles()) {
|
|
51
|
+
const rel = path.relative(projectDir, sf.getFilePath());
|
|
52
|
+
if (rel.includes("node_modules"))
|
|
53
|
+
continue;
|
|
54
|
+
for (const cls of sf.getClasses()) {
|
|
55
|
+
const clsName = cls.getName();
|
|
56
|
+
if (clsName) {
|
|
57
|
+
const list = classIndex.get(clsName) ?? [];
|
|
58
|
+
list.push(cls);
|
|
59
|
+
classIndex.set(clsName, list);
|
|
60
|
+
}
|
|
61
|
+
for (const method of cls.getMethods()) {
|
|
62
|
+
collectFromMethod(rel, cls, method, crons, queues);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const regImports = registrationImports(sf);
|
|
66
|
+
if (regImports.size > 0) {
|
|
67
|
+
collectFromRegistrations(rel, sf, regImports, crons, queues);
|
|
68
|
+
}
|
|
69
|
+
collectEnvKeys(rel, sf, envKeys);
|
|
70
|
+
if (framework === "express") {
|
|
71
|
+
collectExpressRoutes(rel, sf, routes);
|
|
72
|
+
}
|
|
73
|
+
else if (framework === "nest") {
|
|
74
|
+
collectNestRoutes(rel, sf, routes);
|
|
75
|
+
}
|
|
76
|
+
collectCallMarkers(rel, sf, "http", httpMarkers);
|
|
77
|
+
collectCallMarkers(rel, sf, "workers", workerMarkers);
|
|
78
|
+
}
|
|
79
|
+
// The HTTP app is declared solely by the code `http()` marker. One marker → an
|
|
80
|
+
// HTTP app; no marker → workers-only. There's no config flag either way.
|
|
81
|
+
let http;
|
|
82
|
+
if (httpMarkers.length > 1) {
|
|
83
|
+
throw new Error(`Found ${httpMarkers.length} http() markers (${httpMarkers
|
|
84
|
+
.map((m) => m.source)
|
|
85
|
+
.join(", ")}). There can be only one HTTP app per project.`);
|
|
86
|
+
}
|
|
87
|
+
const marker = httpMarkers[0];
|
|
88
|
+
if (marker) {
|
|
89
|
+
http = { handlerEntry: marker.file, appExport: marker.appExport, routes };
|
|
90
|
+
}
|
|
91
|
+
if (http === undefined && crons.length === 0 && queues.length === 0) {
|
|
92
|
+
throw new Error(`Nothing to deploy: no HTTP app (wrap and export your app with http(app)) ` +
|
|
93
|
+
`and no @Cron/@Queue or cron()/queue() handlers were found.`);
|
|
94
|
+
}
|
|
95
|
+
// Each workers() marker names a Nest module we build a standalone DI context from,
|
|
96
|
+
// so class-based (@Cron/@Queue on a provider) workers resolve through real DI
|
|
97
|
+
// instead of `new`. A project may declare several disjoint roots; every method-style
|
|
98
|
+
// handler is bound to exactly one via `workersId`.
|
|
99
|
+
const workers = assignWorkerRoots(workerMarkers, crons, queues, classIndex);
|
|
100
|
+
// Nest resolves method-style workers through DI, which needs a module. (Plain
|
|
101
|
+
// function-style cron()/queue() handlers don't — they're standalone functions.)
|
|
102
|
+
if (framework === "nest" && !workers) {
|
|
103
|
+
const needsDi = [...crons, ...queues].find((h) => h.style === "method");
|
|
104
|
+
if (needsDi) {
|
|
105
|
+
throw new Error(`${needsDi.source}: a Nest @Cron/@Queue on a class needs its dependency-injection graph. ` +
|
|
106
|
+
`Export it once with workers(AppModule), e.g. \`export default workers(AppModule)\`.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Validate `resources` keys against the resources we actually found, then merge
|
|
110
|
+
// global `compute` defaults with each per-resource override and attach the
|
|
111
|
+
// result to the IR. An unknown key is a typo — fail loudly rather than no-op.
|
|
112
|
+
// Resource keys: http -> "http", cron -> its id, queue -> its NAME (what the
|
|
113
|
+
// user wrote in @Queue/queue() — the natural handle, and what a DLQ references).
|
|
114
|
+
// They share one namespace, so reject collisions before anything references them.
|
|
115
|
+
assertUniqueResourceKeys(http, crons, queues, workers);
|
|
116
|
+
validateResourceKeys(config, http, crons, queues, workers);
|
|
117
|
+
const queueNames = new Set(queues.map((q) => q.name));
|
|
118
|
+
if (http) {
|
|
119
|
+
http.compute = resolveCompute(config, "http");
|
|
120
|
+
rejectForeignKeys("http", "http app", config.resources?.["http"], COMPUTE_KEYS);
|
|
121
|
+
}
|
|
122
|
+
// A worker module is ONE Lambda: compute lives on the module key, shared by every
|
|
123
|
+
// handler it hosts (see WorkersIR). Resolve it once per module.
|
|
124
|
+
for (const w of workers ?? []) {
|
|
125
|
+
w.compute = resolveCompute(config, w.id);
|
|
126
|
+
rejectForeignKeys(w.id, "worker module", config.resources?.[w.id], COMPUTE_KEYS);
|
|
127
|
+
}
|
|
128
|
+
const computeById = new Map((workers ?? []).map((w) => [w.id, w.compute]));
|
|
129
|
+
for (const c of crons) {
|
|
130
|
+
// Grouped (method-style, in a worker module): its function's compute is the
|
|
131
|
+
// module's — never per-cron. Standalone crons keep their own compute.
|
|
132
|
+
if (c.workersId) {
|
|
133
|
+
applyCronConfig(c, config.resources?.[c.id], queueNames, true);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
c.compute = resolveCompute(config, c.id);
|
|
137
|
+
applyCronConfig(c, config.resources?.[c.id], queueNames, false);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const q of queues) {
|
|
141
|
+
// Effective consumer timeout for the visibility-timeout floor: the worker
|
|
142
|
+
// function's timeout when grouped, else this queue's own.
|
|
143
|
+
const workerTimeout = q.workersId ? computeById.get(q.workersId)?.timeout : undefined;
|
|
144
|
+
if (q.workersId) {
|
|
145
|
+
applyQueueConfig(q, config.resources?.[q.name], queueNames, true, workerTimeout);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
q.compute = resolveCompute(config, q.name);
|
|
149
|
+
applyQueueConfig(q, config.resources?.[q.name], queueNames, false, q.compute?.timeout);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const stage = config.stage ?? "dev";
|
|
153
|
+
const provider = config.provider ?? "aws";
|
|
154
|
+
const monitoring = config.monitoring ?? true;
|
|
155
|
+
return {
|
|
156
|
+
app: { name: config.name, framework, provider, stage, monitoring, entry: http?.handlerEntry },
|
|
157
|
+
http,
|
|
158
|
+
workers,
|
|
159
|
+
crons,
|
|
160
|
+
queues,
|
|
161
|
+
// STAGE is always available at runtime, overridable via explicit env.
|
|
162
|
+
env: { STAGE: stage, ...config.env },
|
|
163
|
+
// Names only — values are resolved client-side at deploy time.
|
|
164
|
+
envKeys: [...envKeys].sort(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function loc(rel, node) {
|
|
168
|
+
return `${rel}:${node.getStartLineNumber()}`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Pick only the compute fields from a config block, dropping `undefined` so they
|
|
172
|
+
* never clobber a lower-precedence value during the merge. Done by explicit field
|
|
173
|
+
* list (not a blind spread) so future resource-specific keys in `ResourceConfig`
|
|
174
|
+
* don't leak into the compute IR.
|
|
175
|
+
*/
|
|
176
|
+
function pickCompute(src) {
|
|
177
|
+
const out = {};
|
|
178
|
+
if (!src)
|
|
179
|
+
return out;
|
|
180
|
+
if (src.memory !== undefined)
|
|
181
|
+
out.memory = src.memory;
|
|
182
|
+
if (src.timeout !== undefined)
|
|
183
|
+
out.timeout = src.timeout;
|
|
184
|
+
if (src.maxConcurrency !== undefined)
|
|
185
|
+
out.maxConcurrency = src.maxConcurrency;
|
|
186
|
+
if (src.architecture !== undefined)
|
|
187
|
+
out.architecture = src.architecture;
|
|
188
|
+
if (src.logRetention !== undefined)
|
|
189
|
+
out.logRetention = src.logRetention;
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
/** Merge global `compute` defaults with a resource's override; override wins per field. */
|
|
193
|
+
function resolveCompute(config, id) {
|
|
194
|
+
const merged = { ...pickCompute(config.compute), ...pickCompute(config.resources?.[id]) };
|
|
195
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
196
|
+
}
|
|
197
|
+
/** Every `resources` key must name a real resource — a typo'd id is a hard error. */
|
|
198
|
+
function validateResourceKeys(config, http, crons, queues, workers) {
|
|
199
|
+
if (!config.resources)
|
|
200
|
+
return;
|
|
201
|
+
const validIds = new Set([
|
|
202
|
+
...(http ? ["http"] : []),
|
|
203
|
+
...crons.map((c) => c.id),
|
|
204
|
+
...queues.map((q) => q.name),
|
|
205
|
+
...(workers ?? []).map((w) => w.id),
|
|
206
|
+
]);
|
|
207
|
+
for (const key of Object.keys(config.resources)) {
|
|
208
|
+
if (!validIds.has(key)) {
|
|
209
|
+
const known = [...validIds].sort().join(", ") || "(none)";
|
|
210
|
+
throw new Error(`laranja.config.ts: resources["${key}"] doesn't match any resource. Known ids: ${known}.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* http ("http"), crons (their id), and queues (their name) all live in one
|
|
216
|
+
* resource-key namespace — used by `resources` overrides and DLQ references. A
|
|
217
|
+
* collision (two queues named the same, or a queue name equal to a cron id) would
|
|
218
|
+
* make a key ambiguous, so reject it early with a clear message rather than let
|
|
219
|
+
* CloudFormation fail on a duplicate physical name later.
|
|
220
|
+
*/
|
|
221
|
+
function assertUniqueResourceKeys(http, crons, queues, workers) {
|
|
222
|
+
const seen = new Map();
|
|
223
|
+
const claim = (key, what) => {
|
|
224
|
+
const prev = seen.get(key);
|
|
225
|
+
if (prev) {
|
|
226
|
+
throw new Error(`laranja.config.ts: resource id "${key}" is used by both ${prev} and ${what} — ids must be unique.`);
|
|
227
|
+
}
|
|
228
|
+
seen.set(key, what);
|
|
229
|
+
};
|
|
230
|
+
if (http)
|
|
231
|
+
claim("http", "the HTTP app");
|
|
232
|
+
for (const c of crons)
|
|
233
|
+
claim(c.id, `cron "${c.id}"`);
|
|
234
|
+
for (const q of queues)
|
|
235
|
+
claim(q.name, `queue "${q.name}"`);
|
|
236
|
+
for (const w of workers ?? [])
|
|
237
|
+
claim(w.id, `worker module "${w.id}"`);
|
|
238
|
+
}
|
|
239
|
+
/** Default consumer timeout (seconds); mirrors the back-half so validation matches. */
|
|
240
|
+
const DEFAULT_CONSUMER_TIMEOUT = 30;
|
|
241
|
+
const COMPUTE_KEYS = new Set(["memory", "timeout", "maxConcurrency", "architecture", "logRetention"]);
|
|
242
|
+
const QUEUE_KEYS = new Set([
|
|
243
|
+
"contentBasedDedup",
|
|
244
|
+
"visibilityTimeout",
|
|
245
|
+
"maxBatchingWindow",
|
|
246
|
+
"reportBatchItemFailures",
|
|
247
|
+
"messageRetention",
|
|
248
|
+
"dlq",
|
|
249
|
+
]);
|
|
250
|
+
const CRON_KEYS = new Set(["timezone", "retryAttempts", "maxEventAge", "dlq"]);
|
|
251
|
+
/** Reject any override key that doesn't apply to this resource's kind. */
|
|
252
|
+
function rejectForeignKeys(id, kind, override, ...allowed) {
|
|
253
|
+
if (!override)
|
|
254
|
+
return;
|
|
255
|
+
for (const key of Object.keys(override)) {
|
|
256
|
+
if (!allowed.some((set) => set.has(key))) {
|
|
257
|
+
throw new Error(`laranja.config.ts: resources["${id}"].${key} is not valid for a ${kind}.`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Reject compute knobs on a GROUPED handler's key — its Lambda is the worker
|
|
263
|
+
* module, so compute belongs on `resources[<module>]`. A clear migration error
|
|
264
|
+
* beats a silently-ignored `memory` on a queue that no longer owns a function.
|
|
265
|
+
*/
|
|
266
|
+
function rejectComputeOnGrouped(id, workersId, override) {
|
|
267
|
+
const stray = Object.keys(override).find((k) => COMPUTE_KEYS.has(k));
|
|
268
|
+
if (stray) {
|
|
269
|
+
throw new Error(`laranja.config.ts: resources["${id}"].${stray} is a per-worker setting now — ` +
|
|
270
|
+
`${id} shares the "${workersId}" Lambda. Move ${stray} to resources["${workersId}"].`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Apply a cron's per-resource override (timezone, async retry, DLQ) onto its IR. */
|
|
274
|
+
function applyCronConfig(c, override, queueNames, grouped) {
|
|
275
|
+
if (!override)
|
|
276
|
+
return;
|
|
277
|
+
if (grouped && c.workersId) {
|
|
278
|
+
rejectComputeOnGrouped(c.id, c.workersId, override); // compute → migration error
|
|
279
|
+
rejectForeignKeys(c.id, "cron", override, CRON_KEYS); // then only trigger knobs allowed
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
rejectForeignKeys(c.id, "cron", override, COMPUTE_KEYS, CRON_KEYS);
|
|
283
|
+
}
|
|
284
|
+
if (override.timezone !== undefined)
|
|
285
|
+
c.timezone = override.timezone;
|
|
286
|
+
if (override.retryAttempts !== undefined) {
|
|
287
|
+
if (override.retryAttempts < 0 || override.retryAttempts > 2) {
|
|
288
|
+
throw new Error(`laranja.config.ts: resources["${c.id}"].retryAttempts must be between 0 and 2.`);
|
|
289
|
+
}
|
|
290
|
+
c.retryAttempts = override.retryAttempts;
|
|
291
|
+
}
|
|
292
|
+
if (override.maxEventAge !== undefined)
|
|
293
|
+
c.maxEventAge = override.maxEventAge;
|
|
294
|
+
if (override.dlq) {
|
|
295
|
+
assertDlqTarget(c.id, override.dlq.queue, queueNames);
|
|
296
|
+
c.dlq = { queue: override.dlq.queue };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Apply a queue's per-resource override (SQS + event-source knobs, DLQ) onto its IR.
|
|
301
|
+
* `consumerTimeout` is the effective consumer‑function timeout — the worker
|
|
302
|
+
* module's when grouped, else this queue's own — used as the visibility floor.
|
|
303
|
+
*/
|
|
304
|
+
function applyQueueConfig(q, override, queueNames, grouped, consumerTimeout) {
|
|
305
|
+
if (!override)
|
|
306
|
+
return;
|
|
307
|
+
if (grouped && q.workersId) {
|
|
308
|
+
rejectComputeOnGrouped(q.name, q.workersId, override); // compute → migration error
|
|
309
|
+
rejectForeignKeys(q.name, "queue", override, QUEUE_KEYS); // then only trigger knobs allowed
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
rejectForeignKeys(q.name, "queue", override, COMPUTE_KEYS, QUEUE_KEYS);
|
|
313
|
+
}
|
|
314
|
+
if (override.contentBasedDedup !== undefined) {
|
|
315
|
+
if (!q.fifo) {
|
|
316
|
+
throw new Error(`laranja.config.ts: resources["${q.name}"].contentBasedDedup is FIFO-only.`);
|
|
317
|
+
}
|
|
318
|
+
q.contentBasedDedup = override.contentBasedDedup;
|
|
319
|
+
}
|
|
320
|
+
if (override.maxBatchingWindow !== undefined)
|
|
321
|
+
q.maxBatchingWindow = override.maxBatchingWindow;
|
|
322
|
+
if (override.reportBatchItemFailures !== undefined)
|
|
323
|
+
q.reportBatchItemFailures = override.reportBatchItemFailures;
|
|
324
|
+
if (override.messageRetention !== undefined)
|
|
325
|
+
q.messageRetention = override.messageRetention;
|
|
326
|
+
if (override.visibilityTimeout !== undefined) {
|
|
327
|
+
const timeout = consumerTimeout ?? DEFAULT_CONSUMER_TIMEOUT;
|
|
328
|
+
if (override.visibilityTimeout < timeout) {
|
|
329
|
+
throw new Error(`laranja.config.ts: resources["${q.name}"].visibilityTimeout (${override.visibilityTimeout}s) ` +
|
|
330
|
+
`must be >= the consumer timeout (${timeout}s).`);
|
|
331
|
+
}
|
|
332
|
+
q.visibilityTimeout = override.visibilityTimeout;
|
|
333
|
+
}
|
|
334
|
+
if (override.dlq) {
|
|
335
|
+
if (override.dlq.maxReceiveCount === undefined) {
|
|
336
|
+
throw new Error(`laranja.config.ts: resources["${q.name}"].dlq requires maxReceiveCount.`);
|
|
337
|
+
}
|
|
338
|
+
assertDlqTarget(q.name, override.dlq.queue, queueNames);
|
|
339
|
+
q.dlq = { maxReceiveCount: override.dlq.maxReceiveCount, queue: override.dlq.queue };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** A DLQ target must be another declared queue (by name) — never missing, never itself. */
|
|
343
|
+
function assertDlqTarget(key, target, queueNames) {
|
|
344
|
+
if (target === key) {
|
|
345
|
+
throw new Error(`laranja.config.ts: resources["${key}"].dlq.queue cannot be the queue itself.`);
|
|
346
|
+
}
|
|
347
|
+
if (!queueNames.has(target)) {
|
|
348
|
+
const known = [...queueNames].sort().join(", ") || "(none)";
|
|
349
|
+
throw new Error(`laranja.config.ts: resources["${key}"].dlq.queue "${target}" is not a declared queue. Queues: ${known}.`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Resolve a queue's physical name + fifo flag, enforcing AWS's ".fifo" suffix rule.
|
|
354
|
+
* A `.fifo` suffix or `fifo: true` marks a FIFO queue; when `fifo: true` is set but
|
|
355
|
+
* the name lacks the suffix, append it so the deploy doesn't fail with CDK's
|
|
356
|
+
* cryptic `FifoQueueNames` error. The normalized name surfaces in `plan`.
|
|
357
|
+
*/
|
|
358
|
+
function resolveQueueName(rawName, fifoOpt) {
|
|
359
|
+
const fifo = fifoOpt === true || rawName.endsWith(".fifo");
|
|
360
|
+
const name = fifo && !rawName.endsWith(".fifo") ? `${rawName}.fifo` : rawName;
|
|
361
|
+
return { name, fifo };
|
|
362
|
+
}
|
|
363
|
+
function collectFromMethod(rel, cls, method, crons, queues) {
|
|
364
|
+
const className = cls.getName() ?? "(anonymous)";
|
|
365
|
+
const methodName = method.getName();
|
|
366
|
+
for (const dec of method.getDecorators()) {
|
|
367
|
+
const name = dec.getName();
|
|
368
|
+
if (name === "Cron") {
|
|
369
|
+
const where = loc(rel, dec);
|
|
370
|
+
const args = dec.getArguments();
|
|
371
|
+
const argNode = args[0];
|
|
372
|
+
// One decorator name, two call shapes:
|
|
373
|
+
// laranja: @Cron(<schedule>) @Cron({ schedule, id })
|
|
374
|
+
// @nestjs/schedule: @Cron(<expr>, { name, timeZone }) (expr = string | CronExpression)
|
|
375
|
+
let scheduleNode = argNode;
|
|
376
|
+
let explicitId;
|
|
377
|
+
let timezone;
|
|
378
|
+
if (argNode && Node.isObjectLiteralExpression(argNode)) {
|
|
379
|
+
scheduleNode = getPropertyInitializer(argNode, "schedule");
|
|
380
|
+
const idInit = getPropertyInitializer(argNode, "id");
|
|
381
|
+
explicitId = idInit && Node.isStringLiteral(idInit) ? idInit.getLiteralText() : undefined;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// Nest's options object is the SECOND argument: name -> id, timeZone -> timezone.
|
|
385
|
+
const optNode = args[1];
|
|
386
|
+
if (optNode && Node.isObjectLiteralExpression(optNode)) {
|
|
387
|
+
const nameInit = getPropertyInitializer(optNode, "name");
|
|
388
|
+
if (nameInit && Node.isStringLiteral(nameInit))
|
|
389
|
+
explicitId = nameInit.getLiteralText();
|
|
390
|
+
const tzInit = getPropertyInitializer(optNode, "timeZone");
|
|
391
|
+
if (tzInit && Node.isStringLiteral(tzInit))
|
|
392
|
+
timezone = tzInit.getLiteralText();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const schedule = resolveScheduleNode(scheduleNode, where);
|
|
396
|
+
if (!schedule) {
|
|
397
|
+
throw new Error(`@Cron at ${where}: could not resolve a valid static schedule. ` +
|
|
398
|
+
`Use rate(n, unit), every(unit), a "rate(...)"/"cron(...)" string, ` +
|
|
399
|
+
`a node-cron expression, or CronExpression.* with literal arguments.`);
|
|
400
|
+
}
|
|
401
|
+
assertSchedule(schedule, where);
|
|
402
|
+
crons.push({
|
|
403
|
+
style: "method",
|
|
404
|
+
id: explicitId ?? `${className}-${methodName}`,
|
|
405
|
+
schedule,
|
|
406
|
+
...(timezone ? { timezone } : {}),
|
|
407
|
+
file: rel,
|
|
408
|
+
className,
|
|
409
|
+
method: methodName,
|
|
410
|
+
source: loc(rel, method),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (name === "Interval") {
|
|
414
|
+
const where = loc(rel, dec);
|
|
415
|
+
const args = dec.getArguments();
|
|
416
|
+
// @Interval(ms) or @Interval("name", ms) — Nest's signature.
|
|
417
|
+
const hasName = args.length >= 2;
|
|
418
|
+
const explicitId = hasName && Node.isStringLiteral(args[0]) ? args[0].getLiteralText() : undefined;
|
|
419
|
+
const ms = literalValue(hasName ? args[1] : args[0]);
|
|
420
|
+
if (typeof ms !== "number") {
|
|
421
|
+
throw new Error(`@Interval at ${where}: the interval must be a numeric millisecond literal.`);
|
|
422
|
+
}
|
|
423
|
+
crons.push({
|
|
424
|
+
style: "method",
|
|
425
|
+
id: explicitId ?? `${className}-${methodName}`,
|
|
426
|
+
schedule: intervalToSchedule(ms, where),
|
|
427
|
+
file: rel,
|
|
428
|
+
className,
|
|
429
|
+
method: methodName,
|
|
430
|
+
source: loc(rel, method),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (name === "Timeout") {
|
|
434
|
+
throw new Error(`@Timeout at ${loc(rel, dec)}: one-shot @Timeout jobs have no serverless equivalent ` +
|
|
435
|
+
`(they fire once relative to a process start that doesn't exist on Lambda). Use @Cron or @Interval.`);
|
|
436
|
+
}
|
|
437
|
+
if (name === "Queue") {
|
|
438
|
+
const arg = readDecoratorArg(dec.getArguments()[0]);
|
|
439
|
+
if (arg.kind !== "object" || !arg.value.name)
|
|
440
|
+
continue;
|
|
441
|
+
const { name: queueName, fifo } = resolveQueueName(String(arg.value.name), arg.value.fifo);
|
|
442
|
+
queues.push({
|
|
443
|
+
style: "method",
|
|
444
|
+
id: `${className}-${methodName}`,
|
|
445
|
+
name: queueName,
|
|
446
|
+
batchSize: typeof arg.value.batchSize === "number" ? arg.value.batchSize : undefined,
|
|
447
|
+
fifo,
|
|
448
|
+
file: rel,
|
|
449
|
+
className,
|
|
450
|
+
method: methodName,
|
|
451
|
+
source: loc(rel, method),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/** Modules whose `cron`/`queue` exports are laranja's function-style markers. */
|
|
457
|
+
const REGISTRATION_MODULES = new Set(["@alzulejos/laranja-decorators", "@alzulejos/laranja-core"]);
|
|
458
|
+
/**
|
|
459
|
+
* Map a file's local identifiers to the laranja marker they're bound to, honoring
|
|
460
|
+
* aliases — e.g. `import { cron as schedule } from "@alzulejos/laranja-decorators"`.
|
|
461
|
+
*/
|
|
462
|
+
function registrationImports(sf) {
|
|
463
|
+
const map = new Map();
|
|
464
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
465
|
+
if (!REGISTRATION_MODULES.has(imp.getModuleSpecifierValue()))
|
|
466
|
+
continue;
|
|
467
|
+
for (const named of imp.getNamedImports()) {
|
|
468
|
+
const imported = named.getName();
|
|
469
|
+
if (imported === "cron" || imported === "queue") {
|
|
470
|
+
map.set(named.getAliasNode()?.getText() ?? imported, imported);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return map;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Resolve the handler argument of a `cron()`/`queue()` call to an exported
|
|
478
|
+
* function name in the same file — the shim imports it by name, so it must be a
|
|
479
|
+
* named, exported function (or exported `const`).
|
|
480
|
+
*/
|
|
481
|
+
function resolveExportedHandlerName(sf, argNode, where) {
|
|
482
|
+
if (!argNode || !Node.isIdentifier(argNode)) {
|
|
483
|
+
throw new Error(`Registration at ${where}: the handler must be a reference to a named, exported function ` +
|
|
484
|
+
`(e.g. \`cron(rate(5, "minutes"), refreshCache)\`).`);
|
|
485
|
+
}
|
|
486
|
+
const name = argNode.getText();
|
|
487
|
+
const fn = sf.getFunction(name);
|
|
488
|
+
if (fn) {
|
|
489
|
+
if (!fn.isExported()) {
|
|
490
|
+
throw new Error(`Registration at ${where}: function "${name}" must be exported so laranja can import it.`);
|
|
491
|
+
}
|
|
492
|
+
return name;
|
|
493
|
+
}
|
|
494
|
+
const varDecl = sf.getVariableDeclaration(name);
|
|
495
|
+
if (varDecl) {
|
|
496
|
+
if (!varDecl.getVariableStatement()?.isExported()) {
|
|
497
|
+
throw new Error(`Registration at ${where}: "${name}" must be exported so laranja can import it.`);
|
|
498
|
+
}
|
|
499
|
+
return name;
|
|
500
|
+
}
|
|
501
|
+
throw new Error(`Registration at ${where}: could not find "${name}" in this file. ` +
|
|
502
|
+
`Define and export the handler alongside the cron()/queue() call.`);
|
|
503
|
+
}
|
|
504
|
+
/** Discover module-level `cron(...)` / `queue(...)` marker calls (function style). */
|
|
505
|
+
function collectFromRegistrations(rel, sf, imports, crons, queues) {
|
|
506
|
+
sf.forEachDescendant((node) => {
|
|
507
|
+
if (!Node.isCallExpression(node))
|
|
508
|
+
return;
|
|
509
|
+
const callee = node.getExpression();
|
|
510
|
+
if (!Node.isIdentifier(callee))
|
|
511
|
+
return;
|
|
512
|
+
const kind = imports.get(callee.getText());
|
|
513
|
+
if (!kind)
|
|
514
|
+
return;
|
|
515
|
+
const where = loc(rel, node);
|
|
516
|
+
const args = node.getArguments();
|
|
517
|
+
if (kind === "cron") {
|
|
518
|
+
const optNode = args[0];
|
|
519
|
+
let scheduleNode = optNode;
|
|
520
|
+
let explicitId;
|
|
521
|
+
if (optNode && Node.isObjectLiteralExpression(optNode)) {
|
|
522
|
+
scheduleNode = getPropertyInitializer(optNode, "schedule");
|
|
523
|
+
const idInit = getPropertyInitializer(optNode, "id");
|
|
524
|
+
explicitId = idInit && Node.isStringLiteral(idInit) ? idInit.getLiteralText() : undefined;
|
|
525
|
+
}
|
|
526
|
+
const schedule = resolveScheduleNode(scheduleNode, where);
|
|
527
|
+
if (!schedule) {
|
|
528
|
+
throw new Error(`cron() at ${where}: could not resolve a valid static schedule. ` +
|
|
529
|
+
`Use rate(n, unit), every(unit), a "rate(...)"/"cron(...)" string, ` +
|
|
530
|
+
`a node-cron expression, or CronExpression.* with literal arguments.`);
|
|
531
|
+
}
|
|
532
|
+
assertSchedule(schedule, where);
|
|
533
|
+
const exportName = resolveExportedHandlerName(sf, args[1], where);
|
|
534
|
+
crons.push({ style: "function", id: explicitId ?? exportName, schedule, file: rel, exportName, source: where });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// queue
|
|
538
|
+
const arg = readDecoratorArg(args[0]);
|
|
539
|
+
if (arg.kind !== "object" || !arg.value.name) {
|
|
540
|
+
throw new Error(`queue() at ${where}: requires an options object with a "name".`);
|
|
541
|
+
}
|
|
542
|
+
const { name: queueName, fifo } = resolveQueueName(String(arg.value.name), arg.value.fifo);
|
|
543
|
+
const exportName = resolveExportedHandlerName(sf, args[1], where);
|
|
544
|
+
queues.push({
|
|
545
|
+
style: "function",
|
|
546
|
+
id: exportName,
|
|
547
|
+
name: queueName,
|
|
548
|
+
batchSize: typeof arg.value.batchSize === "number" ? arg.value.batchSize : undefined,
|
|
549
|
+
fifo,
|
|
550
|
+
file: rel,
|
|
551
|
+
exportName,
|
|
552
|
+
source: where,
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/** Local identifiers in this file bound to a given laranja marker (alias-aware). */
|
|
557
|
+
function markerNames(sf, marker) {
|
|
558
|
+
const names = new Set();
|
|
559
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
560
|
+
if (!REGISTRATION_MODULES.has(imp.getModuleSpecifierValue()))
|
|
561
|
+
continue;
|
|
562
|
+
for (const named of imp.getNamedImports()) {
|
|
563
|
+
if (named.getName() === marker)
|
|
564
|
+
names.add(named.getAliasNode()?.getText() ?? marker);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return names;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Discover a call-marker (`http(app)` / `workers(AppModule)`). The marker must be
|
|
571
|
+
* bound to an export so the shim can import it — either `export default m(x)` or
|
|
572
|
+
* `export const y = m(x)`.
|
|
573
|
+
*/
|
|
574
|
+
function collectCallMarkers(rel, sf, marker, markers) {
|
|
575
|
+
const names = markerNames(sf, marker);
|
|
576
|
+
if (names.size === 0)
|
|
577
|
+
return;
|
|
578
|
+
sf.forEachDescendant((node) => {
|
|
579
|
+
if (!Node.isCallExpression(node))
|
|
580
|
+
return;
|
|
581
|
+
const callee = node.getExpression();
|
|
582
|
+
if (!Node.isIdentifier(callee) || !names.has(callee.getText()))
|
|
583
|
+
return;
|
|
584
|
+
const where = loc(rel, node);
|
|
585
|
+
const parent = node.getParent();
|
|
586
|
+
const firstArg = node.getArguments()[0];
|
|
587
|
+
const argName = firstArg && Node.isIdentifier(firstArg) ? firstArg.getText() : undefined;
|
|
588
|
+
if (parent && Node.isExportAssignment(parent)) {
|
|
589
|
+
markers.push({ file: rel, appExport: "default", source: where, argName });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (parent && Node.isVariableDeclaration(parent)) {
|
|
593
|
+
if (!parent.getVariableStatement()?.isExported()) {
|
|
594
|
+
throw new Error(`${marker}() at ${where}: the value must be exported, e.g. \`export const x = ${marker}(...)\`.`);
|
|
595
|
+
}
|
|
596
|
+
markers.push({ file: rel, appExport: parent.getName(), source: where, argName });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
throw new Error(`${marker}() at ${where}: wrap and export it, e.g. \`export default ${marker}(...)\` ` +
|
|
600
|
+
`or \`export const x = ${marker}(...)\`.`);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Bind each method-style @Cron/@Queue to its DI root and return the roots.
|
|
605
|
+
*
|
|
606
|
+
* - No marker: undefined (Express, or a workers-free / function-only project).
|
|
607
|
+
* - One marker: the common case — every method-style handler resolves against it,
|
|
608
|
+
* no provider-graph walk needed (a bare `class AppModule {}` with no @Module
|
|
609
|
+
* graph still works, as it always has).
|
|
610
|
+
* - Several markers: disambiguate by DI membership. Resolve each root's provider
|
|
611
|
+
* class graph (its `providers`, transitively through `imports`) and bind each
|
|
612
|
+
* handler to the single root that owns its class — so each worker Lambda later
|
|
613
|
+
* boots only its own module. A handler in zero or several roots, or a root that
|
|
614
|
+
* owns no worker, is a hard error.
|
|
615
|
+
*/
|
|
616
|
+
function assignWorkerRoots(markers, crons, queues, classIndex) {
|
|
617
|
+
if (markers.length === 0)
|
|
618
|
+
return undefined;
|
|
619
|
+
if (markers.length === 1) {
|
|
620
|
+
const m = markers[0];
|
|
621
|
+
const id = m.argName ?? "workers";
|
|
622
|
+
for (const h of [...crons, ...queues]) {
|
|
623
|
+
if (h.style === "method")
|
|
624
|
+
h.workersId = id;
|
|
625
|
+
}
|
|
626
|
+
return [{ id, handlerEntry: m.file, appExport: m.appExport }];
|
|
627
|
+
}
|
|
628
|
+
// Multiple roots: each marker must wrap a named module we can resolve a graph for.
|
|
629
|
+
const roots = markers.map((m) => {
|
|
630
|
+
if (!m.argName) {
|
|
631
|
+
throw new Error(`workers() at ${m.source}: with multiple worker modules each must wrap a named module class, ` +
|
|
632
|
+
`e.g. \`export default workers(QueueModule)\`.`);
|
|
633
|
+
}
|
|
634
|
+
return { marker: m, id: m.argName, providers: providerClassNames(m.argName, classIndex, new Set()) };
|
|
635
|
+
});
|
|
636
|
+
const byId = new Map();
|
|
637
|
+
for (const r of roots) {
|
|
638
|
+
const prev = byId.get(r.id);
|
|
639
|
+
if (prev) {
|
|
640
|
+
throw new Error(`workers(${r.id}) is declared twice (${prev.marker.source}, ${r.marker.source}). Mark each module once.`);
|
|
641
|
+
}
|
|
642
|
+
byId.set(r.id, r);
|
|
643
|
+
}
|
|
644
|
+
const owned = new Map(roots.map((r) => [r.id, 0]));
|
|
645
|
+
for (const h of [...crons, ...queues]) {
|
|
646
|
+
if (h.style !== "method")
|
|
647
|
+
continue;
|
|
648
|
+
const label = `${h.className}.${h.method}`;
|
|
649
|
+
const owning = roots.filter((r) => r.providers.has(h.className));
|
|
650
|
+
if (owning.length === 0) {
|
|
651
|
+
throw new Error(`${h.source}: ${label} isn't a provider in any workers() module. ` +
|
|
652
|
+
`Add ${h.className} to the providers of one of: ${roots.map((r) => r.id).join(", ")}.`);
|
|
653
|
+
}
|
|
654
|
+
if (owning.length > 1) {
|
|
655
|
+
throw new Error(`${h.source}: ${label} resolves to multiple workers() modules (${owning.map((r) => r.id).join(", ")}). ` +
|
|
656
|
+
`A provider must belong to a single DI root — remove it from all but one.`);
|
|
657
|
+
}
|
|
658
|
+
const root = owning[0];
|
|
659
|
+
h.workersId = root.id;
|
|
660
|
+
owned.set(root.id, (owned.get(root.id) ?? 0) + 1);
|
|
661
|
+
}
|
|
662
|
+
for (const r of roots) {
|
|
663
|
+
if ((owned.get(r.id) ?? 0) === 0) {
|
|
664
|
+
throw new Error(`workers(${r.id}) at ${r.marker.source} owns no @Cron/@Queue provider. ` +
|
|
665
|
+
`A workers module must contain at least one worker — remove the marker or move a job into ${r.id}.`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return roots.map((r) => ({ id: r.id, handlerEntry: r.marker.file, appExport: r.marker.appExport }));
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* The class names of the providers a Nest module owns, transitively through its
|
|
672
|
+
* `imports`. Modules we can't resolve in-project (e.g. `ConfigModule` from
|
|
673
|
+
* node_modules) contribute nothing — we only care about the user's own worker
|
|
674
|
+
* providers. Best-effort by design: it reads `providers`/`imports` statically and
|
|
675
|
+
* skips dynamic shapes it can't fold, which is safe (an unresolved provider simply
|
|
676
|
+
* won't match a handler, surfacing as a clear "not in any workers() module" error).
|
|
677
|
+
*/
|
|
678
|
+
function providerClassNames(moduleName, classIndex, seen) {
|
|
679
|
+
const out = new Set();
|
|
680
|
+
if (seen.has(moduleName))
|
|
681
|
+
return out;
|
|
682
|
+
seen.add(moduleName);
|
|
683
|
+
const decl = classIndex.get(moduleName)?.[0];
|
|
684
|
+
const moduleDec = decl?.getDecorators().find((d) => d.getName() === "Module");
|
|
685
|
+
const arg = moduleDec?.getArguments()[0];
|
|
686
|
+
if (!arg || !Node.isObjectLiteralExpression(arg))
|
|
687
|
+
return out;
|
|
688
|
+
const providers = getPropertyInitializer(arg, "providers");
|
|
689
|
+
if (providers && Node.isArrayLiteralExpression(providers)) {
|
|
690
|
+
for (const el of providers.getElements()) {
|
|
691
|
+
const name = providerClassName(el);
|
|
692
|
+
if (name)
|
|
693
|
+
out.add(name);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const imports = getPropertyInitializer(arg, "imports");
|
|
697
|
+
if (imports && Node.isArrayLiteralExpression(imports)) {
|
|
698
|
+
for (const el of imports.getElements()) {
|
|
699
|
+
const imported = importedModuleName(el);
|
|
700
|
+
if (imported)
|
|
701
|
+
for (const p of providerClassNames(imported, classIndex, seen))
|
|
702
|
+
out.add(p);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
/** The class a `providers[]` element names: `Svc` or `{ provide, useClass: Svc }`. */
|
|
708
|
+
function providerClassName(el) {
|
|
709
|
+
if (Node.isIdentifier(el))
|
|
710
|
+
return el.getText();
|
|
711
|
+
if (Node.isObjectLiteralExpression(el)) {
|
|
712
|
+
const useClass = getPropertyInitializer(el, "useClass");
|
|
713
|
+
if (useClass && Node.isIdentifier(useClass))
|
|
714
|
+
return useClass.getText();
|
|
715
|
+
}
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
/** The module an `imports[]` element names: `Mod` or a dynamic `Mod.forRoot(...)`. */
|
|
719
|
+
function importedModuleName(el) {
|
|
720
|
+
if (Node.isIdentifier(el))
|
|
721
|
+
return el.getText();
|
|
722
|
+
if (Node.isCallExpression(el)) {
|
|
723
|
+
const callee = el.getExpression();
|
|
724
|
+
if (Node.isPropertyAccessExpression(callee)) {
|
|
725
|
+
const obj = callee.getExpression();
|
|
726
|
+
if (Node.isIdentifier(obj))
|
|
727
|
+
return obj.getText();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
/** Best-effort discovery of `app.get("/path", ...)` style routes for visibility. */
|
|
733
|
+
function collectExpressRoutes(rel, sf, routes) {
|
|
734
|
+
sf.forEachDescendant((node) => {
|
|
735
|
+
if (!Node.isCallExpression(node))
|
|
736
|
+
return;
|
|
737
|
+
const expr = node.getExpression();
|
|
738
|
+
if (!Node.isPropertyAccessExpression(expr))
|
|
739
|
+
return;
|
|
740
|
+
const methodName = expr.getName().toLowerCase();
|
|
741
|
+
if (!HTTP_METHODS.has(methodName))
|
|
742
|
+
return;
|
|
743
|
+
const firstArg = node.getArguments()[0];
|
|
744
|
+
if (!firstArg || !(Node.isStringLiteral(firstArg) || Node.isNoSubstitutionTemplateLiteral(firstArg)))
|
|
745
|
+
return;
|
|
746
|
+
routes.push({
|
|
747
|
+
method: methodName.toUpperCase(),
|
|
748
|
+
path: firstArg.getLiteralText(),
|
|
749
|
+
source: loc(rel, node),
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/** Local identifiers in this file bound to laranja's `env` helper (alias-aware). */
|
|
754
|
+
function envHelperNames(sf) {
|
|
755
|
+
const names = new Set();
|
|
756
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
757
|
+
if (!REGISTRATION_MODULES.has(imp.getModuleSpecifierValue()))
|
|
758
|
+
continue;
|
|
759
|
+
for (const named of imp.getNamedImports()) {
|
|
760
|
+
if (named.getName() === "env")
|
|
761
|
+
names.add(named.getAliasNode()?.getText() ?? "env");
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return names;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Discover `env("NAME")` calls and collect the declared variable NAMES. The
|
|
768
|
+
* argument must be a string literal — `env(someVar)` is intentionally ignored
|
|
769
|
+
* (we can't know the name statically; this keeps env discovery deterministic and
|
|
770
|
+
* auditable). Location is irrelevant: a call buried in a handler body counts the
|
|
771
|
+
* same as one at module scope, because this is pure source analysis.
|
|
772
|
+
*/
|
|
773
|
+
function collectEnvKeys(rel, sf, keys) {
|
|
774
|
+
const names = envHelperNames(sf);
|
|
775
|
+
if (names.size === 0)
|
|
776
|
+
return;
|
|
777
|
+
sf.forEachDescendant((node) => {
|
|
778
|
+
if (!Node.isCallExpression(node))
|
|
779
|
+
return;
|
|
780
|
+
const callee = node.getExpression();
|
|
781
|
+
if (!Node.isIdentifier(callee) || !names.has(callee.getText()))
|
|
782
|
+
return;
|
|
783
|
+
const arg = node.getArguments()[0];
|
|
784
|
+
if (arg && (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg))) {
|
|
785
|
+
const key = arg.getLiteralText();
|
|
786
|
+
// A malformed name (a stray char that slipped inside the quotes, e.g.
|
|
787
|
+
// `env("MY_SECRET)")`) can never be a real env var — fail loudly here with a
|
|
788
|
+
// location, rather than downstream as a cryptic duplicate-construct error at
|
|
789
|
+
// synth once non-alphanumerics are stripped from the CFN Parameter id.
|
|
790
|
+
if (!isValidEnvName(key)) {
|
|
791
|
+
throw new Error(`Invalid env var name ${JSON.stringify(key)} in ${rel}:${arg.getStartLineNumber()} — ` +
|
|
792
|
+
`env var names must match ${ENV_NAME_PATTERN.source} (letters, digits, underscores; ` +
|
|
793
|
+
`not starting with a digit). Check for a typo like a bracket inside the quotes.`);
|
|
794
|
+
}
|
|
795
|
+
keys.add(key);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
//# sourceMappingURL=scan.js.map
|