@ericminassian/plat 0.0.0-stage → 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/dist/api-types.d.ts +7 -0
- package/dist/api-types.js +3 -0
- package/dist/api-types.js.map +1 -0
- package/dist/api.d.ts +123 -0
- package/dist/api.js +271 -0
- package/dist/api.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/build.d.ts +45 -0
- package/dist/build.js +188 -0
- package/dist/build.js.map +1 -0
- package/dist/commands/account.d.ts +2 -0
- package/dist/commands/account.js +45 -0
- package/dist/commands/account.js.map +1 -0
- package/dist/commands/apps.d.ts +2 -0
- package/dist/commands/apps.js +75 -0
- package/dist/commands/apps.js.map +1 -0
- package/dist/commands/common.d.ts +19 -0
- package/dist/commands/common.js +43 -0
- package/dist/commands/common.js.map +1 -0
- package/dist/commands/lifecycle.d.ts +3 -0
- package/dist/commands/lifecycle.js +105 -0
- package/dist/commands/lifecycle.js.map +1 -0
- package/dist/commands/manage.d.ts +2 -0
- package/dist/commands/manage.js +103 -0
- package/dist/commands/manage.js.map +1 -0
- package/dist/commands/observe.d.ts +8 -0
- package/dist/commands/observe.js +195 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +42 -0
- package/dist/credentials.js.map +1 -0
- package/dist/deploy.d.ts +23 -0
- package/dist/deploy.js +100 -0
- package/dist/deploy.js.map +1 -0
- package/dist/dev/crons.d.ts +16 -0
- package/dist/dev/crons.js +47 -0
- package/dist/dev/crons.js.map +1 -0
- package/dist/dev/entries.d.ts +6 -0
- package/dist/dev/entries.js +21 -0
- package/dist/dev/entries.js.map +1 -0
- package/dist/dev/index.d.ts +33 -0
- package/dist/dev/index.js +262 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/local-stage.d.ts +46 -0
- package/dist/dev/local-stage.js +116 -0
- package/dist/dev/local-stage.js.map +1 -0
- package/dist/dev/pretty.d.ts +16 -0
- package/dist/dev/pretty.js +91 -0
- package/dist/dev/pretty.js.map +1 -0
- package/dist/dev/proc.d.ts +7 -0
- package/dist/dev/proc.js +16 -0
- package/dist/dev/proc.js.map +1 -0
- package/dist/dev/router.d.ts +67 -0
- package/dist/dev/router.js +184 -0
- package/dist/dev/router.js.map +1 -0
- package/dist/dev/server-entry.d.ts +1 -0
- package/dist/dev/server-entry.js +28 -0
- package/dist/dev/server-entry.js.map +1 -0
- package/dist/dev/test.d.ts +10 -0
- package/dist/dev/test.js +62 -0
- package/dist/dev/test.js.map +1 -0
- package/dist/dev/worker-host.d.ts +1 -0
- package/dist/dev/worker-host.js +67 -0
- package/dist/dev/worker-host.js.map +1 -0
- package/dist/dev/worker.d.ts +57 -0
- package/dist/dev/worker.js +216 -0
- package/dist/dev/worker.js.map +1 -0
- package/dist/github.d.ts +39 -0
- package/dist/github.js +108 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +23 -0
- package/dist/init.js +102 -0
- package/dist/init.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +24 -0
- package/dist/main.js.map +1 -0
- package/dist/project.d.ts +22 -0
- package/dist/project.js +81 -0
- package/dist/project.js.map +1 -0
- package/dist/util/net.d.ts +4 -0
- package/dist/util/net.js +33 -0
- package/dist/util/net.js.map +1 -0
- package/dist/util/output.d.ts +42 -0
- package/dist/util/output.js +105 -0
- package/dist/util/output.js.map +1 -0
- package/dist/util/prompt.d.ts +3 -0
- package/dist/util/prompt.js +22 -0
- package/dist/util/prompt.js.map +1 -0
- package/dist/util/time.d.ts +11 -0
- package/dist/util/time.js +29 -0
- package/dist/util/time.js.map +1 -0
- package/dist/util/zip.d.ts +10 -0
- package/dist/util/zip.js +45 -0
- package/dist/util/zip.js.map +1 -0
- package/package.json +50 -3
- package/src/api-types.ts +8 -0
- package/src/api.ts +341 -0
- package/src/bin.ts +4 -0
- package/src/build.ts +255 -0
- package/src/commands/account.ts +62 -0
- package/src/commands/apps.ts +102 -0
- package/src/commands/common.ts +51 -0
- package/src/commands/lifecycle.ts +139 -0
- package/src/commands/manage.ts +169 -0
- package/src/commands/observe.ts +276 -0
- package/src/credentials.ts +50 -0
- package/src/deploy.ts +138 -0
- package/src/dev/crons.ts +70 -0
- package/src/dev/entries.ts +23 -0
- package/src/dev/index.ts +281 -0
- package/src/dev/local-stage.ts +176 -0
- package/src/dev/pretty.ts +90 -0
- package/src/dev/proc.ts +15 -0
- package/src/dev/router.ts +246 -0
- package/src/dev/server-entry.ts +21 -0
- package/src/dev/test.ts +68 -0
- package/src/dev/worker-host.ts +62 -0
- package/src/dev/worker.ts +332 -0
- package/src/github.ts +121 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/main.ts +28 -0
- package/src/project.ts +89 -0
- package/src/util/net.ts +33 -0
- package/src/util/output.ts +117 -0
- package/src/util/prompt.ts +20 -0
- package/src/util/time.ts +32 -0
- package/src/util/zip.ts +46 -0
- package/template/.github/workflows/platform.yml +24 -0
- package/template/AGENTS.md +134 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +13 -0
- package/template/_gitignore +8 -0
- package/template/package.json +31 -0
- package/template/platform.json +18 -0
- package/template/src/server.ts +100 -0
- package/template/src/types.ts +21 -0
- package/template/src/worker.ts +32 -0
- package/template/test/server.test.ts +120 -0
- package/template/test/todos.integration.test.ts +30 -0
- package/template/tsconfig.json +18 -0
- package/template/vitest.config.ts +9 -0
- package/template/vitest.integration.config.ts +10 -0
- package/template/web/index.html +12 -0
- package/template/web/src/App.tsx +102 -0
- package/template/web/src/api.ts +11 -0
- package/template/web/src/main.tsx +10 -0
- package/template/web/src/styles.css +102 -0
- package/template/web/tsconfig.json +18 -0
- package/template/web/vite.config.ts +19 -0
- package/README.md +0 -3
package/src/dev/index.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
2
|
+
import type { Server } from 'node:http';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { LOCAL_STAGE, type ResolvedManifest } from '@ericminassian/platform-core';
|
|
5
|
+
import { EMULATOR_INSTALL_HINT } from '@ericminassian/platform-engine';
|
|
6
|
+
import pc from 'picocolors';
|
|
7
|
+
import { SDK_SERVER, withLocalBin } from '../build.ts';
|
|
8
|
+
import { loadProject, resolveFromApp } from '../project.ts';
|
|
9
|
+
import { freePort, waitForPort } from '../util/net.ts';
|
|
10
|
+
import { CliError, info, step, success, warn } from '../util/output.ts';
|
|
11
|
+
import { type LocalCron, scheduleCrons } from './crons.ts';
|
|
12
|
+
import { tsxArgs } from './entries.ts';
|
|
13
|
+
import { type LocalStage, localEnv, startLocalStage } from './local-stage.ts';
|
|
14
|
+
import { prefixer } from './pretty.ts';
|
|
15
|
+
import { killTree } from './proc.ts';
|
|
16
|
+
import { type Interceptor, type LocalRouterState, localRouteRecord, startLocalRouter } from './router.ts';
|
|
17
|
+
import { type Dispatcher, startLocalWorker, startWorkerDispatch } from './worker.ts';
|
|
18
|
+
|
|
19
|
+
export interface DevOptions {
|
|
20
|
+
cwd: string;
|
|
21
|
+
port: number;
|
|
22
|
+
/** Print SDK trace spans too. */
|
|
23
|
+
spans?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEV_CONTROL_PREFIX = '/__plat_dev/';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `plat dev`: the whole app locally against the AWS Emulator.
|
|
30
|
+
*
|
|
31
|
+
* emulator ← engine (same code as prod; `webMode: 'external'`)
|
|
32
|
+
* web server (SDK wrapper, tsx watch) ─┐
|
|
33
|
+
* frontend dev server (web.dev) ───────┼─ local router (core `decide()`)
|
|
34
|
+
* local sign-in page ──────────────────┘
|
|
35
|
+
* worker (real Node, tsx watch) ← dispatcher (emulator SQS + EventBridge tap + local crons)
|
|
36
|
+
*/
|
|
37
|
+
export async function runDev(opts: DevOptions): Promise<void> {
|
|
38
|
+
const { root, manifest } = loadProject(opts.cwd);
|
|
39
|
+
const children: ChildProcess[] = [];
|
|
40
|
+
const cleanups: Array<() => void | Promise<void>> = [];
|
|
41
|
+
let stage: LocalStage | undefined;
|
|
42
|
+
let router: Server | undefined;
|
|
43
|
+
let shuttingDown = false;
|
|
44
|
+
|
|
45
|
+
const shutdown = async (code = 0) => {
|
|
46
|
+
if (shuttingDown) return;
|
|
47
|
+
shuttingDown = true;
|
|
48
|
+
info(pc.dim('\nstopping…'));
|
|
49
|
+
for (const fn of cleanups) await fn();
|
|
50
|
+
for (const c of children) killTree(c);
|
|
51
|
+
router?.close();
|
|
52
|
+
await stage?.stop();
|
|
53
|
+
process.exit(code);
|
|
54
|
+
};
|
|
55
|
+
process.once('SIGINT', () => void shutdown(0));
|
|
56
|
+
process.once('SIGTERM', () => void shutdown(0));
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
step('starting the AWS Emulator');
|
|
60
|
+
stage = await startLocalStage({
|
|
61
|
+
root,
|
|
62
|
+
manifest,
|
|
63
|
+
stage: LOCAL_STAGE,
|
|
64
|
+
port: opts.port,
|
|
65
|
+
log: (e) => {
|
|
66
|
+
if (e.level !== 'info' || process.env.PLAT_DEV_VERBOSE) info(pc.dim(` engine ${e.level}: ${e.msg}`));
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
await stage?.stop();
|
|
71
|
+
if ((err as Error).message === EMULATOR_INSTALL_HINT) {
|
|
72
|
+
throw new CliError('the AWS Emulator is not installed', { hint: EMULATOR_INSTALL_HINT });
|
|
73
|
+
}
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
success(`provisioned ${manifest.name} (${stage.result.resources.length} resources) in the emulator`);
|
|
77
|
+
|
|
78
|
+
const state: LocalRouterState = {
|
|
79
|
+
record: localRouteRecord({
|
|
80
|
+
app: manifest.name,
|
|
81
|
+
stage: LOCAL_STAGE,
|
|
82
|
+
deploymentId: stage.deploymentId,
|
|
83
|
+
hasServer: !!manifest.web?.server,
|
|
84
|
+
hasStatic: !!(manifest.web?.static || manifest.web?.dev),
|
|
85
|
+
serverRoutes: manifest.web?.serverRoutes ?? ['/api/'],
|
|
86
|
+
spa: manifest.web?.spa ?? true,
|
|
87
|
+
}),
|
|
88
|
+
...(manifest.web?.static ? { staticDir: resolve(root, manifest.web.static) } : {}),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ------------------------------------------------------------ worker
|
|
92
|
+
let dispatcher: Dispatcher | undefined;
|
|
93
|
+
let workerPort: number | undefined;
|
|
94
|
+
if (manifest.worker) {
|
|
95
|
+
const worker = await startLocalWorker(stage, manifest, root);
|
|
96
|
+
workerPort = worker.port;
|
|
97
|
+
children.push(worker.child);
|
|
98
|
+
const out = prefixer(pc.yellow('worker│'), (s) => process.stdout.write(s), { spans: opts.spans });
|
|
99
|
+
worker.child.stdout!.on('data', out);
|
|
100
|
+
worker.child.stderr!.on('data', out);
|
|
101
|
+
dispatcher = await startWorkerDispatch(stage, manifest, worker, (kind, name, outcome, detail) => {
|
|
102
|
+
if (outcome === 'error') warn(`${kind} ${name} failed${detail ? `: ${detail}` : ''}`);
|
|
103
|
+
});
|
|
104
|
+
cleanups.push(() => dispatcher?.stop());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const runCron = async (name: string) => {
|
|
108
|
+
if (!dispatcher) throw new CliError('this app has no worker');
|
|
109
|
+
if (!manifest.crons[name]) {
|
|
110
|
+
throw new CliError(`no cron named ${name}`, {
|
|
111
|
+
hint: `crons: ${Object.keys(manifest.crons).join(', ') || '(none)'}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
info(`${pc.magenta('cron │')} running ${name}`);
|
|
115
|
+
const r = await dispatcher.runCron(name);
|
|
116
|
+
if (!r.ok) throw new CliError(`cron ${name} failed: ${r.error?.message}`);
|
|
117
|
+
};
|
|
118
|
+
const crons = dispatcher
|
|
119
|
+
? scheduleCrons(
|
|
120
|
+
Object.entries(manifest.crons).map(([name, c]): LocalCron => ({ name, ...c })),
|
|
121
|
+
(name) => runCron(name).catch((err) => warn((err as Error).message)),
|
|
122
|
+
)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (crons) cleanups.push(() => crons.stop());
|
|
125
|
+
|
|
126
|
+
// ------------------------------------------------------------ web server
|
|
127
|
+
if (manifest.web?.server) {
|
|
128
|
+
const { child, port: serverPort } = await spawnWebServer(stage, manifest, root, {
|
|
129
|
+
...(workerPort ? { workerUrl: `http://127.0.0.1:${workerPort}/` } : {}),
|
|
130
|
+
});
|
|
131
|
+
children.push(child);
|
|
132
|
+
const out = prefixer(pc.green('web │'), (s) => process.stdout.write(s), { spans: opts.spans });
|
|
133
|
+
child.stdout!.on('data', out);
|
|
134
|
+
child.stderr!.on('data', out);
|
|
135
|
+
child.once('exit', (code) => {
|
|
136
|
+
if (!shuttingDown) warn(`web server exited (code ${code})`);
|
|
137
|
+
});
|
|
138
|
+
void waitForPort(serverPort, '127.0.0.1', 60_000)
|
|
139
|
+
.then(() => {
|
|
140
|
+
state.server = { host: '127.0.0.1', port: serverPort };
|
|
141
|
+
})
|
|
142
|
+
.catch((err) => warn(`web server did not start: ${(err as Error).message}`));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ------------------------------------------------------------ frontend dev server
|
|
146
|
+
if (manifest.web?.dev) {
|
|
147
|
+
const { command, port } = manifest.web.dev;
|
|
148
|
+
const child = spawn(command, {
|
|
149
|
+
cwd: root,
|
|
150
|
+
shell: true,
|
|
151
|
+
detached: true,
|
|
152
|
+
env: withLocalBin(root, { ...process.env, PLATFORM_DEV_ROUTER: `http://localhost:${opts.port}` }),
|
|
153
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
154
|
+
});
|
|
155
|
+
children.push(child);
|
|
156
|
+
const out = prefixer(pc.blue('front │'), (s) => process.stdout.write(s));
|
|
157
|
+
child.stdout!.on('data', out);
|
|
158
|
+
child.stderr!.on('data', out);
|
|
159
|
+
state.webDevStarting = true;
|
|
160
|
+
// Dev servers often bind `localhost` (::1 on many systems); connect the same way.
|
|
161
|
+
void waitForPort(port, 'localhost', 60_000)
|
|
162
|
+
.then(() => {
|
|
163
|
+
state.webDev = { host: 'localhost', port };
|
|
164
|
+
})
|
|
165
|
+
.catch((err) => warn(`frontend dev server did not start: ${(err as Error).message}`))
|
|
166
|
+
.finally(() => {
|
|
167
|
+
state.webDevStarting = false;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ------------------------------------------------------------ router
|
|
172
|
+
const intercept: Interceptor[] = [
|
|
173
|
+
async (req, res, url) => {
|
|
174
|
+
if (!url.pathname.startsWith(DEV_CONTROL_PREFIX)) return false;
|
|
175
|
+
const cron = /^\/__plat_dev\/cron\/([^/]+)$/.exec(url.pathname);
|
|
176
|
+
if (cron && req.method === 'POST') {
|
|
177
|
+
try {
|
|
178
|
+
await runCron(decodeURIComponent(cron[1]!));
|
|
179
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
|
|
180
|
+
} catch (err) {
|
|
181
|
+
res
|
|
182
|
+
.writeHead(400, { 'content-type': 'application/json' })
|
|
183
|
+
.end(JSON.stringify({ error: (err as Error).message }));
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (url.pathname === `${DEV_CONTROL_PREFIX}status`) {
|
|
188
|
+
res
|
|
189
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
190
|
+
.end(
|
|
191
|
+
JSON.stringify({ app: manifest.name, bindings: stage!.bindings, emulator: stage!.conn.endpoint }),
|
|
192
|
+
);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(404).end();
|
|
196
|
+
return true;
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
// Local sign-in (`/__platform/auth/*`) is served by the SDK's server wrapper in local mode.
|
|
200
|
+
router = await startLocalRouter({ port: opts.port, host: '127.0.0.1', state: () => state, intercept });
|
|
201
|
+
|
|
202
|
+
printBanner(manifest, stage, opts.port, crons);
|
|
203
|
+
await new Promise(() => {}); // run until signalled
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The app's server exactly as the Web Function runs it (SDK wrapper, bindings,
|
|
208
|
+
* `AWS_LAMBDA_HTTP_ENDPOINT`), under `tsx watch` so edits restart it.
|
|
209
|
+
*/
|
|
210
|
+
export async function spawnWebServer(
|
|
211
|
+
stage: LocalStage,
|
|
212
|
+
manifest: ResolvedManifest,
|
|
213
|
+
root: string,
|
|
214
|
+
opts: { watch?: boolean; workerUrl?: string } = {},
|
|
215
|
+
): Promise<{ child: ChildProcess; port: number }> {
|
|
216
|
+
if (!manifest.web?.server) throw new Error('manifest has no web.server');
|
|
217
|
+
const port = await freePort();
|
|
218
|
+
const env = localEnv(stage, manifest, {
|
|
219
|
+
AWS_LAMBDA_HTTP_ENDPOINT: `127.0.0.1:${port}`,
|
|
220
|
+
PLATFORM_LOG_PRETTY: '1',
|
|
221
|
+
PLATFORM_ENTRY: resolve(root, manifest.web.server),
|
|
222
|
+
PLATFORM_SDK_SERVER: resolveFromApp(root, SDK_SERVER),
|
|
223
|
+
// `invokeJob` locally: the SDK posts here instead of invoking the emulator's sandboxed Lambda.
|
|
224
|
+
...(opts.workerUrl ? { PLATFORM_LOCAL_WORKER_URL: opts.workerUrl } : {}),
|
|
225
|
+
});
|
|
226
|
+
const child = spawn(process.execPath, tsxArgs('server-entry', opts.watch !== false), {
|
|
227
|
+
cwd: root,
|
|
228
|
+
env,
|
|
229
|
+
detached: true,
|
|
230
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
231
|
+
});
|
|
232
|
+
return { child, port };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function printBanner(
|
|
236
|
+
manifest: ResolvedManifest,
|
|
237
|
+
stage: LocalStage,
|
|
238
|
+
port: number,
|
|
239
|
+
crons: ReturnType<typeof scheduleCrons> | undefined,
|
|
240
|
+
): void {
|
|
241
|
+
const b = stage.bindings;
|
|
242
|
+
const lines = [
|
|
243
|
+
'',
|
|
244
|
+
` ${pc.bold(manifest.name)} ${pc.dim('running locally on the AWS Emulator')}`,
|
|
245
|
+
'',
|
|
246
|
+
` ${pc.green('➜')} App: ${pc.underline(`http://${manifest.name}.localhost:${port}`)} ${pc.dim(`(or http://localhost:${port})`)}`,
|
|
247
|
+
` ${pc.dim('➜')} Emulator: ${stage.conn.endpoint}`,
|
|
248
|
+
];
|
|
249
|
+
if (b.table) lines.push(` ${pc.dim('➜')} Database: ${b.table}`);
|
|
250
|
+
const queues = Object.keys(b.queues);
|
|
251
|
+
if (queues.length) lines.push(` ${pc.dim('➜')} Queues: ${queues.join(', ')}`);
|
|
252
|
+
if (manifest.events.length) lines.push(` ${pc.dim('➜')} Events: ${manifest.events.join(', ')}`);
|
|
253
|
+
if (b.storage) lines.push(` ${pc.dim('➜')} Storage: ${b.storage.stores.join(', ')}`);
|
|
254
|
+
if (b.auth) lines.push(` ${pc.dim('➜')} Auth: local sign-in at /__platform/auth/login (any email)`);
|
|
255
|
+
for (const name of Object.keys(manifest.crons)) {
|
|
256
|
+
const next = crons?.next(name);
|
|
257
|
+
lines.push(
|
|
258
|
+
` ${pc.dim('➜')} Cron: ${name} ${pc.dim(`next ${next?.toISOString() ?? '?'} · now: plat dev run-cron ${name}`)}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
lines.push('', pc.dim(' Ctrl+C to stop. Emulator state lives in memory and is discarded on exit.'), '');
|
|
262
|
+
info(lines.join('\n'));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `plat dev run-cron <name>`: ask a running `plat dev` to fire a cron now. */
|
|
266
|
+
export async function runCronOnDev(name: string, port: number): Promise<void> {
|
|
267
|
+
let res: Response;
|
|
268
|
+
try {
|
|
269
|
+
res = await fetch(`http://127.0.0.1:${port}${DEV_CONTROL_PREFIX}cron/${encodeURIComponent(name)}`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
throw new CliError(`no \`plat dev\` running on port ${port}`, {
|
|
274
|
+
hint: 'start it with `plat dev` (or pass --port)',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
279
|
+
throw new CliError(body.error ?? `HTTP ${res.status}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { Agent } from 'node:https';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { PutParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
|
|
5
|
+
import {
|
|
6
|
+
type Bindings,
|
|
7
|
+
encodeBindings,
|
|
8
|
+
names,
|
|
9
|
+
newDeploymentId,
|
|
10
|
+
type PlatformRuntimeConfig,
|
|
11
|
+
type ResolvedManifest,
|
|
12
|
+
} from '@ericminassian/platform-core';
|
|
13
|
+
import {
|
|
14
|
+
createEngine,
|
|
15
|
+
type DeployStageResult,
|
|
16
|
+
type EmulatorConnection,
|
|
17
|
+
type Engine,
|
|
18
|
+
type EngineLogEntry,
|
|
19
|
+
emulatorChildEnv,
|
|
20
|
+
ensureLocalPlatform,
|
|
21
|
+
type S3Location,
|
|
22
|
+
startEmulator,
|
|
23
|
+
} from '@ericminassian/platform-engine';
|
|
24
|
+
import { bundleWorker, zipBundle } from '../build.ts';
|
|
25
|
+
import { CliError } from '../util/output.ts';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A stage running inside a private AWS Emulator: shared platform resources
|
|
29
|
+
* (`ensureLocalPlatform`) plus this app's resources, created by the same
|
|
30
|
+
* engine that deploys to AWS. The web server runs outside the emulator
|
|
31
|
+
* (`webMode: 'external'`) because the emulator doesn't serve Web Function
|
|
32
|
+
* endpoints yet.
|
|
33
|
+
*/
|
|
34
|
+
export interface LocalStage {
|
|
35
|
+
conn: EmulatorConnection;
|
|
36
|
+
engine: Engine;
|
|
37
|
+
platform: PlatformRuntimeConfig;
|
|
38
|
+
result: DeployStageResult;
|
|
39
|
+
bindings: Bindings;
|
|
40
|
+
deploymentId: string;
|
|
41
|
+
stage: string;
|
|
42
|
+
stop(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StartLocalStageOptions {
|
|
46
|
+
root: string;
|
|
47
|
+
manifest: ResolvedManifest;
|
|
48
|
+
stage: 'local' | 'test';
|
|
49
|
+
/** Public port of the local router (used in URLs, auth redirects). */
|
|
50
|
+
port: number;
|
|
51
|
+
log?: (e: EngineLogEntry) => void;
|
|
52
|
+
onEmulatorLog?: (line: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function startLocalStage(opts: StartLocalStageOptions): Promise<LocalStage> {
|
|
56
|
+
const conn = await startEmulator({ port: 0, idleTimeoutSeconds: 6 * 3600, onLog: opts.onEmulatorLog });
|
|
57
|
+
try {
|
|
58
|
+
const engine = createEngine({
|
|
59
|
+
region: conn.region,
|
|
60
|
+
emulator: { endpoint: conn.endpoint, caFile: conn.caFile, credentials: conn.credentials },
|
|
61
|
+
log: opts.log,
|
|
62
|
+
});
|
|
63
|
+
const platform = await ensureLocalPlatform(engine, { port: opts.port, jwksBaseUrl: conn.endpoint });
|
|
64
|
+
const deploymentId = newDeploymentId();
|
|
65
|
+
const worker = opts.manifest.worker
|
|
66
|
+
? await uploadWorker(engine, platform, opts.root, opts.manifest, opts.stage, deploymentId)
|
|
67
|
+
: undefined;
|
|
68
|
+
const result = await engine.deployStage({
|
|
69
|
+
manifest: opts.manifest,
|
|
70
|
+
stage: opts.stage,
|
|
71
|
+
deploymentId,
|
|
72
|
+
platform,
|
|
73
|
+
artifacts: worker ? { worker } : {},
|
|
74
|
+
webMode: 'external',
|
|
75
|
+
logRetentionDays: 1,
|
|
76
|
+
});
|
|
77
|
+
await seedSecrets(conn, result.bindings, opts.root);
|
|
78
|
+
return {
|
|
79
|
+
conn,
|
|
80
|
+
engine,
|
|
81
|
+
platform,
|
|
82
|
+
result,
|
|
83
|
+
bindings: result.bindings,
|
|
84
|
+
deploymentId,
|
|
85
|
+
stage: opts.stage,
|
|
86
|
+
stop: () => conn.stop(),
|
|
87
|
+
};
|
|
88
|
+
} catch (err) {
|
|
89
|
+
await conn.stop();
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function uploadWorker(
|
|
95
|
+
engine: Engine,
|
|
96
|
+
platform: PlatformRuntimeConfig,
|
|
97
|
+
root: string,
|
|
98
|
+
manifest: ResolvedManifest,
|
|
99
|
+
stage: string,
|
|
100
|
+
deploymentId: string,
|
|
101
|
+
): Promise<S3Location> {
|
|
102
|
+
const dir = await bundleWorker(root, manifest, join(root, '.plat', stage, 'worker'), { minify: false });
|
|
103
|
+
const zip = zipBundle(dir);
|
|
104
|
+
return engine.putArtifact(
|
|
105
|
+
platform,
|
|
106
|
+
names.artifactKey(manifest.name, stage, deploymentId, 'worker.zip'),
|
|
107
|
+
zip,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Environment for the local web server, worker tests and `plat test`. */
|
|
112
|
+
export function localEnv(
|
|
113
|
+
stage: LocalStage,
|
|
114
|
+
manifest: ResolvedManifest,
|
|
115
|
+
extra: Record<string, string> = {},
|
|
116
|
+
): NodeJS.ProcessEnv {
|
|
117
|
+
return {
|
|
118
|
+
...emulatorChildEnv(stage.conn),
|
|
119
|
+
...manifest.env,
|
|
120
|
+
PLATFORM_BINDINGS: encodeBindings(stage.bindings),
|
|
121
|
+
PLATFORM_LOCAL: '1',
|
|
122
|
+
...extra,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** AWS SDK client config for talking to the emulator from the CLI process itself. */
|
|
127
|
+
export function emulatorClientConfig(conn: EmulatorConnection) {
|
|
128
|
+
return {
|
|
129
|
+
region: conn.region,
|
|
130
|
+
endpoint: conn.endpoint,
|
|
131
|
+
credentials: conn.credentials,
|
|
132
|
+
requestHandler: { httpsAgent: new Agent({ ca: readFileSync(conn.caFile), keepAlive: true }) },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `.env.local` (KEY=VALUE, not committed) → SecureString parameters, so
|
|
138
|
+
* `secrets.get()` works locally exactly as deployed. Only names declared in
|
|
139
|
+
* platform.json are loaded.
|
|
140
|
+
*/
|
|
141
|
+
async function seedSecrets(conn: EmulatorConnection, bindings: Bindings, root: string): Promise<void> {
|
|
142
|
+
const values = readDotEnv(join(root, '.env.local'));
|
|
143
|
+
const declared = new Set(bindings.secrets.names);
|
|
144
|
+
const entries = Object.entries(values).filter(([k]) => declared.has(k));
|
|
145
|
+
if (entries.length === 0) return;
|
|
146
|
+
const ssm = new SSMClient(emulatorClientConfig(conn));
|
|
147
|
+
try {
|
|
148
|
+
for (const [name, value] of entries) {
|
|
149
|
+
await ssm.send(
|
|
150
|
+
new PutParameterCommand({
|
|
151
|
+
Name: `${bindings.secrets.path}${name}`,
|
|
152
|
+
Value: value,
|
|
153
|
+
Type: 'SecureString',
|
|
154
|
+
Overwrite: true,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new CliError(`could not load .env.local secrets into the emulator: ${(err as Error).message}`);
|
|
160
|
+
} finally {
|
|
161
|
+
ssm.destroy();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readDotEnv(path: string): Record<string, string> {
|
|
166
|
+
if (!existsSync(path)) return {};
|
|
167
|
+
const out: Record<string, string> = {};
|
|
168
|
+
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
|
169
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
|
170
|
+
if (!m) continue;
|
|
171
|
+
let v = m[2]!;
|
|
172
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
173
|
+
out[m[1]!] = v;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { isTelemetryEvent, type TelemetryEvent } from '@ericminassian/platform-core';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Human formatting for telemetry lines (`{"_p":1,...}`) printed by the SDK in
|
|
6
|
+
* the local web server and worker. Non-telemetry lines pass through as-is.
|
|
7
|
+
* Returns null for lines that are noise locally (spans, Lambda START/END).
|
|
8
|
+
*/
|
|
9
|
+
export function prettyLine(line: string, opts: { spans?: boolean } = {}): string | null {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (!trimmed) return null;
|
|
12
|
+
if (/^(START|END) RequestId:/.test(trimmed)) return null;
|
|
13
|
+
if (trimmed.startsWith('REPORT RequestId:')) {
|
|
14
|
+
const dur = /Duration: ([\d.]+) ms/.exec(trimmed)?.[1];
|
|
15
|
+
return pc.dim(`lambda invocation ${dur ? `${Math.round(Number(dur))}ms` : 'done'}`);
|
|
16
|
+
}
|
|
17
|
+
// Lambda prefixes console output with "<ts>\t<requestId>\t<LEVEL>\t" in text log format.
|
|
18
|
+
const jsonStart = trimmed.indexOf('{');
|
|
19
|
+
if (jsonStart === -1) return trimmed;
|
|
20
|
+
let parsed: unknown;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(trimmed.slice(jsonStart));
|
|
23
|
+
} catch {
|
|
24
|
+
return trimmed;
|
|
25
|
+
}
|
|
26
|
+
if (!isTelemetryEvent(parsed)) return trimmed;
|
|
27
|
+
return formatEvent(parsed, opts);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatEvent(e: TelemetryEvent, opts: { spans?: boolean } = {}): string | null {
|
|
31
|
+
const trace = e.trace ? pc.dim(` ${e.trace.slice(0, 8)}`) : '';
|
|
32
|
+
switch (e.t) {
|
|
33
|
+
case 'req': {
|
|
34
|
+
const color = e.status >= 500 ? pc.red : e.status >= 400 ? pc.yellow : pc.green;
|
|
35
|
+
return `${pc.bold(e.method.padEnd(6))} ${e.path} ${color(String(e.status))} ${pc.dim(`${Math.round(e.dur)}ms`)}${e.cold ? pc.magenta(' cold') : ''}${trace}`;
|
|
36
|
+
}
|
|
37
|
+
case 'err': {
|
|
38
|
+
const head = `${pc.red('✗')} ${pc.red(`${e.name}: ${e.message}`)}${e.route ? pc.dim(` at ${e.route}`) : ''}${e.job ? pc.dim(` in ${e.job}`) : ''}${trace}`;
|
|
39
|
+
const stack = e.stack
|
|
40
|
+
?.split('\n')
|
|
41
|
+
.slice(1, 4)
|
|
42
|
+
.map((l) => pc.dim(` ${l.trim()}`))
|
|
43
|
+
.join('\n');
|
|
44
|
+
return stack ? `${head}\n${stack}` : head;
|
|
45
|
+
}
|
|
46
|
+
case 'job': {
|
|
47
|
+
const status = e.status === 'ok' ? pc.green('ok') : pc.red('error');
|
|
48
|
+
return `${pc.cyan('job')} ${e.kind}:${e.name} ${status} ${pc.dim(`${Math.round(e.dur)}ms`)}${e.attempt && e.attempt > 1 ? pc.yellow(` attempt ${e.attempt}`) : ''}${trace}`;
|
|
49
|
+
}
|
|
50
|
+
case 'log': {
|
|
51
|
+
const { _p, t, ts, app, stage, rev, trace: tr, span, parent, level, msg, ...fields } = e;
|
|
52
|
+
const color =
|
|
53
|
+
level === 'error'
|
|
54
|
+
? pc.red
|
|
55
|
+
: level === 'warn'
|
|
56
|
+
? pc.yellow
|
|
57
|
+
: level === 'debug'
|
|
58
|
+
? pc.dim
|
|
59
|
+
: (s: string) => s;
|
|
60
|
+
const extra = Object.keys(fields).length ? pc.dim(` ${JSON.stringify(fields)}`) : '';
|
|
61
|
+
return `${color(level.toUpperCase().padEnd(5))} ${msg}${extra}${trace}`;
|
|
62
|
+
}
|
|
63
|
+
case 'metric':
|
|
64
|
+
return pc.blue(`metric ${e.name}=${e.value}${e.unit ? e.unit : ''}`);
|
|
65
|
+
case 'env':
|
|
66
|
+
return pc.dim(
|
|
67
|
+
`${e.runtime} environment ${e.event}${e.initMs !== undefined ? ` (${Math.round(e.initMs)}ms init)` : ''}`,
|
|
68
|
+
);
|
|
69
|
+
case 'span':
|
|
70
|
+
return opts.spans
|
|
71
|
+
? pc.dim(` span ${e.op}${e.name ? ` ${e.name}` : ''} ${Math.round(e.dur)}ms ${e.status}`)
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Prefix every line of a child process stream. */
|
|
77
|
+
export function prefixer(label: string, write: (s: string) => void, opts: { spans?: boolean } = {}) {
|
|
78
|
+
let buf = '';
|
|
79
|
+
return (chunk: Buffer | string) => {
|
|
80
|
+
buf += chunk.toString();
|
|
81
|
+
let idx = buf.indexOf('\n');
|
|
82
|
+
while (idx !== -1) {
|
|
83
|
+
const line = buf.slice(0, idx);
|
|
84
|
+
buf = buf.slice(idx + 1);
|
|
85
|
+
const pretty = prettyLine(line, opts);
|
|
86
|
+
if (pretty !== null) write(`${label} ${pretty.split('\n').join(`\n${label} `)}\n`);
|
|
87
|
+
idx = buf.indexOf('\n');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/dev/proc.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev children are spawned `detached` so each leads its own process group:
|
|
5
|
+
* `shell: true` commands (`pnpm --dir web dev` → pnpm → vite) and `tsx watch`
|
|
6
|
+
* fork grandchildren that a plain `child.kill()` leaves running.
|
|
7
|
+
*/
|
|
8
|
+
export function killTree(child: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void {
|
|
9
|
+
if (child.exitCode !== null || child.signalCode !== null || !child.pid) return;
|
|
10
|
+
try {
|
|
11
|
+
process.kill(-child.pid, signal);
|
|
12
|
+
} catch {
|
|
13
|
+
child.kill(signal);
|
|
14
|
+
}
|
|
15
|
+
}
|