@everystack/server 0.4.5 → 0.4.8
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/package.json +6 -2
- package/src/db.ts +18 -16
- package/src/plugin.ts +67 -2
- package/src/sst-resources.ts +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
"types": "./src/plugin.ts",
|
|
62
62
|
"default": "./src/plugin.ts"
|
|
63
63
|
},
|
|
64
|
+
"./provision": {
|
|
65
|
+
"types": "./src/provision.ts",
|
|
66
|
+
"default": "./src/provision.ts"
|
|
67
|
+
},
|
|
64
68
|
"./cdn": {
|
|
65
69
|
"types": "./src/cdn/index.ts",
|
|
66
70
|
"default": "./src/cdn/index.ts"
|
|
@@ -151,8 +155,8 @@
|
|
|
151
155
|
"sst": "4.13.1",
|
|
152
156
|
"ts-jest": "29.4.9",
|
|
153
157
|
"typescript": "5.9.3",
|
|
158
|
+
"@everystack/cli": "0.4.22",
|
|
154
159
|
"@everystack/auth": "0.4.0",
|
|
155
|
-
"@everystack/cli": "0.4.19",
|
|
156
160
|
"@everystack/pg-tools": "0.4.0"
|
|
157
161
|
},
|
|
158
162
|
"scripts": {
|
package/src/db.ts
CHANGED
|
@@ -9,27 +9,29 @@
|
|
|
9
9
|
|
|
10
10
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
11
11
|
import postgres from 'postgres';
|
|
12
|
+
import { loadSstResources } from './sst-resources';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Read an SST-linked resource WITHOUT importing the `sst` package.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* version-check plugin mis-reads a node_module's `sst` import and fails the
|
|
21
|
-
* build. So the API/SSR helpers stayed unbundleable in the exact runtime half
|
|
22
|
-
* the framework targets. Returns undefined when the link is absent — every
|
|
15
|
+
* Read an SST-linked resource WITHOUT importing the `sst` package. `sst` is a
|
|
16
|
+
* deploy-time construct: importing it makes this module unbundleable (Metro) and
|
|
17
|
+
* trips SST's own build-time version-check plugin. `loadSstResources` reads every
|
|
18
|
+
* mechanism SST uses to deliver links — per-name env, the bundled JSON, and the
|
|
19
|
+
* encrypted key file (SST >= ~4.13's Lambda default) — so this resolves the same
|
|
20
|
+
* links `Resource.<Name>` would. Returns undefined when the link is absent; every
|
|
23
21
|
* getter then falls through to process.env, unchanged.
|
|
24
22
|
*/
|
|
25
23
|
function sstLink<T = Record<string, any>>(name: string): T | undefined {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
return loadSstResources()[name] as T | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read ANY SST-linked resource by name, without importing `sst` — the
|
|
29
|
+
* bundler-safe replacement for `Resource.<Name>` (e.g. `sstResource('Updates')?.name`
|
|
30
|
+
* for the ssrPlugin's updatesStorage bucket). Undefined when the link is absent,
|
|
31
|
+
* so callers can fall through to process.env or their own config.
|
|
32
|
+
*/
|
|
33
|
+
export function sstResource<T = Record<string, any>>(name: string): T | undefined {
|
|
34
|
+
return sstLink<T>(name);
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function getDatabaseUrl(): string {
|
package/src/plugin.ts
CHANGED
|
@@ -420,6 +420,16 @@ export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext)
|
|
|
420
420
|
if (options.stripQueryParams) ssrOptions.stripQueryParams = options.stripQueryParams;
|
|
421
421
|
if (options.injectHydrateFlag !== undefined) ssrOptions.injectHydrateFlag = options.injectHydrateFlag;
|
|
422
422
|
|
|
423
|
+
// Fail LOUD on the misconfiguration, not silent 404. Without ctx.updatesStorage the SSR
|
|
424
|
+
// fallback can never serve `/` (getWebHandler has no bundle source) — but /api still works,
|
|
425
|
+
// so it reads like a broken bundle, not a missing link. A consumer lost hours to exactly
|
|
426
|
+
// this silent null. Log once at init with the fix.
|
|
427
|
+
if (!(ctx as SsrContext).updatesStorage) {
|
|
428
|
+
log('error', 'ssrPlugin: no updatesStorage in the plugin context — every non-API route will 404. '
|
|
429
|
+
+ 'Wire it in createAppContext: updatesStorage = await createStorage({ type: "s3", '
|
|
430
|
+
+ 'bucket: sstResource<{name:string}>("Updates")?.name }) and LINK the Updates bucket to this function.');
|
|
431
|
+
}
|
|
432
|
+
|
|
423
433
|
return async (request: Request): Promise<Response> => {
|
|
424
434
|
const webHandler = await getWebHandler(
|
|
425
435
|
(ctx as SsrContext).updatesStorage,
|
|
@@ -460,8 +470,9 @@ export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext)
|
|
|
460
470
|
// --- Database Actions Plugin ---
|
|
461
471
|
|
|
462
472
|
export interface DbPluginOptions {
|
|
463
|
-
/** Absolute path to drizzle migrations folder
|
|
464
|
-
|
|
473
|
+
/** Absolute path to drizzle migrations folder. Optional — a db:sync app has no migrations;
|
|
474
|
+
* migrate/db:reset then answer with a clear error while provision/console/psql still work. */
|
|
475
|
+
migrationsFolder?: string;
|
|
465
476
|
/** App-provided seed function — action only registered if provided */
|
|
466
477
|
seed?: (db: any, schema: any) => Promise<any>;
|
|
467
478
|
/** PostgreSQL schemas to drop during db:reset (default: ['public', 'drizzle']) */
|
|
@@ -553,8 +564,15 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
553
564
|
return { deps: { bucket, region: options.backupRegion ?? process.env.AWS_REGION, adminUrl } };
|
|
554
565
|
};
|
|
555
566
|
|
|
567
|
+
// The migration-backed actions stay in the inventory even without a migrationsFolder —
|
|
568
|
+
// an explanatory error beats "Unknown action" pointing the operator the wrong way.
|
|
569
|
+
const NO_MIGRATIONS_ERROR =
|
|
570
|
+
'no migrationsFolder configured — this app has no migrations (db:sync manages its schema). '
|
|
571
|
+
+ 'To use migrations, pass migrationsFolder to dbPlugin.';
|
|
572
|
+
|
|
556
573
|
// --- migrate ---
|
|
557
574
|
actions.migrate = async (_payload, _ctx) => {
|
|
575
|
+
if (!options.migrationsFolder) return { error: NO_MIGRATIONS_ERROR };
|
|
558
576
|
const { runMigrations } = await import('./migrate');
|
|
559
577
|
return await runMigrations(opsDb, options.migrationsFolder);
|
|
560
578
|
};
|
|
@@ -575,6 +593,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
575
593
|
if (ctx.environment !== 'dev') {
|
|
576
594
|
return { error: 'db:reset is only available in dev environment' };
|
|
577
595
|
}
|
|
596
|
+
// Refuse BEFORE the drop: reset replays migrations after wiping the schemas — with no
|
|
597
|
+
// migrations there is nothing to rebuild from, and the drop would strand the database.
|
|
598
|
+
if (!options.migrationsFolder) return { error: NO_MIGRATIONS_ERROR };
|
|
578
599
|
const { sql } = await import('drizzle-orm');
|
|
579
600
|
const schemas = options.schemas ?? ['public', 'drizzle'];
|
|
580
601
|
const dropStatements = schemas
|
|
@@ -967,6 +988,50 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
967
988
|
}
|
|
968
989
|
};
|
|
969
990
|
|
|
991
|
+
// --- db:reconcile — the credential-free DERIVED-layer apply (the db:apply twin) ---
|
|
992
|
+
// The STATE edge (db:apply) runs in the ops Lambda; this is the DERIVED edge's lane so a
|
|
993
|
+
// least-privileged operator never needs the raw admin URL to deploy functions/views/matviews.
|
|
994
|
+
// Unlike db:apply's portable plan, a reconcile plan needs LIVE introspection (source hash vs
|
|
995
|
+
// live def hash), so the whole executeReconcile runs HERE on the operator connection: the CLI
|
|
996
|
+
// ships only the declared derived objects (bundle-independent — they come from the models
|
|
997
|
+
// barrel), and this introspects → plans → applies, emitting its own SET search_path. The DDL
|
|
998
|
+
// batch and its provenance/schema_log land in one transaction, same as the direct path.
|
|
999
|
+
actions['db:reconcile'] = async (payload, _ctx) => {
|
|
1000
|
+
const p = (payload ?? {}) as {
|
|
1001
|
+
declared?: any[];
|
|
1002
|
+
renamedTables?: any;
|
|
1003
|
+
apply?: boolean;
|
|
1004
|
+
baseline?: boolean;
|
|
1005
|
+
rebaseline?: boolean;
|
|
1006
|
+
rebuild?: boolean;
|
|
1007
|
+
overwriteDrift?: boolean;
|
|
1008
|
+
actor?: string | null;
|
|
1009
|
+
gitRef?: string | null;
|
|
1010
|
+
};
|
|
1011
|
+
try {
|
|
1012
|
+
const { executeReconcile } = await import('@everystack/cli/reconcile');
|
|
1013
|
+
const { sql } = await import('drizzle-orm');
|
|
1014
|
+
const runner = async (query: string): Promise<any[]> => {
|
|
1015
|
+
const result: any = await opsDb.execute(sql.raw(query));
|
|
1016
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
1017
|
+
};
|
|
1018
|
+
const run = await executeReconcile(runner, {
|
|
1019
|
+
declared: p.declared,
|
|
1020
|
+
renamedTables: p.renamedTables,
|
|
1021
|
+
apply: p.apply,
|
|
1022
|
+
baseline: p.baseline,
|
|
1023
|
+
rebaseline: p.rebaseline,
|
|
1024
|
+
rebuild: p.rebuild,
|
|
1025
|
+
overwriteDrift: p.overwriteDrift,
|
|
1026
|
+
actor: p.actor ?? null,
|
|
1027
|
+
gitRef: p.gitRef ?? null,
|
|
1028
|
+
} as any);
|
|
1029
|
+
return { plan: run.plan, applied: run.applied, statements: run.statements, refusal: run.refusal ?? null };
|
|
1030
|
+
} catch (err: any) {
|
|
1031
|
+
return { error: err?.message || String(err) };
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
|
|
970
1035
|
// --- pipeline:run — the credential-free ingest reproduction (Phase 3, Brick 5) ---
|
|
971
1036
|
// The orchestrator runs HERE, in the ops Lambda, over the operator connection: a
|
|
972
1037
|
// deployed stage's small curated overlays reproduce via IAM, never a held URL. The
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server/sst-resources — read SST-linked resources WITHOUT importing `sst`.
|
|
3
|
+
*
|
|
4
|
+
* `sst` is a deploy-time construct: importing it makes @everystack/server unbundleable
|
|
5
|
+
* (Metro) and trips SST's own build-time version-check plugin. So we read the resources the
|
|
6
|
+
* way SST delivers them, mirroring `sst/dist/resource/node.js` exactly:
|
|
7
|
+
*
|
|
8
|
+
* 1. per-name env — `process.env.SST_RESOURCE_<Name>` (JSON)
|
|
9
|
+
* 2. the bundled all-resources JSON — `process.env.SST_RESOURCES_JSON`
|
|
10
|
+
* 3. the ENCRYPTED key file — `SST_KEY_FILE` + `SST_KEY` (SST >= ~4.13's Lambda default,
|
|
11
|
+
* aes-256-gcm, 12-byte zero nonce, 16-byte auth tag appended)
|
|
12
|
+
*
|
|
13
|
+
* (1) alone — the 0.4.5 sst-removal's mistake — misses everything on a modern SST Lambda,
|
|
14
|
+
* where only `SST_RESOURCE_App` is plain env and every real link rides the encrypted blob.
|
|
15
|
+
* `node:crypto`/`node:fs` are builtins already shipped in this package's server-bundle path
|
|
16
|
+
* (ssr.ts, backup-run.ts) — never the `sst` package, so nothing here breaks a bundle.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
let cache: Record<string, any> | undefined;
|
|
23
|
+
|
|
24
|
+
/** Decrypt the SST key file exactly as sst/dist/resource/node.js does. Throws on any format
|
|
25
|
+
* mismatch (bad key, truncated file) — the caller swallows it and uses the other sources. */
|
|
26
|
+
function decryptKeyFile(keyFile: string, keyB64: string): Record<string, any> {
|
|
27
|
+
const key = Buffer.from(keyB64, 'base64');
|
|
28
|
+
const encrypted = readFileSync(keyFile);
|
|
29
|
+
const nonce = Buffer.alloc(12, 0);
|
|
30
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
31
|
+
decipher.setAuthTag(encrypted.subarray(-16));
|
|
32
|
+
const decrypted = Buffer.concat([decipher.update(encrypted.subarray(0, -16)), decipher.final()]);
|
|
33
|
+
return JSON.parse(decrypted.toString());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The merged SST resource map, loaded once and cached (resources are container-stable on a
|
|
38
|
+
* Lambda). Every source is best-effort — a malformed one is skipped, never fatal — so an app
|
|
39
|
+
* that only ever sets plain env still resolves what it has, and local/Metro (no SST env) gets
|
|
40
|
+
* an empty map. Later sources win, matching SST's own precedence.
|
|
41
|
+
*/
|
|
42
|
+
export function loadSstResources(): Record<string, any> {
|
|
43
|
+
if (cache) return cache;
|
|
44
|
+
const raw: Record<string, any> = {};
|
|
45
|
+
const env = process.env;
|
|
46
|
+
|
|
47
|
+
for (const [k, v] of Object.entries(env)) {
|
|
48
|
+
if (v && k.startsWith('SST_RESOURCE_')) {
|
|
49
|
+
try {
|
|
50
|
+
raw[k.slice('SST_RESOURCE_'.length)] = JSON.parse(v);
|
|
51
|
+
} catch {
|
|
52
|
+
/* not JSON — skip this key */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (env.SST_RESOURCES_JSON) {
|
|
58
|
+
try {
|
|
59
|
+
Object.assign(raw, JSON.parse(env.SST_RESOURCES_JSON));
|
|
60
|
+
} catch {
|
|
61
|
+
/* malformed — skip */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (env.SST_KEY_FILE && env.SST_KEY) {
|
|
66
|
+
try {
|
|
67
|
+
Object.assign(raw, decryptKeyFile(env.SST_KEY_FILE, env.SST_KEY));
|
|
68
|
+
} catch {
|
|
69
|
+
/* undecryptable — fall through to what the env already gave us */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cache = raw;
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test seam: drop the cache so a fresh env is re-read. */
|
|
78
|
+
export function resetSstResourceCache(): void {
|
|
79
|
+
cache = undefined;
|
|
80
|
+
}
|