@kirkelliott/zap 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 ADDED
@@ -0,0 +1,287 @@
1
+ # zap
2
+
3
+ > Drop a `.zap` file in S3. It becomes an API endpoint.
4
+
5
+ Like PHP — but JavaScript, serverless, and free on AWS forever.
6
+
7
+ One Lambda function runs permanently as the runtime. When a request arrives at `/proxy`, it fetches `proxy.zap` from your S3 bucket, evaluates it, and returns the response. To change behavior, upload a new file. No redeploy. No CI. No infra changes.
8
+
9
+ ---
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ git clone https://github.com/dmvjs/s3node && cd s3node
15
+ npm install
16
+ npm run init
17
+ ```
18
+
19
+ `init` provisions the full AWS stack and prints your endpoint URL. Requires AWS credentials (`aws configure`). Takes about 30 seconds.
20
+
21
+ ```
22
+ building runtime ✓
23
+ creating bucket ✓ zap-a3f2b8c1
24
+ creating kv table ✓ zap-kv
25
+ configuring iam ✓
26
+ deploying lambda ✓
27
+ creating endpoint ✓
28
+
29
+ → https://abc123.lambda-url.us-east-1.on.aws
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Local dev
35
+
36
+ ```bash
37
+ npm run dev # → http://localhost:3000
38
+ ```
39
+
40
+ Place `.zap` files in the project root. `GET /proxy` maps to `./proxy.zap`. Uses real DynamoDB for `kv` — same table as production.
41
+
42
+ ---
43
+
44
+ ## The .zap format
45
+
46
+ A `.zap` file exports a default async function. That function is the handler.
47
+
48
+ ```js
49
+ export default async (req) => {
50
+ return { status: 200, body: 'hello' }
51
+ }
52
+ ```
53
+
54
+ ### Request
55
+
56
+ ```ts
57
+ req.method // 'GET' | 'POST' | 'PUT' | 'DELETE' | ...
58
+ req.path // '/proxy'
59
+ req.query // Record<string, string> e.g. { url: 'https://...' }
60
+ req.headers // Record<string, string>
61
+ req.body // string | null
62
+ ```
63
+
64
+ ### Response
65
+
66
+ ```ts
67
+ {
68
+ status?: number // default 200
69
+ headers?: Record<string, string>
70
+ body?: string | object // objects are JSON-serialized
71
+ }
72
+ ```
73
+
74
+ ### Built-ins
75
+
76
+ Everything available globally inside every `.zap` file:
77
+
78
+ | Name | Description |
79
+ |---|---|
80
+ | `fetch` | Standard web `fetch` |
81
+ | `kv` | Persistent key/value store (DynamoDB) |
82
+ | `zap(name)` | Import another `.zap` file from S3 |
83
+ | `crypto` | Web Crypto API |
84
+ | `URL`, `URLSearchParams` | URL utilities |
85
+ | `Buffer` | Node.js Buffer |
86
+ | `console` | Logging (goes to CloudWatch) |
87
+ | `setTimeout`, `clearTimeout` | Timers |
88
+ | `process.env` | Environment variables |
89
+
90
+ ---
91
+
92
+ ## kv — persistent storage
93
+
94
+ `kv` is a built-in key/value store backed by DynamoDB. No setup, no imports, no config — it's just there.
95
+
96
+ ```js
97
+ await kv.set('key', value) // value can be string, number, object, array
98
+ await kv.get('key') // returns the value, or null if not found
99
+ await kv.del('key') // delete
100
+ ```
101
+
102
+ ```js
103
+ // counter.zap
104
+ export default async (req) => {
105
+ const count = ((await kv.get('visits')) ?? 0) + 1
106
+ await kv.set('visits', count)
107
+ return { body: { visits: count } }
108
+ }
109
+ ```
110
+
111
+ ---
112
+
113
+ ## zap() — imports
114
+
115
+ Any `.zap` file can import another using `zap(name)`. S3 is the module registry.
116
+
117
+ ```js
118
+ // utils/greet.zap
119
+ export default {
120
+ hello: (name) => `hello ${name}`,
121
+ }
122
+ ```
123
+
124
+ ```js
125
+ // api.zap
126
+ export default async (req) => {
127
+ const greet = await zap('utils/greet')
128
+ return { body: greet.hello(req.query.name ?? 'world') }
129
+ }
130
+ ```
131
+
132
+ `zap('utils/greet')` fetches `utils/greet.zap` from the same bucket. Imports can be nested — a `.zap` file can `zap()` other `.zap` files. The bucket is the module system.
133
+
134
+ ---
135
+
136
+ ## @cron — scheduled handlers
137
+
138
+ Add `// @cron <expr>` as the first line. When deployed, `zap deploy` automatically creates an EventBridge rule that triggers the handler on schedule.
139
+
140
+ ```js
141
+ // @cron 0 * * * *
142
+ export default async () => {
143
+ await kv.set('heartbeat', new Date().toISOString())
144
+ console.log('tick')
145
+ }
146
+ ```
147
+
148
+ Standard 5-field cron expressions (`minute hour day month weekday`). `zap rm` removes the EventBridge rule alongside the S3 file.
149
+
150
+ Cron handlers receive no request argument. All built-ins (`kv`, `fetch`, `zap()`, etc.) are available.
151
+
152
+ ---
153
+
154
+ ## Examples
155
+
156
+ ### CORS proxy
157
+
158
+ Fetch any remote URL server-side, bypassing browser CORS restrictions.
159
+
160
+ ```js
161
+ // proxy.zap
162
+ export default async (req) => {
163
+ const url = req.query.url
164
+ if (!url) return { status: 400, body: 'Missing ?url=' }
165
+
166
+ const upstream = await fetch(url)
167
+ return {
168
+ status: upstream.status,
169
+ headers: {
170
+ 'content-type': upstream.headers.get('content-type') ?? 'text/plain',
171
+ 'access-control-allow-origin': '*',
172
+ },
173
+ body: await upstream.text(),
174
+ }
175
+ }
176
+ ```
177
+
178
+ ```
179
+ GET https://your-endpoint/proxy?url=https://api.example.com/data
180
+ ```
181
+
182
+ ### Stateful counter
183
+
184
+ ```js
185
+ // counter.zap
186
+ export default async (req) => {
187
+ const count = ((await kv.get('visits')) ?? 0) + 1
188
+ await kv.set('visits', count)
189
+ return { body: { visits: count } }
190
+ }
191
+ ```
192
+
193
+ ### Hourly heartbeat
194
+
195
+ ```js
196
+ // @cron 0 * * * *
197
+ export default async () => {
198
+ await kv.set('heartbeat', new Date().toISOString())
199
+ }
200
+ ```
201
+
202
+ ---
203
+
204
+ ## CLI
205
+
206
+ ```
207
+ zap init Provision AWS infra and deploy the runtime
208
+ zap deploy <file|directory> Upload .zap file(s) to S3
209
+ zap rm <name> Remove a handler (and its cron rule if any)
210
+ zap ls List deployed handlers
211
+ ```
212
+
213
+ **Options**
214
+
215
+ ```
216
+ -r, --region <region> AWS region for init (default: us-east-1)
217
+ -b, --bucket <bucket> S3 bucket name (default: reads from .zaprc)
218
+ ```
219
+
220
+ After `init`, a `.zaprc` file is written to the project root. All subsequent commands read the bucket name and function ARN from it automatically — no flags needed.
221
+
222
+ ---
223
+
224
+ ## AWS infrastructure
225
+
226
+ Everything provisioned by `npm run init`:
227
+
228
+ | Resource | Purpose |
229
+ |---|---|
230
+ | S3 bucket | Stores `.zap` files |
231
+ | DynamoDB table (`zap-kv`) | Backs the `kv` built-in |
232
+ | IAM role (`zap-runtime-role`) | Lambda execution role |
233
+ | Lambda function (`zap-runtime`, Node 20) | The runtime |
234
+ | Lambda Function URL | Public HTTPS endpoint — no API Gateway |
235
+ | EventBridge rules | One per `@cron` handler (created on deploy) |
236
+
237
+ ---
238
+
239
+ ## Cost
240
+
241
+ Runs within AWS always-free tier limits at zero cost for typical personal or small-team usage:
242
+
243
+ | Service | Free tier |
244
+ |---|---|
245
+ | Lambda | 1M requests/month, 400K GB-seconds — permanent |
246
+ | S3 | 5GB storage, 20K GET requests/month — permanent |
247
+ | DynamoDB | 25 WCU/RCU, 25GB storage — permanent |
248
+ | EventBridge | 14M scheduled invocations/month — permanent |
249
+
250
+ ---
251
+
252
+ ## How it works
253
+
254
+ The runtime is a single Lambda function. On each HTTP request:
255
+
256
+ 1. Parse the URL path → derive the S3 key (`/proxy` → `proxy.zap`)
257
+ 2. Fetch the `.zap` source from S3
258
+ 3. Evaluate it in a `vm` sandbox with the built-in globals
259
+ 4. Call the exported handler with the request
260
+ 5. Return the response
261
+
262
+ For `@cron` handlers, EventBridge sends `{ zap: { cron: "handler-name" } }` as the Lambda payload. The runtime detects this and invokes the handler with no request argument.
263
+
264
+ `zap(name)` inside a handler triggers the same fetch-and-eval cycle recursively, with the same loader — so the entire module graph lives in S3.
265
+
266
+ ---
267
+
268
+ MIT
269
+
270
+ ---
271
+
272
+ The file in S3 is just a carrier. You don't need it.
273
+
274
+ ```js
275
+ // live.zap
276
+ export default async (req) => {
277
+ if (!req.body) return { status: 400, body: 'POST a function' }
278
+ return { body: await eval(`(${req.body})`)({ kv, fetch }) }
279
+ }
280
+ ```
281
+
282
+ ```bash
283
+ curl -X POST https://your-endpoint/live \
284
+ -d 'async ({ kv }) => kv.get("visits")'
285
+ ```
286
+
287
+ Deploy `live.zap` once. Then POST JavaScript directly — no S3, no CLI, no deploy step. The runtime running inside itself.
@@ -0,0 +1,6 @@
1
+ export default async (req) => {
2
+ const key = req.query.key ?? 'default'
3
+ const count = ((await kv.get(key)) ?? 0) + 1
4
+ await kv.set(key, count)
5
+ return { body: { key, count } }
6
+ }
package/demo/echo.zap ADDED
@@ -0,0 +1,11 @@
1
+ export default async (req) => {
2
+ return {
3
+ body: {
4
+ method: req.method,
5
+ path: req.path,
6
+ query: req.query,
7
+ headers: req.headers,
8
+ body: req.body,
9
+ },
10
+ }
11
+ }
package/demo/hello.zap ADDED
@@ -0,0 +1,4 @@
1
+ export default async (req) => {
2
+ const name = req.query.name ?? 'world'
3
+ return { body: { message: `hello ${name}`, time: new Date().toISOString() } }
4
+ }
package/demo/kv.zap ADDED
@@ -0,0 +1,18 @@
1
+ export default async (req) => {
2
+ const { key, value } = req.query
3
+
4
+ if (!key) return { status: 400, body: 'Missing ?key=' }
5
+
6
+ if (req.method === 'DELETE') {
7
+ await kv.del(key)
8
+ return { body: { deleted: key } }
9
+ }
10
+
11
+ if (value !== undefined) {
12
+ await kv.set(key, value)
13
+ return { body: { set: key, value } }
14
+ }
15
+
16
+ const stored = await kv.get(key)
17
+ return { body: { key, value: stored } }
18
+ }
package/demo/live.zap ADDED
@@ -0,0 +1,4 @@
1
+ export default async (req) => {
2
+ if (!req.body) return { status: 400, body: 'POST a function' }
3
+ return { body: await eval(`(${req.body})`)({ kv, fetch }) }
4
+ }
package/demo/proxy.zap ADDED
@@ -0,0 +1,14 @@
1
+ export default async (req) => {
2
+ const url = req.query.url
3
+ if (!url) return { status: 400, body: 'Missing ?url=' }
4
+
5
+ const upstream = await fetch(url, { headers: { 'user-agent': 'zap-proxy/1.0' } })
6
+ return {
7
+ status: upstream.status,
8
+ headers: {
9
+ 'content-type': upstream.headers.get('content-type') ?? 'text/plain',
10
+ 'access-control-allow-origin': '*',
11
+ },
12
+ body: await upstream.text(),
13
+ }
14
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ const client_s3_1 = require("@aws-sdk/client-s3");
38
+ const commander_1 = require("commander");
39
+ const promises_1 = require("node:fs/promises");
40
+ const node_fs_1 = require("node:fs");
41
+ const node_path_1 = require("node:path");
42
+ const cron_1 = require("./cron");
43
+ const s3 = new client_s3_1.S3Client({});
44
+ function readConfig() {
45
+ try {
46
+ return JSON.parse((0, node_fs_1.readFileSync)('.zaprc', 'utf8'));
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ function bucket(opts) {
53
+ const b = opts.bucket ?? process.env.ZAP_BUCKET ?? readConfig().bucket;
54
+ if (!b) {
55
+ console.error('error: bucket required (--bucket, ZAP_BUCKET, or run: npm run init)');
56
+ process.exit(1);
57
+ }
58
+ return b;
59
+ }
60
+ async function walkZap(dir, prefix = '') {
61
+ const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
62
+ const results = [];
63
+ for (const entry of entries) {
64
+ const filePath = (0, node_path_1.join)(dir, entry.name);
65
+ const key = prefix ? `${prefix}/${entry.name}` : entry.name;
66
+ if (entry.isDirectory())
67
+ results.push(...await walkZap(filePath, key));
68
+ else if (entry.name.endsWith('.zap'))
69
+ results.push({ filePath, key });
70
+ }
71
+ return results;
72
+ }
73
+ async function deployFile(b, filePath, key) {
74
+ const source = await (0, promises_1.readFile)(filePath, 'utf8');
75
+ await s3.send(new client_s3_1.PutObjectCommand({ Bucket: b, Key: key, Body: source, ContentType: 'application/javascript' }));
76
+ const name = key.replace(/\.zap$/, '');
77
+ const cronExpr = (0, cron_1.parseCron)(source);
78
+ if (cronExpr) {
79
+ const { functionArn } = readConfig();
80
+ if (!functionArn) {
81
+ console.error('run npm run init first');
82
+ process.exit(1);
83
+ }
84
+ await (0, cron_1.upsertCron)(name, cronExpr, functionArn);
85
+ console.log(`+ ${name} ↻ ${cronExpr}`);
86
+ }
87
+ else {
88
+ console.log(`+ ${name}`);
89
+ }
90
+ }
91
+ const program = new commander_1.Command()
92
+ .name('zap')
93
+ .description('Deploy .zap handlers to S3')
94
+ .version('0.1.0');
95
+ program
96
+ .command('init')
97
+ .description('provision AWS infrastructure and deploy the runtime')
98
+ .option('-r, --region <region>', 'AWS region', 'us-east-1')
99
+ .action(async (opts) => {
100
+ const { init } = await Promise.resolve().then(() => __importStar(require('./init')));
101
+ await init(opts.region);
102
+ });
103
+ program
104
+ .command('deploy <path>')
105
+ .description('upload a .zap file or directory to S3')
106
+ .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
107
+ .action(async (path, opts) => {
108
+ const b = bucket(opts);
109
+ const info = await (0, promises_1.stat)(path);
110
+ if (info.isDirectory()) {
111
+ const files = await walkZap(path);
112
+ await Promise.all(files.map(({ filePath, key }) => deployFile(b, filePath, key)));
113
+ }
114
+ else {
115
+ const key = path.replace(/^\.\//, '');
116
+ await deployFile(b, path, key);
117
+ }
118
+ });
119
+ program
120
+ .command('rm <name>')
121
+ .description('remove a handler from S3')
122
+ .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
123
+ .action(async (name, opts) => {
124
+ const b = bucket(opts);
125
+ const key = name.endsWith('.zap') ? name : `${name}.zap`;
126
+ await s3.send(new client_s3_1.DeleteObjectCommand({ Bucket: b, Key: key }));
127
+ const { functionArn } = readConfig();
128
+ if (functionArn)
129
+ await (0, cron_1.removeCron)(name.replace(/\.zap$/, ''), functionArn);
130
+ console.log(`- ${name.replace(/\.zap$/, '')}`);
131
+ });
132
+ program
133
+ .command('ls')
134
+ .description('list deployed handlers')
135
+ .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
136
+ .action(async (opts) => {
137
+ const b = bucket(opts);
138
+ const { Contents = [] } = await s3.send(new client_s3_1.ListObjectsV2Command({ Bucket: b }));
139
+ const handlers = Contents.filter(o => o.Key?.endsWith('.zap'));
140
+ if (!handlers.length)
141
+ return console.log('no handlers deployed');
142
+ handlers.forEach(o => console.log(o.Key.replace(/\.zap$/, '')));
143
+ });
144
+ program.parse();
package/dist/cron.js ADDED
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseCron = parseCron;
4
+ exports.upsertCron = upsertCron;
5
+ exports.removeCron = removeCron;
6
+ const client_eventbridge_1 = require("@aws-sdk/client-eventbridge");
7
+ const client_lambda_1 = require("@aws-sdk/client-lambda");
8
+ const eb = new client_eventbridge_1.EventBridgeClient({});
9
+ const lambda = new client_lambda_1.LambdaClient({});
10
+ // Parse // @cron <expr> from .zap source
11
+ function parseCron(source) {
12
+ const match = source.match(/^\/\/\s*@cron\s+(.+)/m);
13
+ return match ? match[1].trim() : null;
14
+ }
15
+ // Convert standard 5-field cron to EventBridge format
16
+ // EventBridge requires one of day-of-month or day-of-week to be ?
17
+ function toSchedule(expr) {
18
+ const [min, hour, dom, month, dow] = expr.split(' ');
19
+ const evbDom = dow !== '*' ? '?' : dom;
20
+ const evbDow = dow !== '*' ? dow : '?';
21
+ return `cron(${min} ${hour} ${evbDom} ${month} ${evbDow} *)`;
22
+ }
23
+ const ruleId = (name) => `zap-cron-${name.replace(/\//g, '-')}`;
24
+ async function upsertCron(name, expr, functionArn) {
25
+ const rule = ruleId(name);
26
+ const { RuleArn } = await eb.send(new client_eventbridge_1.PutRuleCommand({
27
+ Name: rule,
28
+ ScheduleExpression: toSchedule(expr),
29
+ State: 'ENABLED',
30
+ }));
31
+ await eb.send(new client_eventbridge_1.PutTargetsCommand({
32
+ Rule: rule,
33
+ Targets: [{ Id: 'zap', Arn: functionArn, Input: JSON.stringify({ zap: { cron: name } }) }],
34
+ }));
35
+ try {
36
+ await lambda.send(new client_lambda_1.AddPermissionCommand({
37
+ FunctionName: functionArn,
38
+ StatementId: ruleId(name),
39
+ Action: 'lambda:InvokeFunction',
40
+ Principal: 'events.amazonaws.com',
41
+ SourceArn: RuleArn,
42
+ }));
43
+ }
44
+ catch (err) {
45
+ if (err.name !== 'ResourceConflictException')
46
+ throw err;
47
+ }
48
+ }
49
+ async function removeCron(name, functionArn) {
50
+ const rule = ruleId(name);
51
+ try {
52
+ await eb.send(new client_eventbridge_1.RemoveTargetsCommand({ Rule: rule, Ids: ['zap'] }));
53
+ await eb.send(new client_eventbridge_1.DeleteRuleCommand({ Name: rule }));
54
+ await lambda.send(new client_lambda_1.RemovePermissionCommand({ FunctionName: functionArn, StatementId: ruleId(name) }));
55
+ }
56
+ catch (err) {
57
+ if (err.name !== 'ResourceNotFoundException')
58
+ throw err;
59
+ }
60
+ }
package/dist/dev.js ADDED
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const node_http_1 = require("node:http");
4
+ const promises_1 = require("node:fs/promises");
5
+ const eval_1 = require("./eval");
6
+ const types_1 = require("./types");
7
+ const PORT = Number(process.env.PORT ?? 3000);
8
+ const loader = (name) => (0, promises_1.readFile)(`${name}.zap`, 'utf8');
9
+ function readBody(req) {
10
+ return new Promise((resolve) => {
11
+ let data = '';
12
+ req.on('data', (chunk) => (data += chunk.toString()));
13
+ req.on('end', () => resolve(data || null));
14
+ });
15
+ }
16
+ const server = (0, node_http_1.createServer)(async (req, res) => {
17
+ const url = new URL(req.url, `http://localhost:${PORT}`);
18
+ const path = url.pathname;
19
+ const zapFile = `.${path}.zap`;
20
+ const zapReq = {
21
+ method: req.method,
22
+ path,
23
+ query: Object.fromEntries(url.searchParams),
24
+ headers: req.headers,
25
+ body: await readBody(req),
26
+ };
27
+ try {
28
+ const source = await (0, promises_1.readFile)(zapFile, 'utf8');
29
+ const zapRes = await (0, eval_1.run)(source, zapReq, loader);
30
+ res.writeHead(zapRes.status ?? 200, zapRes.headers);
31
+ res.end((0, types_1.serialize)(zapRes.body));
32
+ }
33
+ catch (err) {
34
+ if (err.code === 'ENOENT') {
35
+ res.writeHead(404).end(`No handler: ${zapFile}`);
36
+ }
37
+ else {
38
+ res.writeHead(500).end(err.message);
39
+ }
40
+ }
41
+ });
42
+ server.listen(PORT, () => console.log(`zap dev → http://localhost:${PORT}`));
package/dist/eval.js ADDED
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.evalModule = evalModule;
7
+ exports.run = run;
8
+ const node_vm_1 = __importDefault(require("node:vm"));
9
+ const kv_1 = require("./kv");
10
+ const BASE_SANDBOX = {
11
+ fetch,
12
+ console,
13
+ URL,
14
+ URLSearchParams,
15
+ crypto,
16
+ Buffer,
17
+ setTimeout,
18
+ clearTimeout,
19
+ process: { env: process.env },
20
+ kv: kv_1.kv,
21
+ };
22
+ function evalModule(source, loader) {
23
+ const code = source.replace(/^export\s+default\s+/m, 'module.exports = ');
24
+ const mod = { exports: {} };
25
+ node_vm_1.default.runInNewContext(code, {
26
+ ...BASE_SANDBOX,
27
+ module: mod,
28
+ exports: mod.exports,
29
+ zap: (name) => loader(name).then(src => evalModule(src, loader)),
30
+ });
31
+ return mod.exports;
32
+ }
33
+ async function run(source, req, loader) {
34
+ const handler = evalModule(source, loader);
35
+ return handler(req);
36
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handler = void 0;
4
+ const client_s3_1 = require("@aws-sdk/client-s3");
5
+ const eval_1 = require("./eval");
6
+ const types_1 = require("./types");
7
+ const s3 = new client_s3_1.S3Client({});
8
+ const BUCKET = process.env.ZAP_BUCKET;
9
+ const loader = async (name) => {
10
+ const { Body } = await s3.send(new client_s3_1.GetObjectCommand({ Bucket: BUCKET, Key: `${name}.zap` }));
11
+ return Body.transformToString();
12
+ };
13
+ const handler = async (event) => {
14
+ // Cron invocation from EventBridge
15
+ if (event?.zap?.cron) {
16
+ try {
17
+ const source = await loader(event.zap.cron);
18
+ const fn = (0, eval_1.evalModule)(source, loader);
19
+ await fn();
20
+ }
21
+ catch (err) {
22
+ console.error(`cron error [${event.zap.cron}]:`, err.message);
23
+ }
24
+ return;
25
+ }
26
+ // HTTP invocation from Function URL
27
+ const e = event;
28
+ const req = {
29
+ method: e.requestContext.http.method,
30
+ path: e.rawPath,
31
+ query: Object.fromEntries(new URLSearchParams(e.rawQueryString ?? '').entries()),
32
+ headers: e.headers,
33
+ body: e.body ?? null,
34
+ };
35
+ try {
36
+ const source = await loader(req.path.replace(/^\//, ''));
37
+ const res = await (0, eval_1.run)(source, req, loader);
38
+ return { statusCode: res.status ?? 200, headers: res.headers, body: (0, types_1.serialize)(res.body) };
39
+ }
40
+ catch (err) {
41
+ if (err.name === 'NoSuchKey')
42
+ return { statusCode: 404, body: `No handler for ${req.path}` };
43
+ return { statusCode: 500, body: err.message };
44
+ }
45
+ };
46
+ exports.handler = handler;
package/dist/init.js ADDED
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.init = init;
4
+ const client_iam_1 = require("@aws-sdk/client-iam");
5
+ const client_lambda_1 = require("@aws-sdk/client-lambda");
6
+ const client_s3_1 = require("@aws-sdk/client-s3");
7
+ const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_crypto_1 = require("node:crypto");
10
+ const node_fs_1 = require("node:fs");
11
+ const ROLE = 'zap-runtime-role';
12
+ const FUNCTION = 'zap-runtime';
13
+ const TABLE = 'zap-kv';
14
+ const TRUST = JSON.stringify({
15
+ Version: '2012-10-17',
16
+ Statement: [{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' }],
17
+ });
18
+ const policy = (bucket) => JSON.stringify({
19
+ Version: '2012-10-17',
20
+ Statement: [
21
+ { Effect: 'Allow', Action: ['s3:GetObject', 's3:ListBucket'], Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`] },
22
+ { Effect: 'Allow', Action: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:DeleteItem'], Resource: `arn:aws:dynamodb:*:*:table/${TABLE}` },
23
+ ],
24
+ });
25
+ function step(label) {
26
+ process.stdout.write(` ${label.padEnd(24)}`);
27
+ return (note = '') => console.log(`✓${note ? ' ' + note : ''}`);
28
+ }
29
+ async function init(region) {
30
+ const s3 = new client_s3_1.S3Client({ region });
31
+ const iam = new client_iam_1.IAMClient({ region: 'us-east-1' });
32
+ const lambda = new client_lambda_1.LambdaClient({ region });
33
+ const dynamo = new client_dynamodb_1.DynamoDBClient({ region });
34
+ let config = {};
35
+ try {
36
+ config = JSON.parse((0, node_fs_1.readFileSync)('.zaprc', 'utf8'));
37
+ }
38
+ catch { }
39
+ // Build
40
+ let done = step('building runtime');
41
+ (0, node_child_process_1.execSync)('npx tsc', { stdio: 'pipe' });
42
+ (0, node_child_process_1.execSync)('zip -j dist/runtime.zip dist/*.js', { stdio: 'pipe' });
43
+ done();
44
+ // Bucket
45
+ const bucket = config.bucket ?? `zap-${(0, node_crypto_1.randomBytes)(4).toString('hex')}`;
46
+ done = step('creating bucket');
47
+ try {
48
+ await s3.send(new client_s3_1.HeadBucketCommand({ Bucket: bucket }));
49
+ }
50
+ catch (err) {
51
+ if (err.name !== 'NotFound' && err.$metadata?.httpStatusCode !== 404)
52
+ throw err;
53
+ await s3.send(new client_s3_1.CreateBucketCommand({
54
+ Bucket: bucket,
55
+ ...(region !== 'us-east-1' && { CreateBucketConfiguration: { LocationConstraint: region } }),
56
+ }));
57
+ }
58
+ done(bucket);
59
+ // KV table
60
+ done = step('creating kv table');
61
+ try {
62
+ await dynamo.send(new client_dynamodb_1.DescribeTableCommand({ TableName: TABLE }));
63
+ }
64
+ catch (err) {
65
+ if (err.name !== 'ResourceNotFoundException')
66
+ throw err;
67
+ await dynamo.send(new client_dynamodb_1.CreateTableCommand({
68
+ TableName: TABLE,
69
+ KeySchema: [{ AttributeName: 'k', KeyType: 'HASH' }],
70
+ AttributeDefinitions: [{ AttributeName: 'k', AttributeType: 'S' }],
71
+ BillingMode: 'PAY_PER_REQUEST',
72
+ }));
73
+ }
74
+ done(TABLE);
75
+ // IAM role
76
+ done = step('configuring iam');
77
+ let roleArn;
78
+ let isNew = false;
79
+ try {
80
+ const { Role } = await iam.send(new client_iam_1.GetRoleCommand({ RoleName: ROLE }));
81
+ roleArn = Role.Arn;
82
+ await iam.send(new client_iam_1.PutRolePolicyCommand({ RoleName: ROLE, PolicyName: 'zap-access', PolicyDocument: policy(bucket) }));
83
+ }
84
+ catch (err) {
85
+ if (err.name !== 'NoSuchEntityException')
86
+ throw err;
87
+ isNew = true;
88
+ const { Role } = await iam.send(new client_iam_1.CreateRoleCommand({ RoleName: ROLE, AssumeRolePolicyDocument: TRUST }));
89
+ roleArn = Role.Arn;
90
+ await iam.send(new client_iam_1.AttachRolePolicyCommand({ RoleName: ROLE, PolicyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' }));
91
+ await iam.send(new client_iam_1.PutRolePolicyCommand({ RoleName: ROLE, PolicyName: 'zap-access', PolicyDocument: policy(bucket) }));
92
+ }
93
+ if (isNew) {
94
+ process.stdout.write('propagating...');
95
+ await new Promise(r => setTimeout(r, 10_000));
96
+ process.stdout.write('\r configuring iam ');
97
+ }
98
+ done();
99
+ // Lambda
100
+ done = step('deploying lambda');
101
+ const zip = (0, node_fs_1.readFileSync)('dist/runtime.zip');
102
+ const env = { ZAP_BUCKET: bucket, ZAP_TABLE: TABLE };
103
+ let functionArn;
104
+ try {
105
+ const { Configuration } = await lambda.send(new client_lambda_1.GetFunctionCommand({ FunctionName: FUNCTION }));
106
+ functionArn = Configuration.FunctionArn;
107
+ await lambda.send(new client_lambda_1.UpdateFunctionCodeCommand({ FunctionName: FUNCTION, ZipFile: zip }));
108
+ await lambda.send(new client_lambda_1.UpdateFunctionConfigurationCommand({ FunctionName: FUNCTION, Environment: { Variables: env } }));
109
+ }
110
+ catch (err) {
111
+ if (err.name !== 'ResourceNotFoundException')
112
+ throw err;
113
+ const { FunctionArn } = await lambda.send(new client_lambda_1.CreateFunctionCommand({
114
+ FunctionName: FUNCTION,
115
+ Runtime: 'nodejs20.x',
116
+ Role: roleArn,
117
+ Handler: 'handler.handler',
118
+ Code: { ZipFile: zip },
119
+ Environment: { Variables: env },
120
+ Timeout: 30,
121
+ MemorySize: 256,
122
+ }));
123
+ functionArn = FunctionArn;
124
+ }
125
+ done();
126
+ // Function URL
127
+ done = step('creating endpoint');
128
+ let url;
129
+ try {
130
+ const { FunctionUrl } = await lambda.send(new client_lambda_1.GetFunctionUrlConfigCommand({ FunctionName: FUNCTION }));
131
+ url = FunctionUrl;
132
+ }
133
+ catch (err) {
134
+ if (err.name !== 'ResourceNotFoundException')
135
+ throw err;
136
+ const { FunctionUrl } = await lambda.send(new client_lambda_1.CreateFunctionUrlConfigCommand({
137
+ FunctionName: FUNCTION,
138
+ AuthType: 'NONE',
139
+ Cors: { AllowOrigins: ['*'], AllowMethods: ['*'], AllowHeaders: ['*'] },
140
+ }));
141
+ await lambda.send(new client_lambda_1.AddPermissionCommand({
142
+ FunctionName: FUNCTION,
143
+ StatementId: 'public-access',
144
+ Action: 'lambda:InvokeFunctionUrl',
145
+ Principal: '*',
146
+ FunctionUrlAuthType: 'NONE',
147
+ }));
148
+ url = FunctionUrl;
149
+ }
150
+ done();
151
+ (0, node_fs_1.writeFileSync)('.zaprc', JSON.stringify({ bucket, function: FUNCTION, table: TABLE, region, url, functionArn }, null, 2));
152
+ console.log(`\n → ${url.trim()}\n`);
153
+ }
package/dist/kv.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.kv = void 0;
4
+ const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
5
+ const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
6
+ const TABLE = process.env.ZAP_TABLE ?? 'zap-kv';
7
+ const db = lib_dynamodb_1.DynamoDBDocumentClient.from(new client_dynamodb_1.DynamoDBClient({}));
8
+ exports.kv = {
9
+ get: async (key) => {
10
+ const { Item } = await db.send(new lib_dynamodb_1.GetCommand({ TableName: TABLE, Key: { k: key } }));
11
+ return Item?.v ?? null;
12
+ },
13
+ set: async (key, value) => {
14
+ await db.send(new lib_dynamodb_1.PutCommand({ TableName: TABLE, Item: { k: key, v: value } }));
15
+ },
16
+ del: async (key) => {
17
+ await db.send(new lib_dynamodb_1.DeleteCommand({ TableName: TABLE, Key: { k: key } }));
18
+ },
19
+ };
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serialize = serialize;
4
+ function serialize(body) {
5
+ return typeof body === 'string' ? body : JSON.stringify(body);
6
+ }
@@ -0,0 +1,5 @@
1
+ export default async (req) => {
2
+ const greet = await zap('examples/greet')
3
+ const name = req.query.name ?? 'world'
4
+ return { body: greet.hello(name) }
5
+ }
@@ -0,0 +1,5 @@
1
+ export default async (req) => {
2
+ const count = ((await kv.get('visits')) ?? 0) + 1
3
+ await kv.set('visits', count)
4
+ return { body: { visits: count } }
5
+ }
@@ -0,0 +1,4 @@
1
+ export default {
2
+ hello: (name) => `hello ${name}`,
3
+ shout: (name) => `HELLO ${name.toUpperCase()}`,
4
+ }
@@ -0,0 +1,6 @@
1
+ // @cron 0 * * * *
2
+ export default async () => {
3
+ const last = new Date().toISOString()
4
+ await kv.set('heartbeat', last)
5
+ console.log('heartbeat', last)
6
+ }
@@ -0,0 +1,19 @@
1
+ export default async (req) => {
2
+ const url = req.query.url
3
+ if (!url) return { status: 400, body: 'Missing ?url= parameter' }
4
+
5
+ const upstream = await fetch(url, {
6
+ method: req.method === 'GET' ? 'GET' : req.method,
7
+ headers: { 'user-agent': 'zap-proxy/1.0' },
8
+ body: req.body ?? undefined,
9
+ })
10
+
11
+ return {
12
+ status: upstream.status,
13
+ headers: {
14
+ 'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
15
+ 'access-control-allow-origin': '*',
16
+ },
17
+ body: await upstream.text(),
18
+ }
19
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@kirkelliott/zap",
3
+ "version": "0.1.0",
4
+ "description": "Drop a .zap file in S3. It becomes an API endpoint.",
5
+ "main": "dist/handler.js",
6
+ "bin": {
7
+ "zap": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/dev.ts",
12
+ "init": "tsx src/cli.ts init",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "examples",
18
+ "demo"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/dmvjs/s3node.git"
23
+ },
24
+ "homepage": "https://github.com/dmvjs/s3node",
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "dependencies": {
30
+ "@aws-sdk/client-dynamodb": "^3.1000.0",
31
+ "@aws-sdk/client-eventbridge": "^3.1000.0",
32
+ "@aws-sdk/client-iam": "^3.1000.0",
33
+ "@aws-sdk/client-lambda": "^3.1000.0",
34
+ "@aws-sdk/client-s3": "^3",
35
+ "@aws-sdk/lib-dynamodb": "^3.1000.0",
36
+ "commander": "^14.0.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/aws-lambda": "^8",
40
+ "@types/node": "^22",
41
+ "tsx": "^4",
42
+ "typescript": "^5"
43
+ }
44
+ }