effortless-aws 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/dist/chunk-I5TS7O5S.js +163 -0
- package/dist/cli/index.js +453 -98
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +359 -214
- package/dist/index.js +21 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime/wrap-http.js +86 -0
- package/dist/runtime/wrap-table-stream.js +143 -0
- package/package.json +3 -8
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# effortless-aws
|
|
2
|
+
|
|
3
|
+
Code-first AWS Lambda framework. Export handlers, deploy to AWS. No infrastructure files needed.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install effortless-aws
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What it looks like
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// src/api.ts
|
|
13
|
+
import { defineHttp, defineTable, param } from "effortless-aws";
|
|
14
|
+
|
|
15
|
+
// DynamoDB table — just export it, get the table
|
|
16
|
+
export const orders = defineTable<Order>({
|
|
17
|
+
pk: { name: "id", type: "string" },
|
|
18
|
+
onRecord: async ({ record, table }) => {
|
|
19
|
+
if (record.eventName === "INSERT") {
|
|
20
|
+
await table.put({ ...record.new!, status: "confirmed" });
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// HTTP endpoint — creates API Gateway + Lambda + route
|
|
26
|
+
export const createOrder = defineHttp({
|
|
27
|
+
method: "POST",
|
|
28
|
+
path: "/orders",
|
|
29
|
+
schema: (input) => parseOrder(input),
|
|
30
|
+
deps: { orders },
|
|
31
|
+
params: { apiKey: param("stripe-key") },
|
|
32
|
+
context: async ({ params }) => ({
|
|
33
|
+
stripe: new Stripe(params.apiKey),
|
|
34
|
+
}),
|
|
35
|
+
onRequest: async ({ data, ctx, deps }) => {
|
|
36
|
+
await deps.orders.put({ id: crypto.randomUUID(), ...data });
|
|
37
|
+
return { status: 201, body: { ok: true } };
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx eff deploy
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That's it. No YAML, no CloudFormation, no state files.
|
|
47
|
+
|
|
48
|
+
## Why
|
|
49
|
+
|
|
50
|
+
Traditional Lambda development splits infrastructure and code across multiple files and languages. Adding a single endpoint means touching CloudFormation/CDK/Terraform templates, IAM policies, and handler code separately.
|
|
51
|
+
|
|
52
|
+
**Effortless** derives infrastructure from your TypeScript exports. One `defineHttp` call creates the API Gateway route, Lambda function, and IAM role. One `defineTable` call creates the DynamoDB table, stream, event source mapping, and processor Lambda.
|
|
53
|
+
|
|
54
|
+
## Killer features
|
|
55
|
+
|
|
56
|
+
**Infrastructure from code** — export a handler, get the AWS resources. No config files, no YAML.
|
|
57
|
+
|
|
58
|
+
**Typed everything** — `defineTable` schema gives you typed `table.put()`, typed `deps.orders.get()`, typed `record.new`. One definition, types flow everywhere.
|
|
59
|
+
|
|
60
|
+
**Direct AWS SDK deploys** — no CloudFormation, no Pulumi. Direct API calls. Deploy in ~5-10s, not 5-10 minutes.
|
|
61
|
+
|
|
62
|
+
**No state files** — AWS resource tags are the source of truth. No tfstate, no S3 backends, no drift.
|
|
63
|
+
|
|
64
|
+
**Cross-handler deps** — `deps: { orders }` auto-wires IAM permissions and injects a typed `TableClient`. Zero config.
|
|
65
|
+
|
|
66
|
+
**SSM params** — `param("stripe-key")` fetches from Parameter Store at cold start. Auto IAM, auto caching, supports transforms.
|
|
67
|
+
|
|
68
|
+
**Partial batch failures** — DynamoDB stream processing reports failed records individually. No batch-level retries for one bad record.
|
|
69
|
+
|
|
70
|
+
**Cold start caching** — `context` factory runs once per cold start, cached across invocations. Put DB connections, SDK clients, config there.
|
|
71
|
+
|
|
72
|
+
## Handler types
|
|
73
|
+
|
|
74
|
+
### HTTP
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
export const getUser = defineHttp({
|
|
78
|
+
method: "GET",
|
|
79
|
+
path: "/users/{id}",
|
|
80
|
+
onRequest: async ({ req }) => {
|
|
81
|
+
return { status: 200, body: { id: req.params.id } };
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### DynamoDB Table + Stream
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
export const users = defineTable({
|
|
90
|
+
pk: { name: "id", type: "string" },
|
|
91
|
+
sk: { name: "email", type: "string" },
|
|
92
|
+
ttlAttribute: "expiresAt",
|
|
93
|
+
onRecord: async ({ record, table }) => {
|
|
94
|
+
console.log(record.eventName, record.new);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Table (resource only, no stream)
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
export const sessions = defineTable({
|
|
103
|
+
pk: { name: "id", type: "string" },
|
|
104
|
+
ttlAttribute: "expiresAt",
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// effortless.config.ts
|
|
112
|
+
import { defineConfig } from "effortless-aws";
|
|
113
|
+
|
|
114
|
+
export default defineConfig({
|
|
115
|
+
name: "my-app",
|
|
116
|
+
region: "eu-central-1",
|
|
117
|
+
handlers: ["src/**/*.ts"],
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## CLI
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx eff deploy # deploy all handlers
|
|
125
|
+
npx eff deploy --stage prod # deploy to specific stage
|
|
126
|
+
npx eff deploy --only users # deploy single handler
|
|
127
|
+
npx eff destroy # remove all resources
|
|
128
|
+
npx eff logs users --follow # stream CloudWatch logs
|
|
129
|
+
npx eff list # show deployed resources
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## How it works
|
|
133
|
+
|
|
134
|
+
1. **Static analysis** (ts-morph) — reads your exports, extracts handler config from AST
|
|
135
|
+
2. **Bundle** (esbuild) — wraps each handler with a runtime adapter
|
|
136
|
+
3. **Deploy** (AWS SDK) — creates/updates Lambda, API Gateway, DynamoDB, IAM directly
|
|
137
|
+
|
|
138
|
+
No CloudFormation stacks. No Terraform state. Tags on AWS resources are the only state.
|
|
139
|
+
|
|
140
|
+
## Compared to
|
|
141
|
+
|
|
142
|
+
| | SST v3 | Nitric | Serverless | **Effortless** |
|
|
143
|
+
|---|---|---|---|---|
|
|
144
|
+
| Infra from code (not config) | No | Yes | No | **Yes** |
|
|
145
|
+
| Typed client from schema | No | No | No | **Yes** |
|
|
146
|
+
| No state files | No | No | No | **Yes** |
|
|
147
|
+
| Deploy speed | ~30s | ~30s | minutes | **~5-10s** |
|
|
148
|
+
| Runs in your AWS account | Yes | Yes | Yes | **Yes** |
|
|
149
|
+
| Open source | Yes | Yes | Yes | **Yes** |
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/runtime/table-client.ts
|
|
2
|
+
import { DynamoDB } from "@aws-sdk/client-dynamodb";
|
|
3
|
+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
|
|
4
|
+
var createTableClient = (tableName) => {
|
|
5
|
+
let client2 = null;
|
|
6
|
+
const getClient2 = () => client2 ??= new DynamoDB({});
|
|
7
|
+
return {
|
|
8
|
+
tableName,
|
|
9
|
+
async put(item) {
|
|
10
|
+
await getClient2().putItem({
|
|
11
|
+
TableName: tableName,
|
|
12
|
+
Item: marshall(item, { removeUndefinedValues: true })
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
async get(key) {
|
|
16
|
+
const result = await getClient2().getItem({
|
|
17
|
+
TableName: tableName,
|
|
18
|
+
Key: marshall(key, { removeUndefinedValues: true })
|
|
19
|
+
});
|
|
20
|
+
return result.Item ? unmarshall(result.Item) : void 0;
|
|
21
|
+
},
|
|
22
|
+
async delete(key) {
|
|
23
|
+
await getClient2().deleteItem({
|
|
24
|
+
TableName: tableName,
|
|
25
|
+
Key: marshall(key, { removeUndefinedValues: true })
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
async update(key, actions) {
|
|
29
|
+
const names = {};
|
|
30
|
+
const values = {};
|
|
31
|
+
const setClauses = [];
|
|
32
|
+
const removeClauses = [];
|
|
33
|
+
let counter = 0;
|
|
34
|
+
if (actions.set) {
|
|
35
|
+
for (const [attr, val] of Object.entries(actions.set)) {
|
|
36
|
+
const alias = `#a${counter}`;
|
|
37
|
+
const valAlias = `:v${counter}`;
|
|
38
|
+
names[alias] = attr;
|
|
39
|
+
values[valAlias] = val;
|
|
40
|
+
setClauses.push(`${alias} = ${valAlias}`);
|
|
41
|
+
counter++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (actions.append) {
|
|
45
|
+
for (const [attr, val] of Object.entries(actions.append)) {
|
|
46
|
+
const alias = `#a${counter}`;
|
|
47
|
+
const valAlias = `:v${counter}`;
|
|
48
|
+
const emptyAlias = `:empty${counter}`;
|
|
49
|
+
names[alias] = attr;
|
|
50
|
+
values[valAlias] = val;
|
|
51
|
+
values[emptyAlias] = [];
|
|
52
|
+
setClauses.push(`${alias} = list_append(if_not_exists(${alias}, ${emptyAlias}), ${valAlias})`);
|
|
53
|
+
counter++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (actions.remove) {
|
|
57
|
+
for (const attr of actions.remove) {
|
|
58
|
+
const alias = `#a${counter}`;
|
|
59
|
+
names[alias] = attr;
|
|
60
|
+
removeClauses.push(alias);
|
|
61
|
+
counter++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (setClauses.length) parts.push(`SET ${setClauses.join(", ")}`);
|
|
66
|
+
if (removeClauses.length) parts.push(`REMOVE ${removeClauses.join(", ")}`);
|
|
67
|
+
if (!parts.length) return;
|
|
68
|
+
await getClient2().updateItem({
|
|
69
|
+
TableName: tableName,
|
|
70
|
+
Key: marshall(key, { removeUndefinedValues: true }),
|
|
71
|
+
UpdateExpression: parts.join(" "),
|
|
72
|
+
ExpressionAttributeNames: names,
|
|
73
|
+
...Object.keys(values).length ? { ExpressionAttributeValues: marshall(values, { removeUndefinedValues: true }) } : {}
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
async query(params) {
|
|
77
|
+
const names = { "#pk": params.pk.name };
|
|
78
|
+
const values = { ":pk": params.pk.value };
|
|
79
|
+
let keyCondition = "#pk = :pk";
|
|
80
|
+
if (params.sk) {
|
|
81
|
+
names["#sk"] = params.sk.name;
|
|
82
|
+
values[":sk"] = params.sk.value;
|
|
83
|
+
if (params.sk.condition === "begins_with") {
|
|
84
|
+
keyCondition += " AND begins_with(#sk, :sk)";
|
|
85
|
+
} else {
|
|
86
|
+
keyCondition += ` AND #sk ${params.sk.condition} :sk`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const result = await getClient2().query({
|
|
90
|
+
TableName: tableName,
|
|
91
|
+
KeyConditionExpression: keyCondition,
|
|
92
|
+
ExpressionAttributeNames: names,
|
|
93
|
+
ExpressionAttributeValues: marshall(values, { removeUndefinedValues: true }),
|
|
94
|
+
...params.limit ? { Limit: params.limit } : {},
|
|
95
|
+
...params.scanIndexForward !== void 0 ? { ScanIndexForward: params.scanIndexForward } : {}
|
|
96
|
+
});
|
|
97
|
+
return (result.Items ?? []).map((item) => unmarshall(item));
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// src/runtime/ssm-client.ts
|
|
103
|
+
import { SSM } from "@aws-sdk/client-ssm";
|
|
104
|
+
var client = null;
|
|
105
|
+
var getClient = () => client ??= new SSM({});
|
|
106
|
+
var getParameters = async (names) => {
|
|
107
|
+
const map = /* @__PURE__ */ new Map();
|
|
108
|
+
for (let i = 0; i < names.length; i += 10) {
|
|
109
|
+
const batch = names.slice(i, i + 10);
|
|
110
|
+
const result = await getClient().getParameters({
|
|
111
|
+
Names: batch,
|
|
112
|
+
WithDecryption: true
|
|
113
|
+
});
|
|
114
|
+
for (const p of result.Parameters ?? []) {
|
|
115
|
+
if (p.Name && p.Value !== void 0) {
|
|
116
|
+
map.set(p.Name, p.Value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// src/runtime/handler-utils.ts
|
|
124
|
+
var ENV_TABLE_PREFIX = "EFF_TABLE_";
|
|
125
|
+
var ENV_PARAM_PREFIX = "EFF_PARAM_";
|
|
126
|
+
var buildDeps = (deps) => {
|
|
127
|
+
if (!deps) return void 0;
|
|
128
|
+
const result = {};
|
|
129
|
+
for (const key of Object.keys(deps)) {
|
|
130
|
+
const tableName = process.env[`${ENV_TABLE_PREFIX}${key}`];
|
|
131
|
+
if (!tableName) {
|
|
132
|
+
throw new Error(`Missing environment variable ${ENV_TABLE_PREFIX}${key} for dep "${key}"`);
|
|
133
|
+
}
|
|
134
|
+
result[key] = createTableClient(tableName);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
};
|
|
138
|
+
var buildParams = async (params) => {
|
|
139
|
+
if (!params) return void 0;
|
|
140
|
+
const entries = [];
|
|
141
|
+
for (const propName of Object.keys(params)) {
|
|
142
|
+
const ssmPath = process.env[`${ENV_PARAM_PREFIX}${propName}`];
|
|
143
|
+
if (!ssmPath) {
|
|
144
|
+
throw new Error(`Missing environment variable ${ENV_PARAM_PREFIX}${propName} for param "${propName}"`);
|
|
145
|
+
}
|
|
146
|
+
entries.push({ propName, ssmPath });
|
|
147
|
+
}
|
|
148
|
+
if (entries.length === 0) return void 0;
|
|
149
|
+
const values = await getParameters(entries.map((e) => e.ssmPath));
|
|
150
|
+
const result = {};
|
|
151
|
+
for (const { propName, ssmPath } of entries) {
|
|
152
|
+
const raw = values.get(ssmPath) ?? "";
|
|
153
|
+
const ref = params[propName];
|
|
154
|
+
result[propName] = ref?.transform ? ref.transform(raw) : raw;
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export {
|
|
160
|
+
createTableClient,
|
|
161
|
+
buildDeps,
|
|
162
|
+
buildParams
|
|
163
|
+
};
|