@geekmidas/cloud 10.0.0-alpha.44 → 10.0.0-alpha.46

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/README.md CHANGED
@@ -105,17 +105,25 @@ const cron = new Cron(stack, 'Nightly', { processor: fn, schedule: 'rate(1 day)'
105
105
 
106
106
  ### From a `gkm build` manifest
107
107
 
108
- `gkm build` emits a deployment manifest (types in `@geekmidas/manifest`). Each
109
- construct has a static `fromManifest` factory that maps it straight into infra:
108
+ The root `gkm build` writes the application's manifest to
109
+ `.gkm/manifest/aws.ts` (types in `@geekmidas/manifest`), and `fromManifest`
110
+ maps it straight into infra:
110
111
 
111
112
  ```ts
112
- import routes from './.gkm/routes-manifest.json';
113
+ // sst.config.ts
114
+ const { App, fromManifest, Stack } = await import('@geekmidas/cloud/sst');
115
+ const { backends, constructs } = await import('./.gkm/manifest/aws.js');
113
116
 
114
- const api = Api.fromManifest(stack, 'Api', routes, { links: [db], authorizers });
115
- const workers = Function.fromManifest(stack, functionsManifest, { links: [db] });
116
- const crons = Cron.fromManifest(stack, cronsManifest, { links: [db] });
117
+ fromManifest(new Stack(app, 'Shop'), constructs, { Database: { vpc } }, backends);
117
118
  ```
118
119
 
120
+ `constructs` is every declared construct keyed by id; the overrides are keyed
121
+ by the same ids. One call provisions the resources, each `RestApi`'s endpoints
122
+ (each a Lambda linked only to its own dependencies, placed in the database's
123
+ VPC when it reaches one, with the `iam` authorizer enforced by the gateway),
124
+ queue consumers, every function (a Lambda with an IAM-authorized URL) and every
125
+ cron (a Lambda on its schedule).
126
+
119
127
  ## `@geekmidas/cloud/utils` (runtime)
120
128
 
121
129
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cloud",
3
- "version": "10.0.0-alpha.44",
3
+ "version": "10.0.0-alpha.46",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "lodash.snakecase": "~4.1.1",
44
- "@geekmidas/envkit": "10.0.0-alpha.44",
45
- "@geekmidas/manifest": "10.0.0-alpha.44"
44
+ "@geekmidas/envkit": "10.0.0-alpha.46",
45
+ "@geekmidas/manifest": "10.0.0-alpha.46"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/lodash.get": "~4.4.9",
@@ -58,10 +58,10 @@
58
58
  "@pulumi/aws": ">=7.47.0",
59
59
  "@pulumi/random": ">=4.21.2",
60
60
  "@pulumi/pulumi": ">=3.263.0",
61
- "@geekmidas/manifest": "^10.0.0-alpha.44",
62
- "@geekmidas/storage": "^10.0.0-alpha.44",
63
- "@geekmidas/events": "^10.0.0-alpha.44",
64
- "@geekmidas/db": "^10.0.0-alpha.44"
61
+ "@geekmidas/manifest": "^10.0.0-alpha.46",
62
+ "@geekmidas/storage": "^10.0.0-alpha.46",
63
+ "@geekmidas/events": "^10.0.0-alpha.46",
64
+ "@geekmidas/db": "^10.0.0-alpha.46"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@geekmidas/manifest": {
@@ -183,49 +183,3 @@ describe('Api', () => {
183
183
  expect(recorded(api).authorizers).toEqual([]);
184
184
  });
185
185
  });
186
-
187
- describe('Api.fromManifest', () => {
188
- it('turns built routes, flat or partitioned, into the route table', () => {
189
- const routes = {
190
- public: [
191
- {
192
- method: 'GET',
193
- path: '/orders',
194
- handler: '.gkm/aws-lambda/routes/orders.handler',
195
- environment: ['DB_URL'],
196
- authorizer: 'iam',
197
- timeout: 30,
198
- memorySize: 1024,
199
- },
200
- ],
201
- internal: [
202
- {
203
- method: 'POST',
204
- path: '/jobs',
205
- handler: '.gkm/aws-lambda/routes/jobs.handler',
206
- environment: [],
207
- },
208
- ],
209
- };
210
-
211
- const api = Api.fromManifest(stack, 'Http', routes as never, {
212
- links: [db],
213
- });
214
- const { routes: recordedRoutes } = recorded(api);
215
-
216
- expect(recordedRoutes.map((r) => r.key)).toEqual([
217
- 'GET /orders',
218
- 'POST /jobs',
219
- ]);
220
- expect(recordedRoutes[0]!.handler).toMatchObject({
221
- timeout: '30 seconds',
222
- memory: '1024 MB',
223
- link: [db],
224
- });
225
- expect(recordedRoutes[0]!.args).toEqual({ auth: { iam: true } });
226
- expect(recordedRoutes[1]!.handler).toMatchObject({
227
- timeout: undefined,
228
- memory: undefined,
229
- });
230
- });
231
- });
@@ -1,15 +1,24 @@
1
1
  import { resolveEnvKeys } from '@geekmidas/envkit/sst';
2
2
  import { NoUrlForStage, providedKeyFor, provideKey } from '@geekmidas/manifest';
3
3
  import { describe, expect, it } from 'vitest';
4
+ import { App } from '../App';
5
+ import { Cron } from '../aws/Cron';
6
+ import { Database } from '../aws/Database';
7
+ import { DatabaseSchema } from '../aws/DerivedDatabase';
8
+ import { Function } from '../aws/Function';
4
9
  import { ObjectStorage } from '../aws/ObjectStorage';
10
+ import { RestApiSurface } from '../aws/RestApiSurface';
5
11
  import { type ProvidesMismatch, UnknownDeclarationKind } from '../errors';
6
12
  import {
7
13
  assertProvides,
14
+ mountRoutes,
8
15
  PROVISIONED_ELSEWHERE,
9
16
  type ProvisionedManifest,
17
+ provisionCompute,
10
18
  provisionerFor,
11
19
  resolveEdges,
12
20
  subscribeConsumers,
21
+ vpcFor,
13
22
  } from '../fromManifest';
14
23
 
15
24
  /**
@@ -363,7 +372,7 @@ describe('a queue’s one consumer', () => {
363
372
  provides: ['EMAILS_PUBLISHER_CONNECTION_STRING'],
364
373
  worker: {
365
374
  id: 'EmailsWorker',
366
- handler: '.gkm/aws-lambda/queues/emails.handler',
375
+ handler: 'apps/api/.gkm/aws/queues/emails.handler',
367
376
  dependencies: [{ target: 'Uploads', kind: 'objects' }],
368
377
  },
369
378
  },
@@ -372,7 +381,7 @@ describe('a queue’s one consumer', () => {
372
381
  );
373
382
 
374
383
  expect(consumed).toEqual([
375
- { handler: '.gkm/aws-lambda/queues/emails.handler', link: [uploads] },
384
+ { handler: 'apps/api/.gkm/aws/queues/emails.handler', link: [uploads] },
376
385
  ]);
377
386
  });
378
387
 
@@ -391,6 +400,162 @@ describe('a queue’s one consumer', () => {
391
400
  });
392
401
  });
393
402
 
403
+ describe('a surface’s endpoints', () => {
404
+ const routesOf = (surface: RestApiSurface) =>
405
+ (
406
+ surface as unknown as {
407
+ routes: { key: string; handler: any; args: unknown }[];
408
+ }
409
+ ).routes;
410
+
411
+ it('are mounted from the handlers the build wrote, each linked to what it depends on', () => {
412
+ // An API Gateway with no routes 404s everything — which is what a deploy
413
+ // produced while the routes sat in the manifest with nothing reading them.
414
+ const api = new RestApiSurface({} as never, 'Api');
415
+ const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
416
+
417
+ mountRoutes(
418
+ {
419
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
420
+ Api: {
421
+ kind: 'rest-api',
422
+ id: 'Api',
423
+ provides: ['API_URL'],
424
+ endpoints: [
425
+ {
426
+ id: 'ApiGET/users/{id}',
427
+ method: 'GET',
428
+ path: '/users/{id}',
429
+ handler: 'apps/api/.gkm/aws/routes/getUser.handler',
430
+ dependencies: [{ target: 'Uploads', kind: 'objects' }],
431
+ authorizer: 'none',
432
+ },
433
+ {
434
+ id: 'ApiGET/health',
435
+ method: 'GET',
436
+ path: '/health',
437
+ handler: 'apps/api/.gkm/aws/routes/health.handler',
438
+ dependencies: [],
439
+ },
440
+ ],
441
+ },
442
+ } as never,
443
+ { Uploads: uploads, Api: api } as unknown as ProvisionedManifest,
444
+ );
445
+
446
+ const [getUser, health] = routesOf(api);
447
+ expect(getUser?.key).toBe('GET /users/{id}');
448
+ expect(getUser?.handler.handler).toBe(
449
+ 'apps/api/.gkm/aws/routes/getUser.handler',
450
+ );
451
+ expect(getUser?.handler.link).toEqual([uploads]);
452
+ expect(getUser?.args).toEqual({});
453
+ // Least privilege: an endpoint is linked to its own edges, not the app's.
454
+ expect(health?.key).toBe('GET /health');
455
+ expect(health?.handler.link).toEqual([]);
456
+ });
457
+
458
+ it('leaves IAM to the gateway, and every other authorizer to the handler', () => {
459
+ const api = new RestApiSurface({} as never, 'Api');
460
+
461
+ api.mount({
462
+ method: 'POST',
463
+ path: '/x',
464
+ handler: 'x.handler',
465
+ authorizer: 'iam',
466
+ });
467
+ api.mount({
468
+ method: 'POST',
469
+ path: '/y',
470
+ handler: 'y.handler',
471
+ authorizer: 'session',
472
+ });
473
+
474
+ const [x, y] = routesOf(api);
475
+ expect(x?.args).toEqual({ auth: { iam: true } });
476
+ expect(y?.args).toEqual({});
477
+ });
478
+
479
+ it('runs a handler that reaches a database inside the database’s network', () => {
480
+ const vpc = { id: 'stub-vpc' } as never;
481
+ const cluster = new Database({} as never, 'Orders', { vpc });
482
+ const tenant = new DatabaseSchema('Billing', cluster, 'billing');
483
+
484
+ expect(vpcFor([cluster as never])).toBe(vpc);
485
+ // A schema tenant lives in its parent's cluster, so in its network too.
486
+ expect(vpcFor([{ _id: 'Uploads' } as never, tenant as never])).toBe(vpc);
487
+ expect(vpcFor([{ _id: 'Uploads' } as never])).toBeUndefined();
488
+ });
489
+ });
490
+
491
+ describe('functions and crons', () => {
492
+ const stack = new App({
493
+ name: 'shop',
494
+ stage: 'prod',
495
+ domain: 'example.com',
496
+ hostedZoneId: 'Z',
497
+ region: 'eu-west-1',
498
+ }).stack('jobs');
499
+ const argsOf = (component: unknown) =>
500
+ (component as { args: Record<string, any> }).args;
501
+
502
+ it('are deployed from the declarations, each linked to its own edges', () => {
503
+ // The `manifest.crons` / `manifest.functions` tables they were built from
504
+ // are gone: the declarations carry the handler, schedule and edges.
505
+ const vpc = { id: 'stub-vpc' } as never;
506
+ const orders = new Database({} as never, 'Orders', { vpc });
507
+ const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
508
+ const provisioned = {
509
+ Orders: orders,
510
+ Uploads: uploads,
511
+ } as unknown as ProvisionedManifest;
512
+
513
+ const [digest] = provisionCompute(
514
+ stack,
515
+ {
516
+ Orders: { kind: 'database', id: 'Orders' },
517
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
518
+ Invoice: {
519
+ kind: 'function',
520
+ id: 'Invoice',
521
+ handler: 'apps/api/.gkm/aws/functions/invoice.handler',
522
+ dependencies: [{ target: 'Uploads', kind: 'objects' }],
523
+ provides: ['INVOICE_URL'],
524
+ },
525
+ Digest: {
526
+ kind: 'cron',
527
+ id: 'Digest',
528
+ handler: 'apps/api/.gkm/aws/crons/digest.handler',
529
+ schedule: 'rate(1 day)',
530
+ dependencies: [{ target: 'Orders', kind: 'database' }],
531
+ },
532
+ } as never,
533
+ provisioned,
534
+ );
535
+
536
+ const invoice = provisioned.Invoice as unknown as Function;
537
+ expect(invoice).toBeInstanceOf(Function);
538
+ expect(argsOf(invoice)).toMatchObject({
539
+ name: stack.logicalPrefixedName('Invoice'),
540
+ handler: 'apps/api/.gkm/aws/functions/invoice.handler',
541
+ link: [uploads],
542
+ url: { authorization: 'iam' },
543
+ });
544
+ expect(argsOf(invoice).vpc).toBeUndefined();
545
+
546
+ // A cron is a schedule, not something to link to; its Lambda reaches
547
+ // the database, so it runs in the database's network.
548
+ expect(provisioned.Digest).toBeUndefined();
549
+ expect(digest).toBeInstanceOf(Cron);
550
+ expect(argsOf(digest).schedule).toBe('rate(1 day)');
551
+ expect(argsOf(digest!.processor)).toMatchObject({
552
+ handler: 'apps/api/.gkm/aws/crons/digest.handler',
553
+ link: [orders],
554
+ vpc,
555
+ });
556
+ });
557
+ });
558
+
394
559
  describe('kinds provisioned elsewhere', () => {
395
560
  it('skips a worker, which is a process rather than a resource', () => {
396
561
  // A worker in the manifest used to throw UnknownDeclarationKind at synth,
@@ -79,46 +79,6 @@ describe('Function', () => {
79
79
  invalidVars: ['SMTP_URL'],
80
80
  });
81
81
  });
82
-
83
- it('builds one function per manifest entry, named for the stack', () => {
84
- const functions = Function.fromManifest(
85
- stack,
86
- {
87
- billing: [
88
- {
89
- name: 'Invoice',
90
- handler: '.gkm/aws-lambda/functions/invoice.handler',
91
- environment: ['DB_URL'],
92
- timeout: 60,
93
- memorySize: 512,
94
- },
95
- ],
96
- ops: [
97
- {
98
- name: 'Ping',
99
- handler: '.gkm/aws-lambda/functions/ping.handler',
100
- environment: [],
101
- },
102
- ],
103
- } as never,
104
- { links: [db] },
105
- );
106
-
107
- expect(functions.map((f) => argsOf(f))).toEqual([
108
- expect.objectContaining({
109
- name: stack.logicalPrefixedName('Invoice'),
110
- timeout: '60 seconds',
111
- memory: '512 MB',
112
- link: [db],
113
- }),
114
- expect.objectContaining({
115
- name: stack.logicalPrefixedName('Ping'),
116
- timeout: undefined,
117
- memory: undefined,
118
- link: [],
119
- }),
120
- ]);
121
- });
122
82
  });
123
83
 
124
84
  describe('Cron', () => {
@@ -136,37 +96,6 @@ describe('Cron', () => {
136
96
  function: processor.arn,
137
97
  });
138
98
  });
139
-
140
- it('builds a validated function and a schedule per manifest cron', () => {
141
- const crons = Cron.fromManifest(
142
- stack,
143
- [
144
- {
145
- name: 'Digest',
146
- handler: '.gkm/aws-lambda/crons/digest.handler',
147
- schedule: 'cron(0 8 * * ? *)',
148
- environment: ['DB_URL'],
149
- timeout: 120,
150
- memorySize: 1024,
151
- },
152
- {
153
- name: 'Sweep',
154
- handler: '.gkm/aws-lambda/crons/sweep.handler',
155
- schedule: 'rate(1 hour)',
156
- environment: [],
157
- },
158
- ] as never,
159
- { links: [db], enabled: true },
160
- );
161
-
162
- const [digest, sweep] = crons.map((c) => argsOf(c));
163
- expect(digest).toMatchObject({
164
- schedule: 'cron(0 8 * * ? *)',
165
- enabled: true,
166
- function: expect.stringContaining('DigestFunction'),
167
- });
168
- expect(sweep).toMatchObject({ schedule: 'rate(1 hour)' });
169
- });
170
99
  });
171
100
 
172
101
  describe('App and Stack', () => {
@@ -1,10 +1,5 @@
1
1
  import path from 'node:path';
2
2
  import { EnvValidationError } from '@geekmidas/envkit/sst';
3
- import {
4
- flattenManifestField,
5
- type ManifestField,
6
- type RouteInfo,
7
- } from '@geekmidas/manifest';
8
3
  import { type GkmLinkable, ResourceType } from '../Linkable';
9
4
  import { LinkedEnvironment } from '../LinkedEnvironment';
10
5
  import type { StackType } from '../Stack';
@@ -157,44 +152,6 @@ export class Api<
157
152
  }
158
153
  }
159
154
 
160
- /**
161
- * Build an `Api` from a `gkm build` manifest's `routes` field (flat or
162
- * partitioned): each `RouteInfo` becomes a route (env vars, authorizer,
163
- * timeout/memory mapped). Supply `authorizers` (JWT/Lambda settings),
164
- * `links`, and any native args via `props`.
165
- *
166
- * ```ts
167
- * import { manifest } from './.gkm/manifest/aws';
168
- * Api.fromManifest(stack, 'Api', manifest.routes, { links: [db] });
169
- * ```
170
- */
171
- static fromManifest<
172
- TAuthorizers extends Record<string, unknown> = {},
173
- TStage extends string = string,
174
- TDomain extends string = string,
175
- >(
176
- stack: StackType<TStage, TDomain>,
177
- id: string,
178
- routes: ManifestField<RouteInfo>,
179
- props: Omit<ApiProps<TAuthorizers>, 'routes'> = {},
180
- ): Api<TAuthorizers, TStage, TDomain> {
181
- const routeTable = flattenManifestField(routes).map(
182
- (route): Route<AuthorizerName<TAuthorizers>> => ({
183
- method: route.method as Route['method'],
184
- path: route.path,
185
- handler: route.handler,
186
- environment: route.environment,
187
- authorizer: route.authorizer as AuthorizerName<TAuthorizers>,
188
- timeout: route.timeout ? `${route.timeout} seconds` : undefined,
189
- memory: route.memorySize ? `${route.memorySize} MB` : undefined,
190
- }),
191
- );
192
- return new Api(stack, id, {
193
- ...props,
194
- routes: routeTable,
195
- } as ApiProps<TAuthorizers>);
196
- }
197
-
198
155
  /** Resolves a route's `authorizer` name to the SST `auth` option. */
199
156
  private static buildRouteAuth(
200
157
  route: Route<string>,
@@ -1,11 +1,5 @@
1
- import {
2
- type CronInfo,
3
- flattenManifestField,
4
- type ManifestField,
5
- } from '@geekmidas/manifest';
6
- import type { GkmLinkable } from '../Linkable';
7
1
  import type { StackType } from '../Stack';
8
- import { Function } from './Function';
2
+ import type { Function } from './Function';
9
3
 
10
4
  export type CronExpressionValue = number | '*' | '?' | `${number}/${number}`;
11
5
  export type CronExpressionDay =
@@ -52,6 +46,9 @@ export class Cron<
52
46
  TStage extends string = string,
53
47
  TDomain extends string = string,
54
48
  > extends sst.aws.CronV2 {
49
+ /** The function it invokes. */
50
+ readonly processor: CronProps['processor'];
51
+
55
52
  constructor(
56
53
  _stack: StackType<TStage, TDomain>,
57
54
  name: string,
@@ -63,44 +60,7 @@ export class Cron<
63
60
  schedule,
64
61
  function: processor.arn,
65
62
  });
66
- }
67
-
68
- /**
69
- * Build one `Cron` per entry in a `gkm build` manifest's `crons` field (flat
70
- * or partitioned). Each cron's handler becomes a validated `Function` (the
71
- * cron's target), so pass `links` for that function's env validation;
72
- * remaining `props` are CronV2 args.
73
- *
74
- * ```ts
75
- * Cron.fromManifest(stack, manifest.crons, { links: [db] });
76
- * ```
77
- */
78
- static fromManifest<
79
- TStage extends string = string,
80
- TDomain extends string = string,
81
- >(
82
- stack: StackType<TStage, TDomain>,
83
- crons: ManifestField<CronInfo>,
84
- props: Omit<CronProps, 'processor' | 'schedule'> & {
85
- links?: GkmLinkable[];
86
- } = {},
87
- ): Cron<TStage, TDomain>[] {
88
- const { links, ...cronArgs } = props;
89
- return flattenManifestField(crons).map((cron) => {
90
- const processor = new Function(stack, `${cron.name}Function`, {
91
- name: stack.logicalPrefixedName(cron.name),
92
- handler: cron.handler,
93
- envVars: cron.environment,
94
- links,
95
- timeout: cron.timeout ? `${cron.timeout} seconds` : undefined,
96
- memory: cron.memorySize ? `${cron.memorySize} MB` : undefined,
97
- });
98
- return new Cron(stack, cron.name, {
99
- ...cronArgs,
100
- processor,
101
- schedule: cron.schedule as CronSchedule,
102
- });
103
- });
63
+ this.processor = processor;
104
64
  }
105
65
  }
106
66
 
@@ -1,9 +1,4 @@
1
1
  import type { EnvValidator, ValidationResult } from '@geekmidas/envkit/sst';
2
- import {
3
- type FunctionInfo,
4
- flattenManifestField,
5
- type ManifestField,
6
- } from '@geekmidas/manifest';
7
2
  import { type GkmLinkable, ResourceType } from '../Linkable';
8
3
  import { LinkedEnvironment } from '../LinkedEnvironment';
9
4
  import type { StackType } from '../Stack';
@@ -66,9 +61,10 @@ export class Function<
66
61
  super(id, {
67
62
  ...fnArgs,
68
63
  environment: mergedEnvironment,
69
- // Linking is managed via the `links`/`envVars` flow, so this overrides
70
- // any native `link` passed through `fnArgs`.
71
- link: linked.resolveLink(envVars),
64
+ // Resolved from `links` when `envVars` names what it needs; otherwise
65
+ // the `link` it was given — the edges a manifest declared.
66
+ link:
67
+ envVars.length > 0 ? linked.resolveLink(envVars) : (fnArgs.link ?? []),
72
68
  runtime: runtime ?? 'nodejs24.x',
73
69
  logging: logging ?? { format: 'json' },
74
70
  });
@@ -78,40 +74,15 @@ export class Function<
78
74
  this.envVars = envVars;
79
75
  }
80
76
 
77
+ /** Its address, for whatever declared an edge to it. */
78
+ provides(): Record<string, $util.Input<string>> {
79
+ return { url: this.url };
80
+ }
81
+
81
82
  /** Re-runs validation and returns the result (does not throw). */
82
83
  validate(): ValidationResult {
83
84
  return this.validator.validate(this.envVars);
84
85
  }
85
-
86
- /**
87
- * Build one `Function` per entry in a `gkm build` manifest's `functions`
88
- * field (flat or partitioned). Shared `props` (e.g. `links`) apply to every
89
- * function.
90
- *
91
- * ```ts
92
- * Function.fromManifest(stack, manifest.functions, { links: [db] });
93
- * ```
94
- */
95
- static fromManifest<
96
- TStage extends string = string,
97
- TDomain extends string = string,
98
- >(
99
- stack: StackType<TStage, TDomain>,
100
- functions: ManifestField<FunctionInfo>,
101
- props: Omit<FunctionProps, 'handler'> = {},
102
- ): Function<TStage, TDomain>[] {
103
- return flattenManifestField(functions).map(
104
- (fn) =>
105
- new Function(stack, fn.name, {
106
- ...props,
107
- name: stack.logicalPrefixedName(fn.name),
108
- handler: fn.handler,
109
- envVars: fn.environment,
110
- timeout: fn.timeout ? `${fn.timeout} seconds` : undefined,
111
- memory: fn.memorySize ? `${fn.memorySize} MB` : undefined,
112
- }),
113
- );
114
- }
115
86
  }
116
87
 
117
88
  export interface FunctionProps extends sst.aws.FunctionArgs {
@@ -87,6 +87,7 @@ export class Queue<
87
87
  handler: consumer.handler,
88
88
  link: [this, ...(consumer.link ?? [])],
89
89
  runtime: 'nodejs24.x',
90
+ ...(consumer.vpc ? { vpc: consumer.vpc } : {}),
90
91
  ...(consumer.timeout ? { timeout: consumer.timeout } : {}),
91
92
  },
92
93
  consumer.batchSize ? { batch: { size: consumer.batchSize } } : {},
@@ -104,10 +105,12 @@ export class Queue<
104
105
 
105
106
  /** The queue's one consumer, as {@link Queue.consume} subscribes it. */
106
107
  export interface QueueConsumer {
107
- /** The built handler — `.gkm/aws-lambda/queues/emails.handler`. */
108
+ /** The built handler — `apps/api/.gkm/aws/queues/emails.handler`. */
108
109
  handler: string;
109
110
  /** What the consumer depends on; the queue itself is always linked. */
110
111
  link?: unknown[];
112
+ /** The database's network, when the consumer reaches one. */
113
+ vpc?: sst.aws.Vpc;
111
114
  timeout?: `${number} seconds` | `${number} minutes`;
112
115
  batchSize?: number;
113
116
  }
@@ -4,20 +4,10 @@ import type { StackType } from '../Stack';
4
4
  /**
5
5
  * `RestApiSurface` — an HTTP API, and the infra half of the `rest-api` kind.
6
6
  *
7
- * Deliberately *not* `Api`, which takes a route table and validates each
8
- * route's environment at synth. That component is the endpoint pipeline's, and
9
- * it needs routes; this one is the manifest's, and the manifest's `rest-api`
10
- * node currently carries `endpoints: []` for an application's own API.
11
- *
12
- * So what this provisions is the surface and nothing on it. An API Gateway with
13
- * no routes 404s everything, which is the honest state of things — and it is
14
- * still worth provisioning, because the *address* is what everything downstream
15
- * needs: a site inlines it as `VITE_API_URL`, an auth server puts it on its
16
- * trusted-origin list, and the cookie domain derives from it. Those are blocked
17
- * on the API existing, not on it answering.
18
- *
19
- * The two collapse into one component when the endpoint merge lands and the
20
- * surface node carries its routes.
7
+ * Provisioned before its routes, because its *address* is what everything
8
+ * downstream needs: a site inlines it as `VITE_API_URL`, an auth server puts it
9
+ * on its trusted-origin list, and the cookie domain derives from it. The routes
10
+ * are mounted once everything they link to exists — see {@link mount}.
21
11
  */
22
12
 
23
13
  export interface RestApiSurfaceProps extends sst.aws.ApiGatewayV2Args {
@@ -82,6 +72,25 @@ export class RestApiSurface<
82
72
  };
83
73
  }
84
74
 
75
+ /**
76
+ * Mount one endpoint: a Lambda running the handler the build wrote for it,
77
+ * linked to exactly what that endpoint depends on.
78
+ */
79
+ mount(endpoint: SurfaceRoute) {
80
+ return this.route(
81
+ `${endpoint.method} ${endpoint.path}`,
82
+ {
83
+ handler: endpoint.handler,
84
+ link: endpoint.link ?? [],
85
+ runtime: 'nodejs24.x',
86
+ ...(endpoint.vpc ? { vpc: endpoint.vpc } : {}),
87
+ },
88
+ // `iam` is the one authorizer the gateway enforces itself; every other
89
+ // is the endpoint's own, checked in the handler.
90
+ endpoint.authorizer === 'iam' ? { auth: { iam: true } } : {},
91
+ );
92
+ }
93
+
85
94
  override getSSTLink() {
86
95
  const link = super.getSSTLink();
87
96
  return {
@@ -90,3 +99,16 @@ export class RestApiSurface<
90
99
  };
91
100
  }
92
101
  }
102
+
103
+ /** One endpoint, as {@link RestApiSurface.mount} mounts it. */
104
+ export interface SurfaceRoute {
105
+ method: string;
106
+ path: string;
107
+ /** The built handler — `apps/api/.gkm/aws/routes/getUser.handler`. */
108
+ handler: string;
109
+ /** What the endpoint depends on, and nothing else. */
110
+ link?: unknown[];
111
+ /** The database's network, when the endpoint reaches one. */
112
+ vpc?: sst.aws.Vpc;
113
+ authorizer?: string;
114
+ }
@@ -41,12 +41,14 @@ import {
41
41
  withCacheTable,
42
42
  } from './aws/Cache';
43
43
  import { Credential } from './aws/Credential';
44
+ import { Cron, type CronSchedule } from './aws/Cron';
44
45
  import { Database, DatabaseNeedsVpc, type DatabaseProps } from './aws/Database';
45
46
  import { DatabaseBootstrap } from './aws/DatabaseBootstrap';
46
47
  import { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
47
48
  import { Email, EmailNeedsSender } from './aws/Email';
48
49
  import { ExternalApi, type ExternalApiProps } from './aws/ExternalApi';
49
50
  import { FileServer } from './aws/FileServer';
51
+ import { Function } from './aws/Function';
50
52
  import { ObjectStorage } from './aws/ObjectStorage';
51
53
  import { Queue } from './aws/Queue';
52
54
  import { RestApiSurface } from './aws/RestApiSurface';
@@ -378,17 +380,12 @@ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
378
380
  },
379
381
 
380
382
  /**
381
- * The surface, without its routes.
383
+ * The surface, without its routes yet.
382
384
  *
383
- * An API Gateway with no routes 404s everything, and that is the honest
384
- * state: the *surface* is what this kind declares, and mounting handlers on
385
- * it is the endpoint merge that has not landed — routes still reach the
386
- * deploy target through the separate `RouteInfo[]` pipeline.
387
- *
388
- * Provisioning it anyway is not ceremony. Its address is what a site inlines
389
- * as `VITE_API_URL`, what an auth server puts on its trusted-origin list, and
390
- * what the cookie domain derives from — so everything downstream of the API
391
- * is blocked on the API *existing*, not on it answering.
385
+ * Its address is what a site inlines as `VITE_API_URL`, what an auth server
386
+ * puts on its trusted-origin list, and what the cookie domain derives from —
387
+ * so it is provisioned in order, and its endpoints are mounted by
388
+ * {@link mountRoutes} once everything they link to exists.
392
389
  */
393
390
  'rest-api': (stack, d, props, context) =>
394
391
  new RestApiSurface(stack, d.id, {
@@ -593,9 +590,7 @@ function rootCluster(component: Provisioned): Database {
593
590
  * printed: one line per route, naming what it can reach.
594
591
  *
595
592
  * A surface with no routes says so rather than printing nothing, because "no
596
- * routes yet" and "this printed nothing" look identical otherwise — and for an
597
- * application's own API that is currently the true state, pending the endpoint
598
- * merge.
593
+ * routes" and "this printed nothing" look identical otherwise.
599
594
  *
600
595
  * Pure, so what gets printed can be asserted without a deploy.
601
596
  */
@@ -751,21 +746,132 @@ export function isServed(id: string, manifest: ConstructManifest): boolean {
751
746
  }
752
747
 
753
748
  /**
754
- * Kinds this stack provisions nothing for of their own.
749
+ * Kinds the in-order pass skips.
755
750
  *
756
751
  * - `mobile-app` ships through EAS and the stores. It is in the manifest for
757
752
  * what it makes the surfaces trust, which `callersOf` reads off the graph.
758
753
  * - `worker` is a process, not a resource: what it runs is provisioned as
759
754
  * what it is — a queue's consumer by {@link subscribeConsumers}.
760
- * - `cron` and `function` are Lambdas built from `manifest.crons` and
761
- * `manifest.functions` (`Cron.fromManifest`, `Function.fromManifest`).
755
+ * - `cron` and `function` are Lambdas linked to what they depend on, so they
756
+ * are provisioned once all of it exists, by {@link provisionCompute}.
762
757
  */
763
758
  export const PROVISIONED_ELSEWHERE: ReadonlySet<DeclarationKind> =
764
759
  new Set<DeclarationKind>(['mobile-app', 'worker', 'cron', 'function']);
765
760
 
761
+ /**
762
+ * Every function and cron: a Lambda running the handler the build wrote,
763
+ * linked to exactly what it depends on, in the database's network when it
764
+ * reaches one. A cron is that Lambda on its schedule; a function is reached
765
+ * at its URL, which only callers granted IAM access can invoke.
766
+ *
767
+ * After everything else is provisioned, because each links to things
768
+ * `provisionOrder` does not order before it.
769
+ */
770
+ export function provisionCompute(
771
+ stack: StackType,
772
+ manifest: ConstructManifest,
773
+ provisioned: ProvisionedManifest,
774
+ ): Cron[] {
775
+ const crons: Cron[] = [];
776
+
777
+ for (const [id, declaration] of Object.entries(manifest)) {
778
+ if (declaration?.kind !== 'function' && declaration?.kind !== 'cron') {
779
+ continue;
780
+ }
781
+
782
+ const { link } = resolveEdges(declaration.dependencies, provisioned);
783
+ const vpc = vpcFor(link);
784
+ const lambda = new Function(
785
+ stack,
786
+ declaration.kind === 'cron' ? `${id}Function` : id,
787
+ {
788
+ name: stack.logicalPrefixedName(id),
789
+ handler: declaration.handler,
790
+ link,
791
+ ...(vpc ? { vpc } : {}),
792
+ ...(declaration.kind === 'function'
793
+ ? { url: { authorization: 'iam' } }
794
+ : {}),
795
+ },
796
+ );
797
+
798
+ if (declaration.kind === 'cron') {
799
+ crons.push(
800
+ new Cron(stack, id, {
801
+ processor: lambda,
802
+ schedule: declaration.schedule as CronSchedule,
803
+ }),
804
+ );
805
+ continue;
806
+ }
807
+
808
+ assertProvides(
809
+ id,
810
+ declaration.provides,
811
+ Object.keys(lambda.provides()).map((role) =>
812
+ providedKeyFor(id, declaration.kind, role),
813
+ ),
814
+ );
815
+ provisioned[id] = lambda;
816
+ }
817
+
818
+ return crons;
819
+ }
820
+
766
821
  /** A provisioned queue: what {@link subscribeConsumers} subscribes through. */
767
822
  interface Consumable {
768
- consume(consumer: { handler: string; link?: unknown[] }): unknown;
823
+ consume(consumer: {
824
+ handler: string;
825
+ link?: unknown[];
826
+ vpc?: sst.aws.Vpc;
827
+ }): unknown;
828
+ }
829
+
830
+ /**
831
+ * The network a function has to run in to reach what it links to.
832
+ *
833
+ * A database is private to its VPC, so a handler linked to one — or to a
834
+ * schema tenant or reader of one — runs inside it, or its connection times out.
835
+ */
836
+ export function vpcFor(link: readonly Provisioned[]): sst.aws.Vpc | undefined {
837
+ for (const component of link) {
838
+ const cluster = rootCluster(component);
839
+ if (cluster instanceof Database) return cluster.vpc;
840
+ }
841
+
842
+ return undefined;
843
+ }
844
+
845
+ /**
846
+ * Mount every surface's endpoints — the handlers the build wrote, each linked
847
+ * to exactly what that endpoint depends on.
848
+ *
849
+ * After every construct is provisioned, for the reason consumers are: an
850
+ * endpoint links to things `provisionOrder` does not order before its surface.
851
+ */
852
+ export function mountRoutes(
853
+ manifest: ConstructManifest,
854
+ provisioned: ProvisionedManifest,
855
+ ): void {
856
+ for (const [id, declaration] of Object.entries(manifest)) {
857
+ if (declaration?.kind !== 'rest-api') continue;
858
+
859
+ const surface = provisioned[id];
860
+ if (!(surface instanceof RestApiSurface)) continue;
861
+
862
+ for (const endpoint of declaration.endpoints) {
863
+ const { link } = resolveEdges(endpoint.dependencies, provisioned);
864
+ const vpc = vpcFor(link);
865
+ surface.mount({
866
+ method: endpoint.method,
867
+ path: endpoint.path,
868
+ handler: endpoint.handler,
869
+ link,
870
+ ...(vpc ? { vpc } : {}),
871
+ ...(endpoint.authorizer ? { authorizer: endpoint.authorizer } : {}),
872
+ });
873
+ }
874
+ }
769
875
  }
770
876
 
771
877
  /**
@@ -786,7 +892,12 @@ export function subscribeConsumers(
786
892
  if (!queue) continue;
787
893
 
788
894
  const { link } = resolveEdges(declaration.worker.dependencies, provisioned);
789
- queue.consume({ handler: declaration.worker.handler, link });
895
+ const vpc = vpcFor(link);
896
+ queue.consume({
897
+ handler: declaration.worker.handler,
898
+ link,
899
+ ...(vpc ? { vpc } : {}),
900
+ });
790
901
  }
791
902
  }
792
903
 
@@ -902,8 +1013,11 @@ export function fromManifest(
902
1013
  provisioned[id] = component;
903
1014
  }
904
1015
 
905
- // Each queue's one consumer, once everything it links to exists.
1016
+ // The functions and crons, each queue's one consumer and each surface's
1017
+ // endpoints, once everything they link to exists.
1018
+ provisionCompute(stack, manifest, provisioned);
906
1019
  subscribeConsumers(manifest, provisioned);
1020
+ mountRoutes(manifest, provisioned);
907
1021
 
908
1022
  for (const line of describeRoutes(manifest)) console.log(line);
909
1023
 
@@ -1,77 +0,0 @@
1
- // Type-level checks that the `fromManifest` integrators accept the unified
2
- // `gkm build` manifest shape (`export const manifest = { … } as const`) — both
3
- // the flat and the partitioned `ManifestField` forms. Checked by `ts:check:sst`.
4
-
5
- import type { Manifest, ManifestField, RouteInfo } from '@geekmidas/manifest';
6
- import { App } from '../App';
7
- import { Api } from '../aws/Api';
8
- import { Cron } from '../aws/Cron';
9
- import { Function } from '../aws/Function';
10
- import { type GkmLinkable, ResourceType } from '../Linkable';
11
-
12
- const stack = new App({
13
- name: 'a',
14
- stage: 'dev',
15
- domain: 'example.com',
16
- hostedZoneId: 'Z',
17
- region: 'us-east-1',
18
- }).stack('api');
19
-
20
- const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
21
-
22
- // Mirrors a generated `manifest/aws.ts` — one object, `as const`.
23
- const manifest = {
24
- routes: [
25
- {
26
- path: '/users/{id}',
27
- method: 'GET',
28
- handler: 'users.handler',
29
- authorizer: 'none',
30
- environment: ['DB_HOST'],
31
- timeout: 30,
32
- memorySize: 1024,
33
- },
34
- ],
35
- functions: [
36
- { name: 'worker', handler: 'worker.handler', environment: ['DB_URL'] },
37
- ],
38
- crons: [
39
- { name: 'nightly', handler: 'nightly.handler', schedule: 'rate(1 day)' },
40
- ],
41
- } as const satisfies Manifest;
42
-
43
- // Each integrator takes the manifest *field*.
44
- export const api = Api.fromManifest(stack, 'Api', manifest.routes, {
45
- links: [db],
46
- authorizers: { jwt: { issuer: 'https://i', audiences: ['a'] } },
47
- });
48
- export const workers = Function.fromManifest(stack, manifest.functions, {
49
- links: [db],
50
- });
51
- export const crons = Cron.fromManifest(stack, manifest.crons, { links: [db] });
52
-
53
- // The partitioned `ManifestField` form is also accepted.
54
- const partitionedRoutes = {
55
- admin: [
56
- {
57
- path: '/admin',
58
- method: 'GET',
59
- handler: 'admin.handler',
60
- authorizer: 'iam',
61
- },
62
- ],
63
- default: [
64
- {
65
- path: '/health',
66
- method: 'GET',
67
- handler: 'health.handler',
68
- authorizer: 'none',
69
- },
70
- ],
71
- } satisfies ManifestField<RouteInfo>;
72
- export const partitionedApi = Api.fromManifest(
73
- stack,
74
- 'PartApi',
75
- partitionedRoutes,
76
- { links: [db] },
77
- );