@everystack/server 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 +248 -0
- package/package.json +96 -0
- package/src/cdn/compose.ts +235 -0
- package/src/cdn/features.ts +220 -0
- package/src/cdn/index.ts +77 -0
- package/src/db.ts +59 -0
- package/src/image.ts +215 -0
- package/src/index.ts +324 -0
- package/src/migrate.ts +16 -0
- package/src/ssr.ts +425 -0
- package/src/sst-env.d.ts +27 -0
- package/src/stubs.ts +39 -0
- package/src/worker.ts +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# @everystack/server
|
|
2
|
+
|
|
3
|
+
Lambda runtime primitives for building serverless applications with SST. Provides event adapters, routing, database connection, SSR, image processing, migrations, and background job handling.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @everystack/server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Entry Points
|
|
12
|
+
|
|
13
|
+
| Export | Purpose |
|
|
14
|
+
|--------|---------|
|
|
15
|
+
| `@everystack/server` | Lambda handler, router, event adapters, logging |
|
|
16
|
+
| `@everystack/server/db` | SST Resource-linked database connection |
|
|
17
|
+
| `@everystack/server/ssr` | Expo web SSR bundle serving |
|
|
18
|
+
| `@everystack/server/image` | On-demand image resizing via Sharp |
|
|
19
|
+
| `@everystack/server/migrate` | Drizzle migration runner |
|
|
20
|
+
| `@everystack/server/worker` | SQS job queue consumer |
|
|
21
|
+
| `@everystack/server/stubs` | esbuild bundling helpers for client packages |
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { createLambdaHandler } from '@everystack/server';
|
|
27
|
+
import { createDb, getJwtSecret } from '@everystack/server/db';
|
|
28
|
+
import { runMigrations } from '@everystack/server/migrate';
|
|
29
|
+
import { createWorkerHandler } from '@everystack/server/worker';
|
|
30
|
+
import * as schema from '../db/schema';
|
|
31
|
+
|
|
32
|
+
const { db } = createDb(schema);
|
|
33
|
+
|
|
34
|
+
// API Lambda
|
|
35
|
+
export const handler = createLambdaHandler({
|
|
36
|
+
init: async () => ({
|
|
37
|
+
api: createApiHandler(db),
|
|
38
|
+
}),
|
|
39
|
+
routes: (h) => [
|
|
40
|
+
{ path: '/api', handler: h.api },
|
|
41
|
+
],
|
|
42
|
+
onAction: async (action, payload) => {
|
|
43
|
+
// CLI invocations via IAM-authed Lambda invoke
|
|
44
|
+
if (action === 'migrate') return runMigrations(db, './drizzle');
|
|
45
|
+
if (action === 'seed') return runSeed(db);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Worker Lambda
|
|
50
|
+
export const worker = createWorkerHandler(async () => ({
|
|
51
|
+
'email:send': async (payload) => { /* ... */ },
|
|
52
|
+
'image:process': async (payload) => { /* ... */ },
|
|
53
|
+
}));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Main Export (`@everystack/server`)
|
|
57
|
+
|
|
58
|
+
### `createLambdaHandler(options)`
|
|
59
|
+
|
|
60
|
+
The primary entry point. Wraps initialization, routing, and error handling into a Lambda handler.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
interface LambdaHandlerOptions {
|
|
64
|
+
init: () => Promise<Record<string, Handler>>;
|
|
65
|
+
routes: (handlers: Record<string, Handler>) => Route[];
|
|
66
|
+
fallback?: (handlers: Record<string, Handler>) => Handler;
|
|
67
|
+
onAction?: (action: string, payload: unknown, handlers: Record<string, Handler>) => Promise<unknown>;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Behavior:**
|
|
72
|
+
- Lazy-initializes handlers once, caches across warm invocations
|
|
73
|
+
- Detects CLI invocations (events with `_action` field) and dispatches to `onAction`
|
|
74
|
+
- Sets Cache-Control: `private, no-store` (authenticated) or `public, s-maxage=300` (public GET)
|
|
75
|
+
- Guards response size (5MB max for Function URLs)
|
|
76
|
+
- Adds `x-request-id` from Lambda trace or UUID
|
|
77
|
+
|
|
78
|
+
### `createRouter(routes, fallback?)`
|
|
79
|
+
|
|
80
|
+
Path/method dispatcher.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
interface Route {
|
|
84
|
+
path: string; // URL prefix match
|
|
85
|
+
method?: string; // HTTP method filter (optional)
|
|
86
|
+
exact?: boolean; // Exact path match (default: prefix)
|
|
87
|
+
handler: Handler;
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Routes match by prefix unless `exact: true`. First match wins.
|
|
92
|
+
|
|
93
|
+
### `eventToRequest(event)`
|
|
94
|
+
|
|
95
|
+
Converts AWS API Gateway V2 events to Web Standard `Request` objects.
|
|
96
|
+
|
|
97
|
+
### `responseToResult(response)`
|
|
98
|
+
|
|
99
|
+
Converts Web Standard `Response` to API Gateway V2 results. Auto-detects and base64-encodes binary content.
|
|
100
|
+
|
|
101
|
+
### `log(level, message, meta?)`
|
|
102
|
+
|
|
103
|
+
Structured JSON logging to stdout with ISO timestamp.
|
|
104
|
+
|
|
105
|
+
## Database (`@everystack/server/db`)
|
|
106
|
+
|
|
107
|
+
SST Resource-linked database helpers. All credentials come from SST's Resource linking — no env vars needed.
|
|
108
|
+
|
|
109
|
+
### `createDb(schema, options?)`
|
|
110
|
+
|
|
111
|
+
Lazy singleton Drizzle connection via postgres.js.
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
const { db } = createDb(schema, { maxConnections: 1 });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Pool defaults: max 1, idle timeout 60s, connect timeout 5s, max lifetime 5min, SSL required.
|
|
118
|
+
|
|
119
|
+
### `getDatabaseUrl()`
|
|
120
|
+
|
|
121
|
+
Returns PostgreSQL connection URL from SST Resources.
|
|
122
|
+
|
|
123
|
+
### `getJwtSecret()`
|
|
124
|
+
|
|
125
|
+
Returns JWT secret as `Uint8Array` from `Resource.JwtSecret.value`.
|
|
126
|
+
|
|
127
|
+
## Web SSR (`@everystack/server/ssr`)
|
|
128
|
+
|
|
129
|
+
Serves Expo web builds deployed via `@everystack/cli`.
|
|
130
|
+
|
|
131
|
+
### `getWebHandler(db, storage)`
|
|
132
|
+
|
|
133
|
+
Returns a Request→Response handler for the latest web release.
|
|
134
|
+
|
|
135
|
+
- Queries database for latest `platform='web'` release
|
|
136
|
+
- Downloads and extracts brotli-compressed tar archive to `/tmp/web-build/`
|
|
137
|
+
- Caches handler with 60s TTL (re-queries for new releases)
|
|
138
|
+
- Returns `null` if no web release exists
|
|
139
|
+
|
|
140
|
+
### `downloadAndExtract(storage, storagePrefix)`
|
|
141
|
+
|
|
142
|
+
Downloads `{prefix}/bundle.tar.br` from storage, decompresses with brotli, extracts to `/tmp/web-build/`.
|
|
143
|
+
|
|
144
|
+
### `extractTar(data, destDir)`
|
|
145
|
+
|
|
146
|
+
Pure JavaScript POSIX tar extractor. Required because Lambda doesn't include the `tar` binary.
|
|
147
|
+
|
|
148
|
+
## Image Processing (`@everystack/server/image`)
|
|
149
|
+
|
|
150
|
+
On-demand image resizing via Sharp (provided as Lambda layer).
|
|
151
|
+
|
|
152
|
+
### `createImageHandler(config)`
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
interface ImageHandlerConfig {
|
|
156
|
+
bucket: string; // S3 bucket name
|
|
157
|
+
region?: string; // AWS region (default: AWS_REGION env)
|
|
158
|
+
pathPrefix?: string; // URL prefix (default: '/media/')
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
URL format: `/media/{key}?w=400&h=300&fit=cover&fm=webp&q=80&dpr=2`
|
|
163
|
+
|
|
164
|
+
**Behavior:**
|
|
165
|
+
- No transforms → 302 redirect to presigned S3 URL (avoids 6MB Lambda limit)
|
|
166
|
+
- With transforms → fetch from S3, process with Sharp, return with immutable cache headers
|
|
167
|
+
|
|
168
|
+
### `parseParams(query)`
|
|
169
|
+
|
|
170
|
+
Validates URL query params into transform options:
|
|
171
|
+
|
|
172
|
+
| Param | Range | Description |
|
|
173
|
+
|-------|-------|-------------|
|
|
174
|
+
| `w` | 1–4096 | Width |
|
|
175
|
+
| `h` | 1–4096 | Height |
|
|
176
|
+
| `fit` | cover, contain, fill, inside, outside | Resize fit |
|
|
177
|
+
| `fm` | webp, jpeg, png, avif | Output format |
|
|
178
|
+
| `q` | 1–100 | Quality |
|
|
179
|
+
| `dpr` | 1–3 | Device pixel ratio (multiplies w/h) |
|
|
180
|
+
|
|
181
|
+
## Migrations (`@everystack/server/migrate`)
|
|
182
|
+
|
|
183
|
+
### `runMigrations(db, migrationsFolder)`
|
|
184
|
+
|
|
185
|
+
Runs Drizzle migrations from the specified folder. Called via `onAction` handler (CLI → IAM → Lambda invoke).
|
|
186
|
+
|
|
187
|
+
- Returns `{ success: true, message: 'Migrations applied' }`
|
|
188
|
+
- Throws on error (caller handles response)
|
|
189
|
+
|
|
190
|
+
## Worker (`@everystack/server/worker`)
|
|
191
|
+
|
|
192
|
+
### `createWorkerHandler(init)`
|
|
193
|
+
|
|
194
|
+
SQS Lambda handler with type-based job dispatch.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
type JobHandler = (payload: any, job: { id: string; type: string }) => Promise<void>;
|
|
198
|
+
|
|
199
|
+
const worker = createWorkerHandler(async () => ({
|
|
200
|
+
'email:send': async (payload) => { /* ... */ },
|
|
201
|
+
'image:process': async (payload) => { /* ... */ },
|
|
202
|
+
}));
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- Lazy-initializes handlers, caches across warm invocations
|
|
206
|
+
- Parses SQS message body as `{ jobId, type, payload }`
|
|
207
|
+
- Returns `batchItemFailures` for partial failure handling (failed messages retry)
|
|
208
|
+
|
|
209
|
+
## Stubs (`@everystack/server/stubs`)
|
|
210
|
+
|
|
211
|
+
### `clientStubs`
|
|
212
|
+
|
|
213
|
+
Array of client-side package names to exclude from Lambda bundles via esbuild `external`.
|
|
214
|
+
|
|
215
|
+
### `stubsDir`
|
|
216
|
+
|
|
217
|
+
Path to no-op module stubs. Used with SST `copyFiles` to prevent "module not found" errors when server code transitively imports client packages.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// sst.config.ts
|
|
221
|
+
import { clientStubs, stubsDir } from '@everystack/server/stubs';
|
|
222
|
+
|
|
223
|
+
new sst.aws.Function('Api', {
|
|
224
|
+
handler: 'server/api.handler',
|
|
225
|
+
nodejs: {
|
|
226
|
+
esbuild: { external: clientStubs },
|
|
227
|
+
},
|
|
228
|
+
copyFiles: [{ from: stubsDir, to: 'stubs' }],
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Peer Dependencies
|
|
233
|
+
|
|
234
|
+
- `sst` — Resource linking for credentials
|
|
235
|
+
- `drizzle-orm` — Database ORM
|
|
236
|
+
- `postgres` — PostgreSQL driver
|
|
237
|
+
- `sharp` — Image processing (optional, Lambda layer)
|
|
238
|
+
- `@aws-sdk/client-s3` — S3 operations (image handler)
|
|
239
|
+
- `@aws-sdk/s3-request-presigner` — Presigned URLs (image handler)
|
|
240
|
+
- `@everystack/cli` — Storage adapter (SSR handler)
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@everystack/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./db": {
|
|
20
|
+
"types": "./src/db.ts",
|
|
21
|
+
"default": "./src/db.ts"
|
|
22
|
+
},
|
|
23
|
+
"./ssr": {
|
|
24
|
+
"types": "./src/ssr.ts",
|
|
25
|
+
"default": "./src/ssr.ts"
|
|
26
|
+
},
|
|
27
|
+
"./image": {
|
|
28
|
+
"types": "./src/image.ts",
|
|
29
|
+
"default": "./src/image.ts"
|
|
30
|
+
},
|
|
31
|
+
"./migrate": {
|
|
32
|
+
"types": "./src/migrate.ts",
|
|
33
|
+
"default": "./src/migrate.ts"
|
|
34
|
+
},
|
|
35
|
+
"./worker": {
|
|
36
|
+
"types": "./src/worker.ts",
|
|
37
|
+
"default": "./src/worker.ts"
|
|
38
|
+
},
|
|
39
|
+
"./stubs": {
|
|
40
|
+
"types": "./src/stubs.ts",
|
|
41
|
+
"default": "./src/stubs.ts"
|
|
42
|
+
},
|
|
43
|
+
"./cdn": {
|
|
44
|
+
"types": "./src/cdn/index.ts",
|
|
45
|
+
"default": "./src/cdn/index.ts"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@aws-sdk/client-s3": ">=3.0.0",
|
|
50
|
+
"@aws-sdk/s3-request-presigner": ">=3.0.0",
|
|
51
|
+
"@everystack/auth": ">=0.1.0",
|
|
52
|
+
"@everystack/cli": ">=0.1.0",
|
|
53
|
+
"drizzle-orm": ">=0.30.0",
|
|
54
|
+
"postgres": ">=3.0.0",
|
|
55
|
+
"sharp": ">=0.33.0",
|
|
56
|
+
"sst": ">=3.0.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"sst": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@everystack/auth": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@everystack/cli": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"sharp": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"@aws-sdk/client-s3": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"@aws-sdk/s3-request-presigner": {
|
|
75
|
+
"optional": true
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@types/aws-lambda": "^8.10.145",
|
|
80
|
+
"@types/jest": "^29.5.14",
|
|
81
|
+
"@types/node": "^22.0.0",
|
|
82
|
+
"drizzle-orm": "^0.41.0",
|
|
83
|
+
"jest": "^29.7.0",
|
|
84
|
+
"postgres": "^3.4.0",
|
|
85
|
+
"sst": "^4.13.1",
|
|
86
|
+
"ts-jest": "^29.3.0",
|
|
87
|
+
"typescript": "^5.7.0",
|
|
88
|
+
"@everystack/auth": "0.1.0",
|
|
89
|
+
"@everystack/cli": "0.1.0"
|
|
90
|
+
},
|
|
91
|
+
"scripts": {
|
|
92
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
93
|
+
"build": "tsc --build",
|
|
94
|
+
"lint": "tsc --noEmit"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* composeViewerRequest — multi-feature CloudFront Functions composer.
|
|
3
|
+
*
|
|
4
|
+
* Turns a list of `Feature` records into a single CFF runtime 2.0 source
|
|
5
|
+
* string, deduping shared helpers, namespacing per-feature state, ordering
|
|
6
|
+
* per-request bodies, wrapping everything in a fail-closed try/catch, and
|
|
7
|
+
* (by default) running a safe minifier with a 10 KB size budget.
|
|
8
|
+
*
|
|
9
|
+
* Output is the BODY for an SST `injection` (i.e. it goes inside an existing
|
|
10
|
+
* `function handler(event) { ... return event.request; }`), so helpers and
|
|
11
|
+
* state are nested function/var declarations rather than top-level.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface Feature {
|
|
15
|
+
/** Unique feature name. Used to namespace state vars as `__<name>_<key>`. */
|
|
16
|
+
name: string;
|
|
17
|
+
/**
|
|
18
|
+
* Helper functions/vars shared with other features. Keys are dedupe IDs;
|
|
19
|
+
* values are full source ('function foo(){...}' or 'var T = ...;'). When
|
|
20
|
+
* two features register the same key, sources MUST be byte-identical or
|
|
21
|
+
* the composer throws.
|
|
22
|
+
*/
|
|
23
|
+
helpers?: Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* JSON-serializable constants emitted as `var __<feature>_<key> = JSON;`.
|
|
26
|
+
*/
|
|
27
|
+
state?: Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Per-request code. Runs inside the wrapping try{}, in `order`. May
|
|
30
|
+
* `return <response>` to short-circuit downstream features.
|
|
31
|
+
*/
|
|
32
|
+
body: string;
|
|
33
|
+
/** Smaller runs first. Default 100. Stable for ties. */
|
|
34
|
+
order?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ComposeOptions {
|
|
38
|
+
features: Feature[];
|
|
39
|
+
/** Default true. */
|
|
40
|
+
minify?: boolean;
|
|
41
|
+
/** CFF runtime 2.0 source-size hard limit. Default 10240. */
|
|
42
|
+
maxBytes?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ComposeResult {
|
|
46
|
+
code: string;
|
|
47
|
+
bytes: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_MAX_BYTES = 10 * 1024;
|
|
51
|
+
const DEFAULT_ORDER = 100;
|
|
52
|
+
|
|
53
|
+
export function composeViewerRequest(opts: ComposeOptions): ComposeResult {
|
|
54
|
+
const features = opts.features;
|
|
55
|
+
const minify = opts.minify !== false;
|
|
56
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
57
|
+
|
|
58
|
+
// 1. Dedupe helpers across features. Conflict (same key, different source)
|
|
59
|
+
// is a hard error — silently picking one would mask a real bug.
|
|
60
|
+
const helpers = new Map<string, string>();
|
|
61
|
+
for (const f of features) {
|
|
62
|
+
if (!f.helpers) continue;
|
|
63
|
+
for (const [k, src] of Object.entries(f.helpers)) {
|
|
64
|
+
const existing = helpers.get(k);
|
|
65
|
+
if (existing !== undefined && existing !== src) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`composeViewerRequest: conflicting helper '${k}' between features (one of: ${f.name})`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
helpers.set(k, src);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. Emit state as namespaced vars.
|
|
75
|
+
const stateLines: string[] = [];
|
|
76
|
+
for (const f of features) {
|
|
77
|
+
if (!f.state) continue;
|
|
78
|
+
for (const [k, v] of Object.entries(f.state)) {
|
|
79
|
+
stateLines.push(`var __${f.name}_${k} = ${JSON.stringify(v)};`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. Stable-sort bodies by `order`.
|
|
84
|
+
const ordered = features
|
|
85
|
+
.map((f, i) => ({ f, i, order: f.order ?? DEFAULT_ORDER }))
|
|
86
|
+
.sort((a, b) => (a.order - b.order) || (a.i - b.i))
|
|
87
|
+
.map((x) => x.f);
|
|
88
|
+
|
|
89
|
+
const bodyParts = ordered.map((f) => `// --- ${f.name} ---\n${f.body}`).join('\n');
|
|
90
|
+
|
|
91
|
+
// 4. Wrap in fail-closed try/catch.
|
|
92
|
+
// Any uncaught throw becomes a 401 — fail closed at the edge.
|
|
93
|
+
const composed = [
|
|
94
|
+
...Array.from(helpers.values()),
|
|
95
|
+
...stateLines,
|
|
96
|
+
'try {',
|
|
97
|
+
bodyParts,
|
|
98
|
+
'} catch (__e) {',
|
|
99
|
+
' return { statusCode: 401, statusDescription: "Unauthorized", headers: { "content-type": { value: "application/json" }, "cache-control": { value: "no-store" } }, body: { encoding: "text", data: JSON.stringify({ message: "Edge auth error" }) } };',
|
|
100
|
+
'}',
|
|
101
|
+
].join('\n');
|
|
102
|
+
|
|
103
|
+
const code = minify ? minifyCff(composed) : composed;
|
|
104
|
+
const bytes = Buffer.byteLength(code, 'utf8');
|
|
105
|
+
|
|
106
|
+
if (bytes > maxBytes) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`composeViewerRequest: output (${bytes} bytes) exceeds max (${maxBytes} bytes). ` +
|
|
109
|
+
`CloudFront Functions runtime 2.0 hard limit is 10 KB compiled.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { code, bytes };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Safe minifier for CFF source.
|
|
118
|
+
*
|
|
119
|
+
* Strips // and /* *\/ comments and collapses runs of whitespace, while
|
|
120
|
+
* preserving the contents of string literals (', ", `) and regex literals.
|
|
121
|
+
* Does NOT mangle identifiers (CFF runtime API surface like
|
|
122
|
+
* `event.request.headers.authorization.value` must survive intact).
|
|
123
|
+
*
|
|
124
|
+
* Hand-rolled tokenizer rather than terser so we ship zero new runtime deps
|
|
125
|
+
* and keep deploy-time evaluation fast.
|
|
126
|
+
*/
|
|
127
|
+
export function minifyCff(src: string): string {
|
|
128
|
+
let out = '';
|
|
129
|
+
let i = 0;
|
|
130
|
+
const n = src.length;
|
|
131
|
+
|
|
132
|
+
// Track previous non-whitespace char to disambiguate `/` (regex vs divide).
|
|
133
|
+
let prevSig = '';
|
|
134
|
+
|
|
135
|
+
while (i < n) {
|
|
136
|
+
const c = src[i];
|
|
137
|
+
const c2 = src[i + 1];
|
|
138
|
+
|
|
139
|
+
// Line comment.
|
|
140
|
+
if (c === '/' && c2 === '/') {
|
|
141
|
+
while (i < n && src[i] !== '\n') i++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Block comment.
|
|
146
|
+
if (c === '/' && c2 === '*') {
|
|
147
|
+
i += 2;
|
|
148
|
+
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
|
|
149
|
+
i += 2;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// String literal — copy verbatim, handle escapes.
|
|
154
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
155
|
+
const quote = c;
|
|
156
|
+
out += c;
|
|
157
|
+
i++;
|
|
158
|
+
while (i < n) {
|
|
159
|
+
const ch = src[i];
|
|
160
|
+
out += ch;
|
|
161
|
+
if (ch === '\\' && i + 1 < n) {
|
|
162
|
+
out += src[i + 1];
|
|
163
|
+
i += 2;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
i++;
|
|
167
|
+
if (ch === quote) break;
|
|
168
|
+
}
|
|
169
|
+
prevSig = quote;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Regex literal — only after operators / keywords where regex is valid.
|
|
174
|
+
if (c === '/' && isRegexContext(prevSig)) {
|
|
175
|
+
out += c;
|
|
176
|
+
i++;
|
|
177
|
+
while (i < n) {
|
|
178
|
+
const ch = src[i];
|
|
179
|
+
out += ch;
|
|
180
|
+
if (ch === '\\' && i + 1 < n) {
|
|
181
|
+
out += src[i + 1];
|
|
182
|
+
i += 2;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
i++;
|
|
186
|
+
if (ch === '/') break;
|
|
187
|
+
}
|
|
188
|
+
// Flags.
|
|
189
|
+
while (i < n && /[a-z]/i.test(src[i])) {
|
|
190
|
+
out += src[i];
|
|
191
|
+
i++;
|
|
192
|
+
}
|
|
193
|
+
prevSig = '/';
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Whitespace collapse.
|
|
198
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
199
|
+
// Look ahead for the next non-whitespace char.
|
|
200
|
+
let j = i + 1;
|
|
201
|
+
while (j < n && (src[j] === ' ' || src[j] === '\t' || src[j] === '\n' || src[j] === '\r')) j++;
|
|
202
|
+
const nextCh = j < n ? src[j] : '';
|
|
203
|
+
// Keep a single space only if removing it would fuse two identifier/number chars.
|
|
204
|
+
if (nextCh && needsSeparator(prevSig, nextCh)) {
|
|
205
|
+
out += ' ';
|
|
206
|
+
}
|
|
207
|
+
i = j;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
out += c;
|
|
212
|
+
prevSig = c;
|
|
213
|
+
i++;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Identifier-like = [A-Za-z0-9_$]
|
|
220
|
+
function needsSeparator(a: string, b: string): boolean {
|
|
221
|
+
if (!a || !b) return false;
|
|
222
|
+
return isIdentChar(a) && isIdentChar(b);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isIdentChar(c: string): boolean {
|
|
226
|
+
return /[A-Za-z0-9_$]/.test(c);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Heuristic: a `/` is a regex literal start when the previous non-whitespace
|
|
230
|
+
// token is an operator, opening bracket/brace/paren, comma, semicolon, or
|
|
231
|
+
// the empty string (start of body). Otherwise it's division.
|
|
232
|
+
function isRegexContext(prev: string): boolean {
|
|
233
|
+
if (prev === '') return true;
|
|
234
|
+
return /[=([{,;!&|?:+\-*%^~<>]/.test(prev);
|
|
235
|
+
}
|