@kirkelliott/zap 0.1.20 → 0.1.22

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
@@ -211,15 +211,69 @@ Both pages share `lib/page.zap`. Update that one file in S3 — both pages chang
211
211
 
212
212
  ---
213
213
 
214
+ ## Environments
215
+
216
+ Each environment is a fully isolated AWS stack — its own S3 bucket, Lambda function, KV table, and URL. `prod` is the default. No flags needed for prod.
217
+
218
+ ```bash
219
+ # spin up staging (takes ~30 seconds, same as init)
220
+ zap init --env staging
221
+
222
+ # deploy and test there
223
+ zap deploy --env staging api.zap
224
+ curl https://staging-url.lambda-url.us-east-1.on.aws/api
225
+
226
+ # promote to prod when ready
227
+ zap promote api --from staging --to prod
228
+ ```
229
+
230
+ `promote` copies the file from one bucket to the other. The prod URL is live instantly.
231
+
232
+ `.zaprc` stores each environment under its own key:
233
+
234
+ ```json
235
+ {
236
+ "prod": { "bucket": "zap-a3f2b8c1", "url": "https://abc.lambda-url…", … },
237
+ "staging": { "bucket": "zap-f9e2d4c7", "url": "https://xyz.lambda-url…", … }
238
+ }
239
+ ```
240
+
241
+ Every command accepts `--env`:
242
+
243
+ ```bash
244
+ zap ls --env staging
245
+ zap rollback api --env staging
246
+ zap rm old-handler --env staging
247
+ ```
248
+
249
+ ---
250
+
251
+ ## rollback — undo a deploy
252
+
253
+ Every `zap deploy` creates a new S3 version. Roll back to the previous one instantly:
254
+
255
+ ```bash
256
+ zap rollback hello
257
+ # ↩ hello restored to 2026-02-28T19:35:00.000Z
258
+ ```
259
+
260
+ Works per-environment: `zap rollback hello --env staging`
261
+
262
+ No revert commits. No redeploy. The old code is live immediately.
263
+
264
+ ---
265
+
214
266
  ## CLI
215
267
 
216
268
  ```
217
- zap init Provision AWS and deploy the runtime
218
- zap deploy <file|directory> Upload .zap file(s) to S3
219
- zap rm <name> Remove a handler (and its cron rule)
220
- zap ls List deployed handlers
221
- zap demo Deploy the built-in demo handlers
222
- zap repair Fix Lambda permissions if the URL stops working
269
+ zap init [--env <env>] Provision AWS and deploy the runtime
270
+ zap deploy <file|dir> [--env <env>] Upload .zap file(s) to S3
271
+ zap promote <name> [--from] [--to] Copy a handler between environments
272
+ zap rollback <name> [--env <env>] Restore the previous version of a handler
273
+ zap rm <name> [--env <env>] Remove a handler (and its cron rule)
274
+ zap ls [--env <env>] List deployed handlers
275
+ zap demo [--env <env>] Deploy the built-in demo handlers
276
+ zap repair [--env <env>] Fix Lambda permissions if the URL stops working
223
277
  ```
224
278
 
225
279
  `init` writes a `.zaprc` to the project directory. All other commands read bucket and region from it — no flags needed.
@@ -272,23 +326,4 @@ MIT
272
326
 
273
327
  ---
274
328
 
275
- The file in S3 is just a carrier. You don't need it.
276
-
277
- ```js
278
- // live.zap
279
- export default async (req) => {
280
- if (!req.body) return { status: 400, body: 'POST a function' }
281
- return { body: await eval(`(${req.body})`)({ kv, fetch }) }
282
- }
283
- ```
284
-
285
- ```bash
286
- curl -X POST https://your-endpoint/live \
287
- -d 'async ({ kv }) => kv.get("visits")'
288
- ```
289
-
290
- Deploy `live.zap` once. Then POST JavaScript directly — no S3, no CLI, no deploy step. The runtime running inside itself.
291
-
292
- ---
293
-
294
329
  **[live demo →](https://zn2qgaqlofvauxmoncf36m4ynq0pfarj.lambda-url.us-east-1.on.aws/)**
package/dist/cli.js CHANGED
@@ -42,18 +42,21 @@ const node_fs_1 = require("node:fs");
42
42
  const node_path_1 = require("node:path");
43
43
  const cron_1 = require("./cron");
44
44
  const s3 = new client_s3_1.S3Client({});
45
- function readConfig() {
45
+ function readConfig(env = 'prod') {
46
46
  try {
47
- return JSON.parse((0, node_fs_1.readFileSync)('.zaprc', 'utf8'));
47
+ const raw = JSON.parse((0, node_fs_1.readFileSync)('.zaprc', 'utf8'));
48
+ if (raw.bucket)
49
+ return raw; // old flat format — treat as prod
50
+ return raw[env] ?? {};
48
51
  }
49
52
  catch {
50
53
  return {};
51
54
  }
52
55
  }
53
56
  function bucket(opts) {
54
- const b = opts.bucket ?? process.env.ZAP_BUCKET ?? readConfig().bucket;
57
+ const b = opts.bucket ?? process.env.ZAP_BUCKET ?? readConfig(opts.env).bucket;
55
58
  if (!b) {
56
- console.error('error: bucket required (--bucket, ZAP_BUCKET, or run: npm run init)');
59
+ console.error('error: bucket required (--bucket, ZAP_BUCKET, or run: zap init)');
57
60
  process.exit(1);
58
61
  }
59
62
  return b;
@@ -79,7 +82,7 @@ async function deployFile(b, filePath, key, baseUrl) {
79
82
  if (cronExpr) {
80
83
  const { functionArn } = readConfig();
81
84
  if (!functionArn) {
82
- console.error('run npm run init first');
85
+ console.error('run zap init first');
83
86
  process.exit(1);
84
87
  }
85
88
  await (0, cron_1.upsertCron)(name, cronExpr, functionArn);
@@ -98,14 +101,16 @@ program
98
101
  .command('init')
99
102
  .description('provision AWS infrastructure and deploy the runtime')
100
103
  .option('-r, --region <region>', 'AWS region', 'us-east-1')
104
+ .option('-e, --env <env>', 'environment name', 'prod')
101
105
  .action(async (opts) => {
102
106
  const { init } = await Promise.resolve().then(() => __importStar(require('./init')));
103
- await init(opts.region);
107
+ await init(opts.region, opts.env);
104
108
  });
105
109
  program
106
110
  .command('deploy <path>')
107
111
  .description('upload a .zap file or directory to S3')
108
112
  .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
113
+ .option('-e, --env <env>', 'environment', 'prod')
109
114
  .action(async (path, opts) => {
110
115
  const b = bucket(opts);
111
116
  const info = await (0, promises_1.stat)(path);
@@ -118,15 +123,60 @@ program
118
123
  await deployFile(b, path, key);
119
124
  }
120
125
  });
126
+ program
127
+ .command('promote <name>')
128
+ .description('copy a handler from one environment to another')
129
+ .option('--from <env>', 'source environment', 'staging')
130
+ .option('--to <env>', 'target environment', 'prod')
131
+ .action(async (name, opts) => {
132
+ const srcCfg = readConfig(opts.from);
133
+ const dstCfg = readConfig(opts.to);
134
+ if (!srcCfg.bucket) {
135
+ console.error(`no config for env: ${opts.from}`);
136
+ process.exit(1);
137
+ }
138
+ if (!dstCfg.bucket) {
139
+ console.error(`no config for env: ${opts.to}`);
140
+ process.exit(1);
141
+ }
142
+ const key = name.endsWith('.zap') ? name : `${name}.zap`;
143
+ const { Body } = await s3.send(new client_s3_1.GetObjectCommand({ Bucket: srcCfg.bucket, Key: key }));
144
+ const source = await Body.transformToString();
145
+ await s3.send(new client_s3_1.PutObjectCommand({ Bucket: dstCfg.bucket, Key: key, Body: source, ContentType: 'application/javascript' }));
146
+ console.log(`↑ ${name} ${opts.from} → ${opts.to}`);
147
+ });
148
+ program
149
+ .command('rollback <name>')
150
+ .description('restore the previous version of a handler')
151
+ .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
152
+ .option('-e, --env <env>', 'environment', 'prod')
153
+ .action(async (name, opts) => {
154
+ const b = bucket(opts);
155
+ const key = name.endsWith('.zap') ? name : `${name}.zap`;
156
+ const { Versions = [] } = await s3.send(new client_s3_1.ListObjectVersionsCommand({ Bucket: b, Prefix: key }));
157
+ const sorted = Versions
158
+ .filter(v => v.Key === key)
159
+ .sort((a, b) => (b.LastModified?.getTime() ?? 0) - (a.LastModified?.getTime() ?? 0));
160
+ if (sorted.length < 2) {
161
+ console.error(`no previous version found for ${name}`);
162
+ process.exit(1);
163
+ }
164
+ const prev = sorted[1];
165
+ const { Body } = await s3.send(new client_s3_1.GetObjectCommand({ Bucket: b, Key: key, VersionId: prev.VersionId }));
166
+ const source = await Body.transformToString();
167
+ await s3.send(new client_s3_1.PutObjectCommand({ Bucket: b, Key: key, Body: source, ContentType: 'application/javascript' }));
168
+ console.log(`↩ ${name} restored to ${prev.LastModified?.toISOString()}`);
169
+ });
121
170
  program
122
171
  .command('rm <name>')
123
172
  .description('remove a handler from S3')
124
173
  .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
174
+ .option('-e, --env <env>', 'environment', 'prod')
125
175
  .action(async (name, opts) => {
126
176
  const b = bucket(opts);
127
177
  const key = name.endsWith('.zap') ? name : `${name}.zap`;
128
178
  await s3.send(new client_s3_1.DeleteObjectCommand({ Bucket: b, Key: key }));
129
- const { functionArn } = readConfig();
179
+ const { functionArn } = readConfig(opts.env);
130
180
  if (functionArn)
131
181
  await (0, cron_1.removeCron)(name.replace(/\.zap$/, ''), functionArn);
132
182
  console.log(`- ${name.replace(/\.zap$/, '')}`);
@@ -135,6 +185,7 @@ program
135
185
  .command('ls')
136
186
  .description('list deployed handlers')
137
187
  .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
188
+ .option('-e, --env <env>', 'environment', 'prod')
138
189
  .action(async (opts) => {
139
190
  const b = bucket(opts);
140
191
  const { Contents = [] } = await s3.send(new client_s3_1.ListObjectsV2Command({ Bucket: b }));
@@ -147,9 +198,10 @@ program
147
198
  .command('demo')
148
199
  .description('deploy the built-in demo handlers')
149
200
  .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
201
+ .option('-e, --env <env>', 'environment', 'prod')
150
202
  .action(async (opts) => {
151
203
  const b = bucket(opts);
152
- const { url } = readConfig();
204
+ const { url } = readConfig(opts.env);
153
205
  const demoDir = (0, node_path_1.resolve)(__dirname, '..', 'demo');
154
206
  const files = await walkZap(demoDir, 'demo');
155
207
  const remapped = files.map(f => f.key === 'demo/index.zap' ? { ...f, key: 'index.zap' } : f);
@@ -160,8 +212,9 @@ program
160
212
  program
161
213
  .command('debug')
162
214
  .description('show Lambda function URL auth config and resource-based policy')
163
- .action(async () => {
164
- const cfg = readConfig();
215
+ .option('-e, --env <env>', 'environment', 'prod')
216
+ .action(async (opts) => {
217
+ const cfg = readConfig(opts.env);
165
218
  const region = cfg.region ?? 'us-east-1';
166
219
  const fn = cfg.functionArn ?? cfg.function ?? 'zap-runtime';
167
220
  const lambda = new client_lambda_1.LambdaClient({ region });
@@ -198,10 +251,9 @@ program
198
251
  catch (err) {
199
252
  console.log('\ndirect invoke failed:', err.message);
200
253
  }
201
- const urlCfg = readConfig();
202
- if (urlCfg.url) {
254
+ if (cfg.url) {
203
255
  try {
204
- const res = await fetch(urlCfg.url);
256
+ const res = await fetch(cfg.url);
205
257
  console.log(`\nurl fetch: HTTP ${res.status}`);
206
258
  console.log('headers:', Object.fromEntries([...res.headers.entries()].filter(([k]) => k.startsWith('x-amzn') || k === 'content-type')));
207
259
  console.log('body:', await res.text());
@@ -214,16 +266,13 @@ program
214
266
  program
215
267
  .command('repair')
216
268
  .description('re-apply Lambda Function URL public access permissions')
217
- .action(async () => {
218
- const cfg = readConfig();
269
+ .option('-e, --env <env>', 'environment', 'prod')
270
+ .action(async (opts) => {
271
+ const cfg = readConfig(opts.env);
219
272
  const region = cfg.region ?? 'us-east-1';
220
273
  const fn = cfg.functionArn ?? cfg.function ?? 'zap-runtime';
221
274
  const lambda = new client_lambda_1.LambdaClient({ region });
222
275
  await lambda.send(new client_lambda_1.UpdateFunctionUrlConfigCommand({ FunctionName: fn, AuthType: 'NONE', Cors: { AllowOrigins: ['*'], AllowMethods: ['*'], AllowHeaders: ['*'] } }));
223
- try {
224
- await lambda.send(new client_lambda_1.RemovePermissionCommand({ FunctionName: fn, StatementId: 'public-access' }));
225
- }
226
- catch { }
227
276
  for (const sid of ['public-access', 'public-invoke', 'FunctionURLAllowPublicAccess']) {
228
277
  try {
229
278
  await lambda.send(new client_lambda_1.RemovePermissionCommand({ FunctionName: fn, StatementId: sid }));
@@ -236,25 +285,4 @@ program
236
285
  if (cfg.url)
237
286
  console.log(`\n → ${cfg.url.trim()}\n`);
238
287
  });
239
- program
240
- .command('rollback <name>')
241
- .description('restore the previous version of a handler')
242
- .option('-b, --bucket <bucket>', 'S3 bucket (or set ZAP_BUCKET)')
243
- .action(async (name, opts) => {
244
- const b = bucket(opts);
245
- const key = name.endsWith('.zap') ? name : `${name}.zap`;
246
- const { Versions = [] } = await s3.send(new client_s3_1.ListObjectVersionsCommand({ Bucket: b, Prefix: key }));
247
- const sorted = Versions
248
- .filter(v => v.Key === key)
249
- .sort((a, b) => (b.LastModified?.getTime() ?? 0) - (a.LastModified?.getTime() ?? 0));
250
- if (sorted.length < 2) {
251
- console.error(`no previous version found for ${name}`);
252
- process.exit(1);
253
- }
254
- const prev = sorted[1];
255
- const { Body } = await s3.send(new client_s3_1.GetObjectCommand({ Bucket: b, Key: key, VersionId: prev.VersionId }));
256
- const source = await Body.transformToString();
257
- await s3.send(new client_s3_1.PutObjectCommand({ Bucket: b, Key: key, Body: source, ContentType: 'application/javascript' }));
258
- console.log(`↩ ${name} restored to ${prev.LastModified?.toISOString()}`);
259
- });
260
288
  program.parse();
package/dist/init.js CHANGED
@@ -10,18 +10,16 @@ const node_crypto_1 = require("node:crypto");
10
10
  const node_fs_1 = require("node:fs");
11
11
  const node_path_1 = require("node:path");
12
12
  const node_os_1 = require("node:os");
13
- const ROLE = 'zap-runtime-role';
14
- const FUNCTION = 'zap-runtime';
15
- const TABLE = 'zap-kv';
13
+ const sfx = (env) => env === 'prod' ? '' : `-${env}`;
16
14
  const TRUST = JSON.stringify({
17
15
  Version: '2012-10-17',
18
16
  Statement: [{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' }],
19
17
  });
20
- const policy = (bucket) => JSON.stringify({
18
+ const policy = (bucket, table) => JSON.stringify({
21
19
  Version: '2012-10-17',
22
20
  Statement: [
23
21
  { Effect: 'Allow', Action: ['s3:GetObject', 's3:ListBucket'], Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`] },
24
- { Effect: 'Allow', Action: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:DeleteItem'], Resource: `arn:aws:dynamodb:*:*:table/${TABLE}` },
22
+ { Effect: 'Allow', Action: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:DeleteItem'], Resource: `arn:aws:dynamodb:*:*:table/${table}` },
25
23
  ],
26
24
  });
27
25
  async function allowPublicUrl(lambda, functionArn) {
@@ -49,16 +47,22 @@ function step(label) {
49
47
  process.stdout.write(` ${label.padEnd(24)}`);
50
48
  return (note = '') => console.log(`✓${note ? ' ' + note : ''}`);
51
49
  }
52
- async function init(region) {
50
+ async function init(region, env = 'prod') {
51
+ const ROLE = `zap-runtime-role${sfx(env)}`;
52
+ const FUNCTION = `zap-runtime${sfx(env)}`;
53
+ const TABLE = `zap-kv${sfx(env)}`;
53
54
  const s3 = new client_s3_1.S3Client({ region });
54
55
  const iam = new client_iam_1.IAMClient({ region: 'us-east-1' });
55
56
  const lambda = new client_lambda_1.LambdaClient({ region });
56
57
  const dynamo = new client_dynamodb_1.DynamoDBClient({ region });
57
- let config = {};
58
+ // Load existing config migrate old flat format to per-env format
59
+ let allConfig = {};
58
60
  try {
59
- config = JSON.parse((0, node_fs_1.readFileSync)('.zaprc', 'utf8'));
61
+ const raw = JSON.parse((0, node_fs_1.readFileSync)('.zaprc', 'utf8'));
62
+ allConfig = raw.bucket ? { prod: raw } : raw;
60
63
  }
61
64
  catch { }
65
+ const config = allConfig[env] ?? {};
62
66
  // Build — compile from source if running in the repo, otherwise use pre-built dist
63
67
  let done = step('packaging runtime');
64
68
  const distDir = (0, node_path_1.join)(__dirname, '.');
@@ -107,7 +111,7 @@ async function init(region) {
107
111
  try {
108
112
  const { Role } = await iam.send(new client_iam_1.GetRoleCommand({ RoleName: ROLE }));
109
113
  roleArn = Role.Arn;
110
- await iam.send(new client_iam_1.PutRolePolicyCommand({ RoleName: ROLE, PolicyName: 'zap-access', PolicyDocument: policy(bucket) }));
114
+ await iam.send(new client_iam_1.PutRolePolicyCommand({ RoleName: ROLE, PolicyName: 'zap-access', PolicyDocument: policy(bucket, TABLE) }));
111
115
  }
112
116
  catch (err) {
113
117
  if (err.name !== 'NoSuchEntityException')
@@ -116,7 +120,7 @@ async function init(region) {
116
120
  const { Role } = await iam.send(new client_iam_1.CreateRoleCommand({ RoleName: ROLE, AssumeRolePolicyDocument: TRUST }));
117
121
  roleArn = Role.Arn;
118
122
  await iam.send(new client_iam_1.AttachRolePolicyCommand({ RoleName: ROLE, PolicyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' }));
119
- await iam.send(new client_iam_1.PutRolePolicyCommand({ RoleName: ROLE, PolicyName: 'zap-access', PolicyDocument: policy(bucket) }));
123
+ await iam.send(new client_iam_1.PutRolePolicyCommand({ RoleName: ROLE, PolicyName: 'zap-access', PolicyDocument: policy(bucket, TABLE) }));
120
124
  }
121
125
  if (isNew) {
122
126
  process.stdout.write('propagating...');
@@ -127,7 +131,7 @@ async function init(region) {
127
131
  // Lambda
128
132
  done = step('deploying lambda');
129
133
  const zip = (0, node_fs_1.readFileSync)(zipPath);
130
- const env = { ZAP_BUCKET: bucket, ZAP_TABLE: TABLE };
134
+ const lambdaEnv = { ZAP_BUCKET: bucket, ZAP_TABLE: TABLE };
131
135
  let functionArn;
132
136
  try {
133
137
  const { Configuration } = await lambda.send(new client_lambda_1.GetFunctionCommand({ FunctionName: FUNCTION }));
@@ -135,7 +139,7 @@ async function init(region) {
135
139
  await (0, client_lambda_1.waitUntilFunctionUpdated)({ client: lambda, maxWaitTime: 60 }, { FunctionName: FUNCTION });
136
140
  await lambda.send(new client_lambda_1.UpdateFunctionCodeCommand({ FunctionName: FUNCTION, ZipFile: zip }));
137
141
  await (0, client_lambda_1.waitUntilFunctionUpdated)({ client: lambda, maxWaitTime: 60 }, { FunctionName: FUNCTION });
138
- await lambda.send(new client_lambda_1.UpdateFunctionConfigurationCommand({ FunctionName: FUNCTION, Environment: { Variables: env } }));
142
+ await lambda.send(new client_lambda_1.UpdateFunctionConfigurationCommand({ FunctionName: FUNCTION, Environment: { Variables: lambdaEnv } }));
139
143
  }
140
144
  catch (err) {
141
145
  if (err.name !== 'ResourceNotFoundException')
@@ -146,7 +150,7 @@ async function init(region) {
146
150
  Role: roleArn,
147
151
  Handler: 'handler.handler',
148
152
  Code: { ZipFile: zip },
149
- Environment: { Variables: env },
153
+ Environment: { Variables: lambdaEnv },
150
154
  Timeout: 30,
151
155
  MemorySize: 256,
152
156
  }));
@@ -173,6 +177,9 @@ async function init(region) {
173
177
  }
174
178
  await allowPublicUrl(lambda, functionArn);
175
179
  done();
176
- (0, node_fs_1.writeFileSync)('.zaprc', JSON.stringify({ bucket, function: FUNCTION, table: TABLE, region, url, functionArn }, null, 2));
180
+ allConfig[env] = { bucket, function: FUNCTION, table: TABLE, region, url, functionArn };
181
+ (0, node_fs_1.writeFileSync)('.zaprc', JSON.stringify(allConfig, null, 2));
182
+ if (env !== 'prod')
183
+ process.stdout.write(`\n env: ${env}`);
177
184
  console.log(`\n → ${url.trim()}\n`);
178
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirkelliott/zap",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Drop a .zap file in S3. It becomes an API endpoint.",
5
5
  "main": "dist/handler.js",
6
6
  "bin": {
package/demo/live.zap DELETED
@@ -1,4 +0,0 @@
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
- }