@geekmidas/cloud 1.0.1 → 1.1.1
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 +125 -0
- package/package.json +18 -5
- package/src/sst/Api.ts +309 -0
- package/src/sst/App.ts +80 -0
- package/src/sst/Cron.ts +113 -0
- package/src/sst/Function.ts +128 -0
- package/src/sst/Linkable.ts +19 -0
- package/src/sst/LinkedEnvironment.ts +61 -0
- package/src/sst/Queue.ts +46 -0
- package/src/sst/Stack.ts +55 -0
- package/src/sst/Storage.ts +55 -0
- package/src/sst/Topic.ts +37 -0
- package/src/sst/__tests__/LinkedEnvironment.spec.ts +79 -0
- package/src/sst/__type-tests__/authorizers.type-test.ts +72 -0
- package/src/sst/__type-tests__/manifest.type-test.ts +77 -0
- package/src/sst/__type-tests__/messaging.type-test.ts +29 -0
- package/src/sst/__type-tests__/storage.type-test.ts +24 -0
- package/src/sst/index.ts +23 -0
- package/src/sst/naming.ts +23 -0
- package/src/sst/tsconfig.json +5 -0
- package/CHANGELOG.md +0 -13
- package/src/__tests__/utils.spec.ts +0 -256
- package/src/index.ts +0 -1
- package/src/utils/index.ts +0 -158
- package/tsconfig.json +0 -9
- package/tsdown.config.ts +0 -3
package/README.md
CHANGED
|
@@ -1 +1,126 @@
|
|
|
1
1
|
# @geekmidas/cloud
|
|
2
|
+
|
|
3
|
+
SST (ion / Pulumi) integration for `@geekmidas` apps — opinionated, linkable
|
|
4
|
+
constructs that map 1:1 to deployable units and **validate their environment
|
|
5
|
+
before deploy**, plus the runtime helpers that resolve linked resources into
|
|
6
|
+
environment variables.
|
|
7
|
+
|
|
8
|
+
Two halves:
|
|
9
|
+
|
|
10
|
+
- **`@geekmidas/cloud/sst`** — the **infra-time** constructs you instantiate in
|
|
11
|
+
`sst.config.ts` (`App`, `Stack`, `Function`, `Api`, `Cron`).
|
|
12
|
+
- **`@geekmidas/cloud` / `@geekmidas/cloud/utils`** — the **runtime** helpers
|
|
13
|
+
(`buildResourceEnv`, `ResourceType`) that turn SST `Resource` links into flat
|
|
14
|
+
environment variables inside a Lambda.
|
|
15
|
+
|
|
16
|
+
> Design notes and rationale live in [`docs/sst-constructs.md`](./docs/sst-constructs.md)
|
|
17
|
+
> (constructs) and [`docs/sst-testing.md`](./docs/sst-testing.md) (testing).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @geekmidas/cloud
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`./sst` targets **SST v4** (peer dependency `sst@^4`) and is distributed as raw
|
|
26
|
+
TypeScript source — it extends the ambient `sst.aws.*` globals that only exist
|
|
27
|
+
after `sst install` in your app.
|
|
28
|
+
|
|
29
|
+
## `@geekmidas/cloud/sst`
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { App, Api, Function, Cron } from '@geekmidas/cloud/sst';
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### App & Stack
|
|
36
|
+
|
|
37
|
+
`App` is a plain synchronous construct; resolve the hosted zone once at the call
|
|
38
|
+
site and pass it in. `app.stack(name)` creates a `Stack` bound to the app.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const { zoneId } = await aws.route53.getZone({ name: 'example.com' });
|
|
42
|
+
|
|
43
|
+
const app = new App({
|
|
44
|
+
name: 'my-app',
|
|
45
|
+
stage: 'prod',
|
|
46
|
+
domain: 'example.com',
|
|
47
|
+
hostedZoneId: zoneId,
|
|
48
|
+
region: 'us-east-1',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const stack = app.stack('api');
|
|
52
|
+
stack.logicalPrefixedName('handler'); // "prod-my-app-api-handler"
|
|
53
|
+
stack.select({ prod: 'live', default: 'dev' }); // by-stage value with a default
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Function
|
|
57
|
+
|
|
58
|
+
Extends `sst.aws.FunctionArgs` (native options pass through), merges standard
|
|
59
|
+
env defaults, defaults to `nodejs24.x` + JSON logging, and **validates `envVars`
|
|
60
|
+
against `links` at synth time** — attaching only the links a function needs
|
|
61
|
+
(least privilege).
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const fn = new Function(stack, 'Processor', {
|
|
65
|
+
handler: 'src/processor.handler',
|
|
66
|
+
links: [db, topic],
|
|
67
|
+
envVars: ['DATABASE_URL'], // validated against links; fails synth if missing
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Api
|
|
72
|
+
|
|
73
|
+
Extends `sst.aws.ApiGatewayV2Args` (CORS/domain/etc. pass through). Routes are a
|
|
74
|
+
typed table with per-route env validation, least-privilege linking, and a typed
|
|
75
|
+
authorizer model.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const api = new Api(stack, 'Api', {
|
|
79
|
+
links: [db],
|
|
80
|
+
authorizers: {
|
|
81
|
+
jwt: { issuer: 'https://issuer', audiences: ['aud'] }, // 'jwt' → JWT settings
|
|
82
|
+
employee: { handler: 'src/employee-auth.handler' }, // custom → Lambda authorizer
|
|
83
|
+
},
|
|
84
|
+
routes: [
|
|
85
|
+
{ method: 'GET', path: '/me', handler: 'me.handler', authorizer: 'jwt' },
|
|
86
|
+
{ method: 'GET', path: '/adm', handler: 'adm.handler', authorizer: 'employee' },
|
|
87
|
+
{ method: 'POST', path: '/pub', handler: 'pub.handler' }, // public (none)
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A route's `authorizer` is type-constrained to `'iam' | 'none'` plus the declared
|
|
93
|
+
authorizer names — an undeclared name is a compile error, and `jwt` requires JWT
|
|
94
|
+
settings while custom authorizers require a `handler`.
|
|
95
|
+
|
|
96
|
+
### Cron
|
|
97
|
+
|
|
98
|
+
Wraps `sst.aws.CronV2` (`sst.aws.Cron` is deprecated). `processor` is a
|
|
99
|
+
`Function` (or anything with an `arn`); `schedule` is a typed `rate(…)` /
|
|
100
|
+
`cron(…)` / `at(…)`.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const cron = new Cron(stack, 'Nightly', { processor: fn, schedule: 'rate(1 day)' });
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### From a `gkm build` manifest
|
|
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:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import routes from './.gkm/routes-manifest.json';
|
|
113
|
+
|
|
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
|
+
```
|
|
118
|
+
|
|
119
|
+
## `@geekmidas/cloud/utils` (runtime)
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { buildResourceEnv } from '@geekmidas/cloud';
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`buildResourceEnv` turns a record of SST `Resource` links into flat environment
|
|
126
|
+
variables at runtime, using the shared `ResourceType` vocabulary.
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekmidas/cloud",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"src/sst"
|
|
8
|
+
],
|
|
5
9
|
"exports": {
|
|
6
10
|
".": {
|
|
7
11
|
"import": {
|
|
@@ -22,7 +26,8 @@
|
|
|
22
26
|
"types": "./dist/utils/index.d.cts",
|
|
23
27
|
"default": "./dist/utils/index.cjs"
|
|
24
28
|
}
|
|
25
|
-
}
|
|
29
|
+
},
|
|
30
|
+
"./sst": "./src/sst/index.ts"
|
|
26
31
|
},
|
|
27
32
|
"repository": {
|
|
28
33
|
"type": "git",
|
|
@@ -33,14 +38,22 @@
|
|
|
33
38
|
"access": "public"
|
|
34
39
|
},
|
|
35
40
|
"dependencies": {
|
|
36
|
-
"lodash.snakecase": "~4.1.1"
|
|
41
|
+
"lodash.snakecase": "~4.1.1",
|
|
42
|
+
"@geekmidas/envkit": "1.1.1",
|
|
43
|
+
"@geekmidas/manifest": "0.1.1"
|
|
37
44
|
},
|
|
38
45
|
"devDependencies": {
|
|
39
46
|
"@types/lodash.get": "~4.4.9",
|
|
40
47
|
"@types/lodash.set": "~4.3.9",
|
|
41
|
-
"@types/lodash.snakecase": "~4.1.9"
|
|
48
|
+
"@types/lodash.snakecase": "~4.1.9",
|
|
49
|
+
"@types/node": "~24.9.1",
|
|
50
|
+
"sst": "4.15.2"
|
|
42
51
|
},
|
|
43
52
|
"peerDependencies": {
|
|
44
|
-
"sst": "
|
|
53
|
+
"sst": "^4.15.2"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"sst:install": "sst install",
|
|
57
|
+
"ts:check:sst": "tsc --noEmit -p src/sst/tsconfig.json 2>&1 | grep -E 'src/sst/.*error TS' && exit 1 || exit 0"
|
|
45
58
|
}
|
|
46
59
|
}
|
package/src/sst/Api.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { EnvValidationError } from '@geekmidas/envkit/sst';
|
|
3
|
+
import {
|
|
4
|
+
flattenManifestField,
|
|
5
|
+
type ManifestField,
|
|
6
|
+
type RouteInfo,
|
|
7
|
+
} from '@geekmidas/manifest';
|
|
8
|
+
import type { Function } from './Function';
|
|
9
|
+
import { type GkmLinkable, ResourceType } from './Linkable';
|
|
10
|
+
import { LinkedEnvironment } from './LinkedEnvironment';
|
|
11
|
+
import type { StackType } from './Stack';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `Api` — wraps SST's `sst.aws.ApiGatewayV2` (HTTP API) with a typed route
|
|
15
|
+
* table and per-route environment validation that fails at synth time (before
|
|
16
|
+
* deploy) when a route requires variables its links cannot provide. Native
|
|
17
|
+
* `ApiGatewayV2Args` (CORS, domain, …) pass through untouched.
|
|
18
|
+
*
|
|
19
|
+
* NOTE: this module is distributed as raw TypeScript source. It extends the
|
|
20
|
+
* ambient `sst.aws.*` globals that only exist inside an `sst install`ed app, so
|
|
21
|
+
* it cannot be type-checked or built in this repo (see docs §2). It targets
|
|
22
|
+
* SST v4 (ion); the route/auth shape follows the v3 reference and should be
|
|
23
|
+
* verified against v4 in a consuming app.
|
|
24
|
+
*/
|
|
25
|
+
export class Api<
|
|
26
|
+
TAuthorizers extends Record<string, unknown> = {},
|
|
27
|
+
TStage extends string = string,
|
|
28
|
+
TDomain extends string = string,
|
|
29
|
+
>
|
|
30
|
+
extends sst.aws.ApiGatewayV2
|
|
31
|
+
implements GkmLinkable
|
|
32
|
+
{
|
|
33
|
+
readonly _id!: string;
|
|
34
|
+
|
|
35
|
+
get _type() {
|
|
36
|
+
return ResourceType.ApiGatewayV2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
stack: StackType<TStage, TDomain>,
|
|
41
|
+
id: string,
|
|
42
|
+
props: ApiProps<TAuthorizers>,
|
|
43
|
+
) {
|
|
44
|
+
const {
|
|
45
|
+
links = [],
|
|
46
|
+
routes,
|
|
47
|
+
root = process.cwd(),
|
|
48
|
+
vpc,
|
|
49
|
+
environment: apiEnvironment,
|
|
50
|
+
runtime: apiRuntime = 'nodejs24.x',
|
|
51
|
+
authorizers,
|
|
52
|
+
...apiArgs
|
|
53
|
+
} = props;
|
|
54
|
+
|
|
55
|
+
// Pass the consumer's native `ApiGatewayV2Args` straight through — CORS,
|
|
56
|
+
// domain, access logs, etc. are entirely the consumer's call.
|
|
57
|
+
super(id, apiArgs);
|
|
58
|
+
|
|
59
|
+
this._id = id;
|
|
60
|
+
|
|
61
|
+
// Register each declared authorizer and map its name to the created id.
|
|
62
|
+
// The reserved `jwt` entry is a JWT authorizer; any other name is a Lambda
|
|
63
|
+
// authorizer (enforced by the `ApiAuthorizers` type via the `handler`).
|
|
64
|
+
const authorizerIds = new Map<string, $util.Output<string>>();
|
|
65
|
+
for (const [name, config] of Object.entries(authorizers ?? {}) as [
|
|
66
|
+
string,
|
|
67
|
+
JwtAuthorizer | LambdaAuthorizer,
|
|
68
|
+
][]) {
|
|
69
|
+
const authorizer =
|
|
70
|
+
'handler' in config
|
|
71
|
+
? this.addAuthorizer({
|
|
72
|
+
name,
|
|
73
|
+
lambda: {
|
|
74
|
+
// Accept a handler path, function args, or one of our
|
|
75
|
+
// `Function` constructs (passed through as its `arn`).
|
|
76
|
+
function:
|
|
77
|
+
typeof config.handler === 'object' && 'arn' in config.handler
|
|
78
|
+
? config.handler.arn
|
|
79
|
+
: config.handler,
|
|
80
|
+
identitySources: config.identitySources,
|
|
81
|
+
payload: config.payload,
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
: this.addAuthorizer({
|
|
85
|
+
name,
|
|
86
|
+
jwt: {
|
|
87
|
+
issuer: config.issuer,
|
|
88
|
+
audiences: config.audiences,
|
|
89
|
+
identitySource: config.identitySource,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
authorizerIds.set(name, authorizer.id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const relativeRoot = path.relative(process.cwd(), root);
|
|
96
|
+
|
|
97
|
+
const environment = {
|
|
98
|
+
...LinkedEnvironment.createBaseEnvironment(stack),
|
|
99
|
+
...apiEnvironment,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Same links + whitelist for every route, so build the linker once; the
|
|
103
|
+
// failing route is identified via the per-route error context below.
|
|
104
|
+
const linked = new LinkedEnvironment(links, {
|
|
105
|
+
whitelist: Object.keys(environment),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const failures: EnvValidationError[] = [];
|
|
109
|
+
|
|
110
|
+
for (const route of routes) {
|
|
111
|
+
const routeKey = `${route.method} ${route.path}`;
|
|
112
|
+
const names = route.environment ?? [];
|
|
113
|
+
|
|
114
|
+
const result = linked.validator.validate(names);
|
|
115
|
+
if (!result.valid) {
|
|
116
|
+
failures.push(
|
|
117
|
+
new EnvValidationError({
|
|
118
|
+
missing: result.invalidVars,
|
|
119
|
+
available: linked.validator.availableVars,
|
|
120
|
+
linkVars: linked.validator.linkVars,
|
|
121
|
+
suggestions: result.suggestions,
|
|
122
|
+
context: `${id} ${routeKey}`,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const link = linked.resolveLink(names);
|
|
128
|
+
|
|
129
|
+
const auth = Api.buildRouteAuth(
|
|
130
|
+
route,
|
|
131
|
+
authorizers as
|
|
132
|
+
| Record<string, JwtAuthorizer | LambdaAuthorizer>
|
|
133
|
+
| undefined,
|
|
134
|
+
authorizerIds,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
this.route(
|
|
138
|
+
routeKey,
|
|
139
|
+
{
|
|
140
|
+
handler: path.join(relativeRoot, route.handler),
|
|
141
|
+
vpc,
|
|
142
|
+
environment,
|
|
143
|
+
link,
|
|
144
|
+
runtime: route.runtime ?? apiRuntime,
|
|
145
|
+
nodejs: route.nodejs,
|
|
146
|
+
timeout: route.timeout,
|
|
147
|
+
memory: route.memory,
|
|
148
|
+
},
|
|
149
|
+
auth,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fail the whole synth if any route is misconfigured, with one actionable
|
|
154
|
+
// message per offending route.
|
|
155
|
+
if (failures.length) {
|
|
156
|
+
throw new Error(failures.map((f) => f.message).join('\n\n'));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
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
|
+
/** Resolves a route's `authorizer` name to the SST `auth` option. */
|
|
199
|
+
private static buildRouteAuth(
|
|
200
|
+
route: Route<string>,
|
|
201
|
+
authorizers: Record<string, JwtAuthorizer | LambdaAuthorizer> | undefined,
|
|
202
|
+
authorizerIds: Map<string, $util.Output<string>>,
|
|
203
|
+
) {
|
|
204
|
+
const name = route.authorizer;
|
|
205
|
+
if (!name || name === 'none') return undefined;
|
|
206
|
+
if (name === 'iam') return { auth: { iam: true } };
|
|
207
|
+
|
|
208
|
+
const authorizerId = authorizerIds.get(name);
|
|
209
|
+
if (!authorizerId) return undefined;
|
|
210
|
+
|
|
211
|
+
const config = authorizers?.[name];
|
|
212
|
+
if (config && 'handler' in config) {
|
|
213
|
+
return { auth: { lambda: authorizerId } };
|
|
214
|
+
}
|
|
215
|
+
const jwt = config as JwtAuthorizer | undefined;
|
|
216
|
+
return {
|
|
217
|
+
auth: {
|
|
218
|
+
jwt: {
|
|
219
|
+
authorizer: authorizerId,
|
|
220
|
+
scopes: route.scopes ?? jwt?.scopes,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** JWT authorizer settings — `issuer` and `audiences` are required. */
|
|
228
|
+
export interface JwtAuthorizer {
|
|
229
|
+
issuer: $util.Input<string>;
|
|
230
|
+
audiences: $util.Input<$util.Input<string>[]>;
|
|
231
|
+
identitySource?: $util.Input<string>;
|
|
232
|
+
/** Default OAuth scopes required by routes that use this authorizer. */
|
|
233
|
+
scopes?: string[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Lambda (request) authorizer — requires a `handler`: a handler path, full
|
|
238
|
+
* function args, or one of our `Function` constructs.
|
|
239
|
+
*/
|
|
240
|
+
export interface LambdaAuthorizer {
|
|
241
|
+
handler: string | sst.aws.FunctionArgs | Function;
|
|
242
|
+
identitySources?: $util.Input<$util.Input<string>[]>;
|
|
243
|
+
payload?: '1.0' | '2.0';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Authorizer config map. The reserved `jwt` key must be {@link JwtAuthorizer}
|
|
248
|
+
* settings; every other named authorizer must be a {@link LambdaAuthorizer}
|
|
249
|
+
* (enforced via the required `handler`).
|
|
250
|
+
*/
|
|
251
|
+
export type ApiAuthorizers<T> = {
|
|
252
|
+
[K in keyof T]: K extends 'jwt' ? JwtAuthorizer : LambdaAuthorizer;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** Valid `authorizer` values for a route: the built-ins plus declared names. */
|
|
256
|
+
export type AuthorizerName<T> = 'iam' | 'none' | (keyof T & string);
|
|
257
|
+
|
|
258
|
+
export interface Route<TAuthorizer extends string = 'iam' | 'none'> {
|
|
259
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'ALL';
|
|
260
|
+
path: string;
|
|
261
|
+
/** Handler entrypoint, resolved relative to `root` (default `cwd`). */
|
|
262
|
+
handler: string;
|
|
263
|
+
/** Required env vars for this route; validated against `links`. */
|
|
264
|
+
environment?: readonly string[];
|
|
265
|
+
/**
|
|
266
|
+
* Authorization for this route:
|
|
267
|
+
* - `iam` — AWS SigV4 signed requests
|
|
268
|
+
* - `none` (default) — public
|
|
269
|
+
* - a declared authorizer name (`jwt`, or a custom Lambda authorizer)
|
|
270
|
+
*/
|
|
271
|
+
authorizer?: TAuthorizer;
|
|
272
|
+
/** OAuth scopes for a `jwt` route (overrides the authorizer's defaults). */
|
|
273
|
+
scopes?: string[];
|
|
274
|
+
nodejs?: { install?: string[]; externals?: string[] };
|
|
275
|
+
/** Lambda runtime for this route. Overrides the API default (`nodejs24.x`). */
|
|
276
|
+
runtime?: sst.aws.FunctionArgs['runtime'];
|
|
277
|
+
/** Lambda timeout for this route, e.g. `30 seconds`. */
|
|
278
|
+
timeout?: sst.aws.FunctionArgs['timeout'];
|
|
279
|
+
/** Lambda memory for this route, e.g. `1024 MB`. */
|
|
280
|
+
memory?: sst.aws.FunctionArgs['memory'];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `ApiProps` extends SST's native `sst.aws.ApiGatewayV2Args`, so every native
|
|
285
|
+
* option (`cors`, `domain`, `accessLog`, `transform`, …) passes straight
|
|
286
|
+
* through untouched. We only add the route table, authorizers, and the
|
|
287
|
+
* linking/validation inputs on top.
|
|
288
|
+
*/
|
|
289
|
+
export interface ApiProps<TAuthorizers extends Record<string, unknown> = {}>
|
|
290
|
+
extends sst.aws.ApiGatewayV2Args {
|
|
291
|
+
routes: Route<AuthorizerName<TAuthorizers>>[];
|
|
292
|
+
/**
|
|
293
|
+
* Named authorizers. The reserved `jwt` key configures a JWT authorizer; any
|
|
294
|
+
* other name configures a Lambda authorizer (requires a `handler`). Route
|
|
295
|
+
* `authorizer` values are constrained to these names plus `iam`/`none`.
|
|
296
|
+
*/
|
|
297
|
+
authorizers?: ApiAuthorizers<TAuthorizers>;
|
|
298
|
+
/** Pool of linkable resources routes may draw on. */
|
|
299
|
+
links?: GkmLinkable[];
|
|
300
|
+
/** Base directory for resolving route `handler` paths. Defaults to `cwd`. */
|
|
301
|
+
root?: string;
|
|
302
|
+
/** Env vars applied to every route, merged over the API defaults. */
|
|
303
|
+
environment?: Record<string, string>;
|
|
304
|
+
/** VPC to place each route's Lambda in. */
|
|
305
|
+
vpc?: sst.aws.Vpc;
|
|
306
|
+
/** Default Lambda runtime for every route. Defaults to `nodejs24.x`; a route
|
|
307
|
+
* may override it. */
|
|
308
|
+
runtime?: sst.aws.FunctionArgs['runtime'];
|
|
309
|
+
}
|
package/src/sst/App.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { prefixedName } from './naming';
|
|
2
|
+
import { Stack } from './Stack';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A by-stage value map with a required `default` fallback — the argument to
|
|
6
|
+
* {@link App.select} / {@link Stack.select}. Keys are stages (`TStage`).
|
|
7
|
+
*/
|
|
8
|
+
export type StageValues<TStage extends string, T> = Partial<
|
|
9
|
+
Record<TStage, T>
|
|
10
|
+
> & {
|
|
11
|
+
default: T;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface AppProps<TStage extends string, TDomain extends string> {
|
|
15
|
+
/** Application name; part of resource name prefixes. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Deployment stage, e.g. `dev` or `prod`. */
|
|
18
|
+
stage: TStage;
|
|
19
|
+
/** Root domain backed by a Route53 hosted zone. */
|
|
20
|
+
domain: TDomain;
|
|
21
|
+
/**
|
|
22
|
+
* Pre-resolved Route53 hosted zone id for `domain`. Resolve it once at the
|
|
23
|
+
* call site (`aws.route53.getZone`, or `getZoneOutput` for a lazy `Output`)
|
|
24
|
+
* and pass it in — the constructor stays synchronous (see docs §4).
|
|
25
|
+
*/
|
|
26
|
+
hostedZoneId: $util.Output<string> | string;
|
|
27
|
+
/** AWS region. */
|
|
28
|
+
region: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Application-level context: identity, deployment target, and a pre-resolved
|
|
33
|
+
* hosted zone. A plain synchronous constructor — zone resolution is the caller's
|
|
34
|
+
* responsibility, so every construct stays uniform (`new X(...)`).
|
|
35
|
+
*/
|
|
36
|
+
export class App<
|
|
37
|
+
TStage extends string = string,
|
|
38
|
+
TDomain extends string = string,
|
|
39
|
+
> {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly stage: TStage;
|
|
42
|
+
readonly domain: TDomain;
|
|
43
|
+
readonly region: string;
|
|
44
|
+
readonly hostedZoneId: $util.Output<string> | string;
|
|
45
|
+
|
|
46
|
+
constructor(props: AppProps<TStage, TDomain>) {
|
|
47
|
+
this.name = props.name;
|
|
48
|
+
this.stage = props.stage;
|
|
49
|
+
this.domain = props.domain;
|
|
50
|
+
this.region = props.region;
|
|
51
|
+
this.hostedZoneId = props.hostedZoneId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Create a {@link Stack} bound to this app (sugar for `new Stack(app, name)`). */
|
|
55
|
+
stack(name: string): Stack<TStage, TDomain> {
|
|
56
|
+
return new Stack(this, name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Picks a value for the current stage from a by-stage map, falling back to
|
|
61
|
+
* `default`. e.g. `app.select({ prod: 'live', staging: 'test', default: 'dev' })`.
|
|
62
|
+
*/
|
|
63
|
+
select<T>(values: StageValues<TStage, T>): T {
|
|
64
|
+
return values[this.stage] ?? values.default;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getSubdomain<TSub extends string>(subdomain: TSub) {
|
|
68
|
+
return `${subdomain}.${this.domain}` as const;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getURL<TSub extends string>(subdomain?: TSub) {
|
|
72
|
+
const prefix = subdomain ? (`${subdomain}.` as const) : '';
|
|
73
|
+
return `https://${prefix}${this.domain}` as const;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Kebab-cased, stage/app-name-prefixed physical resource name. */
|
|
77
|
+
logicalPrefixedName(id: string): string {
|
|
78
|
+
return prefixedName([this.stage, this.name], id);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/sst/Cron.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CronInfo,
|
|
3
|
+
flattenManifestField,
|
|
4
|
+
type ManifestField,
|
|
5
|
+
} from '@geekmidas/manifest';
|
|
6
|
+
import { Function } from './Function';
|
|
7
|
+
import type { GkmLinkable } from './Linkable';
|
|
8
|
+
import type { StackType } from './Stack';
|
|
9
|
+
|
|
10
|
+
export type CronExpressionValue = number | '*' | '?' | `${number}/${number}`;
|
|
11
|
+
export type CronExpressionDay =
|
|
12
|
+
| '?'
|
|
13
|
+
| 'SUN'
|
|
14
|
+
| 'MON'
|
|
15
|
+
| 'TUE'
|
|
16
|
+
| 'WED'
|
|
17
|
+
| 'THU'
|
|
18
|
+
| 'FRI'
|
|
19
|
+
| 'SAT';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A 6-field cron expression body: `minute hour day-of-month month day-of-week year`.
|
|
23
|
+
*/
|
|
24
|
+
export type CronString =
|
|
25
|
+
`${CronExpressionValue} ${CronExpressionValue} ${CronExpressionValue} ${CronExpressionValue} ${CronExpressionDay} ${CronExpressionValue}`;
|
|
26
|
+
export type CronExpression = `cron(${CronString})`;
|
|
27
|
+
export type CronRate = `rate(${number} ${
|
|
28
|
+
| 'minute'
|
|
29
|
+
| 'minutes'
|
|
30
|
+
| 'hour'
|
|
31
|
+
| 'hours'
|
|
32
|
+
| 'day'
|
|
33
|
+
| 'days'})`;
|
|
34
|
+
/** One-time `at(…)` schedule (e.g. `at(2025-06-01T10:00:00)`). */
|
|
35
|
+
export type CronAt = `at(${string})`;
|
|
36
|
+
export type CronSchedule = CronExpression | CronRate | CronAt;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `Cron` — wraps `sst.aws.CronV2` to invoke a `Function` on a schedule.
|
|
40
|
+
* (`sst.aws.Cron` is deprecated in SST v4 in favour of
|
|
41
|
+
* [`CronV2`](https://sst.dev/docs/component/aws/cron-v2).)
|
|
42
|
+
*
|
|
43
|
+
* `CronProps` extends the native `sst.aws.CronV2Args` (so `enabled`, `timezone`,
|
|
44
|
+
* `transform`, etc. pass through), but replaces `function`/`schedule` with the
|
|
45
|
+
* friendlier `processor` (a `Function`, or anything exposing `arn`) and a fully
|
|
46
|
+
* type-checked `schedule`. A cron is not a link target, so it is not `Linkable`
|
|
47
|
+
* and carries no `_type` (see docs §11.3).
|
|
48
|
+
*
|
|
49
|
+
* Source-only (extends ambient `sst.aws.*`); see docs §2.
|
|
50
|
+
*/
|
|
51
|
+
export class Cron<
|
|
52
|
+
TStage extends string = string,
|
|
53
|
+
TDomain extends string = string,
|
|
54
|
+
> extends sst.aws.CronV2 {
|
|
55
|
+
constructor(
|
|
56
|
+
_stack: StackType<TStage, TDomain>,
|
|
57
|
+
name: string,
|
|
58
|
+
props: CronProps,
|
|
59
|
+
) {
|
|
60
|
+
const { processor, schedule, ...cronArgs } = props;
|
|
61
|
+
super(name, {
|
|
62
|
+
...cronArgs,
|
|
63
|
+
schedule,
|
|
64
|
+
function: processor.arn,
|
|
65
|
+
});
|
|
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
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CronProps
|
|
108
|
+
extends Omit<sst.aws.CronV2Args, 'function' | 'schedule'> {
|
|
109
|
+
/** The function invoked on schedule — a `Function` (or anything with an `arn`). */
|
|
110
|
+
processor: Function | { arn: $util.Input<string> };
|
|
111
|
+
/** Cron `rate(…)`, `cron(…)`, or `at(…)` schedule, fully type-checked. */
|
|
112
|
+
schedule: CronSchedule;
|
|
113
|
+
}
|