@everystack/server 0.2.12 → 0.2.13
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 +55 -0
- package/package.json +5 -1
- package/src/media.ts +126 -0
- package/src/plugin.ts +19 -6
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ pnpm add @everystack/server
|
|
|
16
16
|
| `@everystack/server/db` | SST Resource-linked database connection |
|
|
17
17
|
| `@everystack/server/ssr` | Expo web SSR bundle serving |
|
|
18
18
|
| `@everystack/server/image` | On-demand image resizing via Sharp |
|
|
19
|
+
| `@everystack/server/media` | Fingerprinted media asset URL resolver |
|
|
19
20
|
| `@everystack/server/migrate` | Drizzle migration runner |
|
|
20
21
|
| `@everystack/server/worker` | SQS job queue consumer |
|
|
21
22
|
| `@everystack/server/stubs` | esbuild bundling helpers for client packages |
|
|
@@ -212,6 +213,60 @@ Validates URL query params into transform options:
|
|
|
212
213
|
| `q` | 1–100 | Quality |
|
|
213
214
|
| `dpr` | 1–3 | Device pixel ratio (multiplies w/h) |
|
|
214
215
|
|
|
216
|
+
## Media Assets (`@everystack/server/media`)
|
|
217
|
+
|
|
218
|
+
Resolves logical media asset paths to fingerprinted CDN URLs. During `everystack update`, media assets are uploaded with content-hash suffixes (e.g., `logo-a1b2c3d4.png`) and a `media-manifest.json` is written to the media bucket. This module loads that manifest and resolves URLs at runtime.
|
|
219
|
+
|
|
220
|
+
### `createMediaResolver(config)`
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
import { createMediaResolver } from '@everystack/server/media';
|
|
224
|
+
|
|
225
|
+
const media = createMediaResolver({
|
|
226
|
+
bucket: Resource.Media.name, // S3 bucket with media-manifest.json
|
|
227
|
+
cdnUrl: process.env.CDN_URL, // CDN base URL (no trailing slash)
|
|
228
|
+
ttlMs: 60_000, // Manifest cache TTL (default: 1 minute)
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### `resolve(logicalPath)`
|
|
233
|
+
|
|
234
|
+
Async. Returns the fingerprinted CDN URL. Falls back to the logical path if the manifest is unavailable or the path isn't mapped.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
const logoUrl = await media.resolve('email/logo.png');
|
|
238
|
+
// → "https://cdn.example.com/email/logo-a1b2c3d4.png"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### `resolveSync(logicalPath)`
|
|
242
|
+
|
|
243
|
+
Sync. Uses the cached manifest only. Returns `null` if no manifest has been loaded yet.
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
const url = media.resolveSync('og/default.png');
|
|
247
|
+
// → "https://cdn.example.com/og/default-f6e5d4c3.png" or null
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### `preload()`
|
|
251
|
+
|
|
252
|
+
Loads the manifest during Lambda init. Call this in `createLambdaHandler`'s `init` to amortize the S3 call across all requests.
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
export const handler = createLambdaHandler({
|
|
256
|
+
init: async () => {
|
|
257
|
+
await media.preload();
|
|
258
|
+
return { api };
|
|
259
|
+
},
|
|
260
|
+
// ...
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Caching behavior:**
|
|
265
|
+
- Manifest cached in module scope across warm Lambda invocations
|
|
266
|
+
- After TTL expires, revalidates via `HeadObject` ETag comparison (one cheap HEAD call)
|
|
267
|
+
- Full re-fetch only when the manifest content changes
|
|
268
|
+
- S3 errors return stale cache gracefully
|
|
269
|
+
|
|
215
270
|
## Migrations (`@everystack/server/migrate`)
|
|
216
271
|
|
|
217
272
|
### `runMigrations(db, migrationsFolder)`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
"./kvs": {
|
|
52
52
|
"types": "./src/kvs.ts",
|
|
53
53
|
"default": "./src/kvs.ts"
|
|
54
|
+
},
|
|
55
|
+
"./media": {
|
|
56
|
+
"types": "./src/media.ts",
|
|
57
|
+
"default": "./src/media.ts"
|
|
54
58
|
}
|
|
55
59
|
},
|
|
56
60
|
"scripts": {
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server/media — Fingerprinted media asset URL resolver.
|
|
3
|
+
*
|
|
4
|
+
* Loads the media manifest from S3 and resolves logical asset names
|
|
5
|
+
* to fingerprinted CDN URLs. Lazy-loaded, cached with TTL for
|
|
6
|
+
* revalidation on new deploys.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const media = createMediaResolver({ bucket: Resource.Media.name, cdnUrl: process.env.CDN_URL });
|
|
10
|
+
* await media.preload();
|
|
11
|
+
* const url = await media.resolve('email/logo.png');
|
|
12
|
+
* // → "https://cdn.example.com/email/logo-a1b2c3d4.png"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface MediaManifest {
|
|
16
|
+
version: number;
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
files: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MediaResolverConfig {
|
|
22
|
+
/** S3 bucket name for media assets. */
|
|
23
|
+
bucket: string;
|
|
24
|
+
/** AWS region (defaults to process.env.AWS_REGION). */
|
|
25
|
+
region?: string;
|
|
26
|
+
/** CDN base URL (e.g., 'https://cdn.example.com'). No trailing slash. */
|
|
27
|
+
cdnUrl?: string;
|
|
28
|
+
/** Manifest TTL in milliseconds. Default: 60_000 (1 minute). */
|
|
29
|
+
ttlMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CachedManifest {
|
|
33
|
+
manifest: MediaManifest;
|
|
34
|
+
etag: string;
|
|
35
|
+
loadedAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createMediaResolver(config: MediaResolverConfig): MediaResolver {
|
|
39
|
+
const {
|
|
40
|
+
bucket,
|
|
41
|
+
ttlMs = 60_000,
|
|
42
|
+
} = config;
|
|
43
|
+
const region = config.region || process.env.AWS_REGION;
|
|
44
|
+
const cdnUrl = (config.cdnUrl || '').replace(/\/$/, '');
|
|
45
|
+
|
|
46
|
+
let cached: CachedManifest | null = null;
|
|
47
|
+
|
|
48
|
+
async function loadManifest(): Promise<MediaManifest | null> {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
|
|
51
|
+
if (cached && (now - cached.loadedAt) < ttlMs) {
|
|
52
|
+
return cached.manifest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const { S3Client, GetObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3');
|
|
57
|
+
const client = new S3Client({ region });
|
|
58
|
+
|
|
59
|
+
// ETag-based revalidation: cheap HEAD instead of full GET
|
|
60
|
+
if (cached) {
|
|
61
|
+
const head = await client.send(new HeadObjectCommand({
|
|
62
|
+
Bucket: bucket,
|
|
63
|
+
Key: 'media-manifest.json',
|
|
64
|
+
}));
|
|
65
|
+
if (head.ETag === cached.etag) {
|
|
66
|
+
cached.loadedAt = now;
|
|
67
|
+
return cached.manifest;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const response = await client.send(new GetObjectCommand({
|
|
72
|
+
Bucket: bucket,
|
|
73
|
+
Key: 'media-manifest.json',
|
|
74
|
+
}));
|
|
75
|
+
if (!response.Body) return cached?.manifest ?? null;
|
|
76
|
+
|
|
77
|
+
const bytes = await response.Body.transformToByteArray();
|
|
78
|
+
const manifest: MediaManifest = JSON.parse(
|
|
79
|
+
Buffer.from(bytes).toString('utf8'),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
cached = {
|
|
83
|
+
manifest,
|
|
84
|
+
etag: response.ETag || '',
|
|
85
|
+
loadedAt: now,
|
|
86
|
+
};
|
|
87
|
+
return manifest;
|
|
88
|
+
} catch {
|
|
89
|
+
// S3 error — return stale cache if available
|
|
90
|
+
return cached?.manifest ?? null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async resolve(logicalPath: string): Promise<string> {
|
|
96
|
+
const manifest = await loadManifest();
|
|
97
|
+
const resolved = manifest?.files[logicalPath] ?? logicalPath;
|
|
98
|
+
return cdnUrl ? `${cdnUrl}/${resolved}` : `/${resolved}`;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
resolveSync(logicalPath: string): string | null {
|
|
102
|
+
if (!cached) return null;
|
|
103
|
+
const resolved = cached.manifest.files[logicalPath] ?? logicalPath;
|
|
104
|
+
return cdnUrl ? `${cdnUrl}/${resolved}` : `/${resolved}`;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async preload(): Promise<void> {
|
|
108
|
+
await loadManifest();
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
clearCache(): void {
|
|
112
|
+
cached = null;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface MediaResolver {
|
|
118
|
+
/** Resolve a logical media path to a fingerprinted CDN URL. Falls back to logical path if manifest unavailable. */
|
|
119
|
+
resolve(logicalPath: string): Promise<string>;
|
|
120
|
+
/** Sync resolve from cached manifest. Returns null if no manifest cached. */
|
|
121
|
+
resolveSync(logicalPath: string): string | null;
|
|
122
|
+
/** Pre-load the manifest. Call during Lambda init. */
|
|
123
|
+
preload(): Promise<void>;
|
|
124
|
+
/** Clear the cached manifest (for testing). */
|
|
125
|
+
clearCache(): void;
|
|
126
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -504,6 +504,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
504
504
|
ctx,
|
|
505
505
|
db, schema: ctx.schema,
|
|
506
506
|
eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc,
|
|
507
|
+
current_user: payloadUser ?? null,
|
|
507
508
|
...models,
|
|
508
509
|
...authHelpers,
|
|
509
510
|
};
|
|
@@ -519,8 +520,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
519
520
|
|
|
520
521
|
let result: unknown;
|
|
521
522
|
|
|
522
|
-
if (
|
|
523
|
-
|
|
523
|
+
if (options.pgSettings) {
|
|
524
|
+
// Always apply pgSettings when configured — enforces RLS.
|
|
525
|
+
// Without a logged-in user, pgSettings(null) returns anon role settings.
|
|
526
|
+
const settings = options.pgSettings(payloadUser ?? null);
|
|
524
527
|
|
|
525
528
|
async function applySettings(tx: any) {
|
|
526
529
|
const role = settings['role'];
|
|
@@ -538,7 +541,6 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
538
541
|
}
|
|
539
542
|
|
|
540
543
|
if (sandbox) {
|
|
541
|
-
// pgSettings + sandbox: apply settings, eval, capture result, rollback
|
|
542
544
|
try {
|
|
543
545
|
await ctx.db.transaction(async (tx: any) => {
|
|
544
546
|
await applySettings(tx);
|
|
@@ -549,14 +551,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
549
551
|
if (err !== SANDBOX_ROLLBACK) throw err;
|
|
550
552
|
}
|
|
551
553
|
} else {
|
|
552
|
-
// pgSettings without sandbox: normal transaction
|
|
553
554
|
result = await ctx.db.transaction(async (tx: any) => {
|
|
554
555
|
await applySettings(tx);
|
|
555
556
|
return evalExpression(tx);
|
|
556
557
|
});
|
|
557
558
|
}
|
|
558
559
|
} else if (sandbox) {
|
|
559
|
-
// Sandbox without auth: wrap in transaction and rollback
|
|
560
560
|
try {
|
|
561
561
|
await ctx.db.transaction(async (tx: any) => {
|
|
562
562
|
result = await evalExpression(tx);
|
|
@@ -569,8 +569,21 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
569
569
|
result = await evalExpression(ctx.db);
|
|
570
570
|
}
|
|
571
571
|
|
|
572
|
+
// Safe-serialize: handle circular refs, Symbols, and complex objects
|
|
573
|
+
// that Lambda can't JSON.stringify (e.g. Drizzle schema objects)
|
|
574
|
+
let safeResult: unknown;
|
|
575
|
+
try {
|
|
576
|
+
JSON.stringify(result);
|
|
577
|
+
safeResult = result;
|
|
578
|
+
} catch {
|
|
579
|
+
// Not JSON-serializable — return a useful string representation
|
|
580
|
+
safeResult = typeof result === 'object' && result !== null
|
|
581
|
+
? `[${result.constructor?.name ?? 'Object'}: keys=${Object.keys(result).join(', ')}]`
|
|
582
|
+
: String(result);
|
|
583
|
+
}
|
|
584
|
+
|
|
572
585
|
// Build response with optional auth side-channel
|
|
573
|
-
const response: Record<string, unknown> = { result };
|
|
586
|
+
const response: Record<string, unknown> = { result: safeResult };
|
|
574
587
|
if (authSignal.changed) {
|
|
575
588
|
response._auth = { user: authSignal.user };
|
|
576
589
|
}
|