sproutboat 0.9.0 → 0.10.1
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/CHANGELOG.md +80 -1
- package/README.md +20 -7
- package/SURFACE.md +6 -2
- package/bin/sproutboat.cjs +50 -0
- package/package.json +13 -6
- package/scripts/build-cli.ts +44 -0
- package/src/assets.ts +4 -121
- package/src/broker.ts +4 -1182
- package/src/build.ts +24 -17
- package/src/compile.ts +51 -19
- package/src/config.ts +4 -379
- package/src/dev.ts +263 -62
- package/src/json.ts +3 -38
- package/src/main.ts +39 -14
- package/src/manifest.ts +4 -167
- package/src/patch-porffor.ts +5 -144
- package/src/porffor-toolchain.ts +5 -0
- package/src/report.ts +8 -7
- package/src/source.ts +4 -44
- package/src/surface.ts +16 -0
- package/src/toolchain.ts +157 -44
- package/src/types-gen.ts +3 -0
- package/src/wrap.ts +5 -267
- package/types/sproutboat.d.ts +31 -0
- package/src/native-fetch-prelude.js +0 -950
- package/src/transport-broker.js +0 -299
- package/src/transport-embedded.js +0 -1571
package/src/dev.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* project, one port.
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, watch, type FSWatcher } from "node:fs";
|
|
14
|
-
import { mkdir, readFile } from "node:fs/promises";
|
|
14
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
15
|
+
import { connect } from "node:net";
|
|
15
16
|
import { dirname, resolve } from "node:path";
|
|
16
17
|
import { buildArtifact } from "./build";
|
|
17
18
|
import { createBroker, listen, type Bindings, type Broker } from "./broker";
|
|
@@ -29,10 +30,27 @@ export type DevInput = {
|
|
|
29
30
|
source: string;
|
|
30
31
|
port: number;
|
|
31
32
|
watch: boolean;
|
|
32
|
-
/** Re-
|
|
33
|
-
rebuild: () => Promise<
|
|
33
|
+
/** Re-read the complete project after a change; throws with a readable message. */
|
|
34
|
+
rebuild: () => Promise<Pick<DevInput, "config" | "sourcePath" | "source">>;
|
|
35
|
+
/** Set internally for an asset-only refresh, where recompiling is unnecessary. */
|
|
36
|
+
reuseSproutPath?: string;
|
|
37
|
+
/** Test seam: production always uses the native artifact starter below. */
|
|
38
|
+
candidateFactory?: (input: DevInput, port: number) => Promise<Running>;
|
|
39
|
+
/** Test seam: avoid terminating Bun's test process on a simulated signal. */
|
|
40
|
+
exitOnShutdown?: boolean;
|
|
41
|
+
/** Clock seam for reload observability tests. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
/** Called once a replacement is ready and the stable route has switched. */
|
|
44
|
+
onReload?: (timing: { editToReadyMs: number; downtimeMs: number }) => void;
|
|
34
45
|
};
|
|
35
46
|
|
|
47
|
+
export function isAssetOnlyRefresh(
|
|
48
|
+
current: Pick<DevInput, "config" | "source">,
|
|
49
|
+
next: Pick<DevInput, "config" | "source">,
|
|
50
|
+
): boolean {
|
|
51
|
+
return next.source === current.source && JSON.stringify(next.config) === JSON.stringify(current.config);
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
/**
|
|
37
55
|
* Secrets for local dev, `KEY=value` per line, from `.dev.vars` beside the
|
|
38
56
|
* config — the same file Wrangler uses. Deployed secrets live in the control
|
|
@@ -70,70 +88,118 @@ async function readBindings(artifactDir: string): Promise<Partial<Bindings> | un
|
|
|
70
88
|
return record as Partial<Bindings> | undefined;
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
type Running = {
|
|
91
|
+
export type Running = {
|
|
74
92
|
sprout: Bun.Subprocess;
|
|
93
|
+
sproutPath: string;
|
|
94
|
+
artifactDir: string;
|
|
75
95
|
broker: Broker;
|
|
76
96
|
stopBroker: () => void;
|
|
77
97
|
/** Set before a kill we initiated, so its exit code is not reported as a crash. */
|
|
78
98
|
expected: boolean;
|
|
99
|
+
port: number;
|
|
100
|
+
enableDispatch: () => void;
|
|
101
|
+
disableDispatch: () => void;
|
|
79
102
|
};
|
|
80
103
|
|
|
81
|
-
async function start(input: DevInput,
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
async function start(input: DevInput, port: number): Promise<Running> {
|
|
105
|
+
const candidateDir = resolve(
|
|
106
|
+
input.projectDir,
|
|
107
|
+
".sproutboat/dev-build",
|
|
108
|
+
`${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
109
|
+
);
|
|
110
|
+
let artifact;
|
|
111
|
+
try {
|
|
112
|
+
artifact = await buildArtifact({
|
|
113
|
+
projectDir: input.projectDir,
|
|
114
|
+
config: input.config,
|
|
115
|
+
sourcePath: input.sourcePath,
|
|
116
|
+
source: input.source,
|
|
117
|
+
target: "host",
|
|
118
|
+
// Never deployable, rebuilt on every edit: buy the iteration loop.
|
|
119
|
+
optimize: "dev",
|
|
120
|
+
reuseSproutPath: input.reuseSproutPath,
|
|
121
|
+
outputDirectory: candidateDir,
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await rm(candidateDir, { recursive: true, force: true });
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
91
127
|
const artifactDir = artifact.artifactDir;
|
|
92
128
|
const sproutPath = resolve(artifactDir, "sprout");
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
let dispatchEnabled = false;
|
|
130
|
+
try {
|
|
131
|
+
const stateDir = resolve(input.projectDir, ".sproutboat/dev");
|
|
132
|
+
await mkdir(stateDir, { recursive: true });
|
|
133
|
+
const assetsDir = resolve(artifactDir, "assets");
|
|
134
|
+
const broker = createBroker({
|
|
135
|
+
db: resolve(stateDir, "state.sqlite"),
|
|
136
|
+
dataDir: resolve(stateDir, "d1"),
|
|
137
|
+
resourceDir: resolve(stateDir, "resources"),
|
|
138
|
+
token: "sproutboat-dev",
|
|
139
|
+
bindings: await readBindings(artifactDir),
|
|
140
|
+
secrets: await readDevVars(input.projectDir),
|
|
141
|
+
sproutUrl: `http://127.0.0.1:${port}/`,
|
|
142
|
+
assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
|
|
143
|
+
dispatchEnabled: () => dispatchEnabled,
|
|
144
|
+
});
|
|
145
|
+
let server: ReturnType<typeof listen>;
|
|
146
|
+
try {
|
|
147
|
+
server = listen(broker, "127.0.0.1", 0);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
broker.close();
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
let sprout: Bun.Subprocess;
|
|
153
|
+
try {
|
|
154
|
+
sprout = Bun.spawn([sproutPath], {
|
|
155
|
+
cwd: dirname(sproutPath),
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
PORT: String(port),
|
|
159
|
+
SB_BROKER_PORT: String(server.port),
|
|
160
|
+
SB_BROKER_TOKEN: "sproutboat-dev",
|
|
161
|
+
},
|
|
162
|
+
stdout: "inherit",
|
|
163
|
+
stderr: "inherit",
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
126
166
|
server.stop();
|
|
127
167
|
broker.close();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
sprout,
|
|
172
|
+
sproutPath,
|
|
173
|
+
artifactDir,
|
|
174
|
+
broker,
|
|
175
|
+
stopBroker: () => {
|
|
176
|
+
server.stop();
|
|
177
|
+
broker.close();
|
|
178
|
+
},
|
|
179
|
+
expected: false,
|
|
180
|
+
port,
|
|
181
|
+
enableDispatch: () => {
|
|
182
|
+
dispatchEnabled = true;
|
|
183
|
+
},
|
|
184
|
+
disableDispatch: () => {
|
|
185
|
+
dispatchEnabled = false;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
await rm(candidateDir, { recursive: true, force: true });
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
131
192
|
}
|
|
132
193
|
|
|
133
194
|
function stop(running: Running): void {
|
|
195
|
+
if (running.expected) return;
|
|
134
196
|
running.expected = true;
|
|
197
|
+
running.disableDispatch();
|
|
135
198
|
running.sprout.kill(9);
|
|
136
199
|
running.stopBroker();
|
|
200
|
+
// Every candidate owns an isolated snapshot. Once it is no longer serving,
|
|
201
|
+
// remove it so repeated edits do not grow .sproutboat/dev-build forever.
|
|
202
|
+
void rm(running.artifactDir, { recursive: true, force: true });
|
|
137
203
|
}
|
|
138
204
|
|
|
139
205
|
/** Report a sprout that died on its own; a kill we asked for is not news. */
|
|
@@ -144,42 +210,173 @@ function watchExit(running: Running): void {
|
|
|
144
210
|
});
|
|
145
211
|
}
|
|
146
212
|
|
|
213
|
+
/** Pick an unused loopback port without keeping it reserved. The candidate is
|
|
214
|
+
* started immediately afterwards; the tiny race is preferable to interrupting
|
|
215
|
+
* the current server just to discover a bad replacement. */
|
|
216
|
+
function candidatePort(): number {
|
|
217
|
+
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
|
|
218
|
+
const port = probe.port;
|
|
219
|
+
probe.stop();
|
|
220
|
+
if (port === undefined) throw new Error("could not allocate a candidate port");
|
|
221
|
+
return port;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function waitUntilReady(running: Running): Promise<void> {
|
|
225
|
+
const deadline = Date.now() + 3_000;
|
|
226
|
+
while (Date.now() < deadline) {
|
|
227
|
+
if (running.expected) throw new Error("candidate stopped before becoming ready");
|
|
228
|
+
const exited = await Promise.race([running.sprout.exited.then(() => true), Bun.sleep(30).then(() => false)]);
|
|
229
|
+
if (exited) throw new Error("candidate sprout exited before becoming ready");
|
|
230
|
+
if (await tcpReady(running.port, Math.min(250, deadline - Date.now()))) return;
|
|
231
|
+
}
|
|
232
|
+
throw new Error("candidate sprout did not become ready within 3s");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Readiness is a bounded TCP connect, never an application request: booting a
|
|
236
|
+
* candidate must not execute user code or wait forever on a handler. */
|
|
237
|
+
export function tcpReady(port: number, timeout: number): Promise<boolean> {
|
|
238
|
+
return new Promise((resolveReady) => {
|
|
239
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
240
|
+
const finish = (ready: boolean) => {
|
|
241
|
+
socket.removeAllListeners();
|
|
242
|
+
socket.destroy();
|
|
243
|
+
resolveReady(ready);
|
|
244
|
+
};
|
|
245
|
+
socket.once("connect", () => finish(true));
|
|
246
|
+
socket.once("error", () => finish(false));
|
|
247
|
+
socket.setTimeout(timeout, () => finish(false));
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
147
251
|
/** Build, run, and (optionally) rebuild on change. Resolves only on shutdown. */
|
|
148
252
|
export async function runDev(input: DevInput): Promise<void> {
|
|
149
|
-
let
|
|
253
|
+
let current = input;
|
|
254
|
+
const now = input.now ?? Date.now;
|
|
255
|
+
const startCandidate = input.candidateFactory ?? start;
|
|
256
|
+
let running = await startCandidate(current, candidatePort());
|
|
257
|
+
try {
|
|
258
|
+
await waitUntilReady(running);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
stop(running);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
running.enableDispatch();
|
|
264
|
+
let activePort = running.port;
|
|
265
|
+
// Keep the public port stable while candidates boot on private ports. This is
|
|
266
|
+
// what lets a failed startup leave the last known-good process reachable.
|
|
267
|
+
let proxy: ReturnType<typeof Bun.serve>;
|
|
268
|
+
try {
|
|
269
|
+
proxy = Bun.serve({
|
|
270
|
+
hostname: "127.0.0.1",
|
|
271
|
+
port: input.port,
|
|
272
|
+
fetch(request) {
|
|
273
|
+
const target = new URL(request.url);
|
|
274
|
+
target.host = `127.0.0.1:${activePort}`;
|
|
275
|
+
return fetch(target, {
|
|
276
|
+
method: request.method,
|
|
277
|
+
headers: request.headers,
|
|
278
|
+
body: request.body,
|
|
279
|
+
redirect: "manual",
|
|
280
|
+
}).catch(() => new Response("sprout unavailable", { status: 502 }));
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
} catch (error) {
|
|
284
|
+
stop(running);
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
150
287
|
watchExit(running);
|
|
151
288
|
console.log(ok(`${input.config.name} running on ${leaf(`http://127.0.0.1:${input.port}`)}`));
|
|
152
289
|
if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
|
|
153
290
|
|
|
154
|
-
|
|
291
|
+
let watchers: FSWatcher[] = [];
|
|
155
292
|
let shuttingDown = false;
|
|
156
293
|
let resolveShutdown: (() => void) | null = null;
|
|
294
|
+
let pendingCandidate: Running | null = null;
|
|
157
295
|
const shutdown = () => {
|
|
158
296
|
if (shuttingDown) return;
|
|
159
297
|
shuttingDown = true;
|
|
160
298
|
for (const watcher of watchers) watcher.close();
|
|
299
|
+
proxy.stop();
|
|
300
|
+
if (pendingCandidate) stop(pendingCandidate);
|
|
161
301
|
stop(running);
|
|
162
302
|
resolveShutdown?.();
|
|
163
|
-
process.exit(0);
|
|
303
|
+
if (input.exitOnShutdown !== false) process.exit(0);
|
|
164
304
|
};
|
|
165
|
-
|
|
305
|
+
const signals = ["SIGINT", "SIGTERM"] as const;
|
|
306
|
+
for (const signal of signals) process.on(signal, shutdown);
|
|
307
|
+
let rebuildTask: Promise<void> | null = null;
|
|
166
308
|
|
|
167
309
|
if (input.watch) {
|
|
168
310
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
169
311
|
let rebuilding = false;
|
|
312
|
+
let dirty = false;
|
|
313
|
+
const resetWatchers = () => {
|
|
314
|
+
for (const watcher of watchers) watcher.close();
|
|
315
|
+
const next: FSWatcher[] = [
|
|
316
|
+
watch(dirname(current.sourcePath), { recursive: true }, onChange),
|
|
317
|
+
// Watch the containing directory as well: atomic-save editors replace
|
|
318
|
+
// the config inode, which otherwise silently detaches a file watcher.
|
|
319
|
+
watch(current.projectDir, { recursive: false }, onChange),
|
|
320
|
+
];
|
|
321
|
+
if (current.config.assets) {
|
|
322
|
+
const assets = resolve(current.projectDir, current.config.assets.directory);
|
|
323
|
+
// The parent sees a generated directory being atomically replaced;
|
|
324
|
+
// the directory watcher sees updates inside an existing snapshot.
|
|
325
|
+
next.push(watch(dirname(assets), { recursive: false }, onChange));
|
|
326
|
+
if (existsSync(assets)) next.push(watch(assets, { recursive: true }, onChange));
|
|
327
|
+
}
|
|
328
|
+
watchers = next;
|
|
329
|
+
};
|
|
170
330
|
const onChange = () => {
|
|
331
|
+
dirty = true;
|
|
171
332
|
if (pending !== null) clearTimeout(pending);
|
|
172
333
|
// Editors write a file in several syscalls; one save should be one build.
|
|
173
334
|
pending = setTimeout(() => {
|
|
174
|
-
|
|
335
|
+
rebuildTask = (async () => {
|
|
175
336
|
if (rebuilding || shuttingDown) return;
|
|
176
337
|
rebuilding = true;
|
|
177
338
|
try {
|
|
178
|
-
|
|
339
|
+
dirty = false;
|
|
340
|
+
const editStartedAt = now();
|
|
341
|
+
const next = await current.rebuild();
|
|
179
342
|
console.log(dim(" change detected, rebuilding…"));
|
|
180
|
-
|
|
181
|
-
|
|
343
|
+
const assetOnly = isAssetOnlyRefresh(current, next);
|
|
344
|
+
const candidate = await startCandidate(
|
|
345
|
+
{ ...current, ...next, reuseSproutPath: assetOnly ? running.sproutPath : undefined },
|
|
346
|
+
candidatePort(),
|
|
347
|
+
);
|
|
348
|
+
pendingCandidate = candidate;
|
|
349
|
+
if (shuttingDown) {
|
|
350
|
+
stop(candidate);
|
|
351
|
+
pendingCandidate = null;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await waitUntilReady(candidate);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
stop(candidate);
|
|
358
|
+
pendingCandidate = null;
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
if (shuttingDown) {
|
|
362
|
+
stop(candidate);
|
|
363
|
+
pendingCandidate = null;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Only now is the public route switched. The old process stays up
|
|
367
|
+
// through compilation and candidate startup.
|
|
368
|
+
const previous = running;
|
|
369
|
+
previous.disableDispatch();
|
|
370
|
+
candidate.enableDispatch();
|
|
371
|
+
running = candidate;
|
|
372
|
+
pendingCandidate = null;
|
|
373
|
+
current = { ...current, ...next };
|
|
374
|
+
const switchedAt = now();
|
|
375
|
+
activePort = candidate.port;
|
|
376
|
+
resetWatchers();
|
|
377
|
+
stop(previous);
|
|
182
378
|
watchExit(running);
|
|
379
|
+
input.onReload?.({ editToReadyMs: switchedAt - editStartedAt, downtimeMs: now() - switchedAt });
|
|
183
380
|
console.log(ok(` reloaded on http://127.0.0.1:${input.port}`));
|
|
184
381
|
} catch (cause) {
|
|
185
382
|
// Keep the last good build serving; a typo should not take the
|
|
@@ -191,14 +388,13 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
191
388
|
);
|
|
192
389
|
} finally {
|
|
193
390
|
rebuilding = false;
|
|
391
|
+
if (dirty && !shuttingDown) onChange();
|
|
194
392
|
}
|
|
195
393
|
})();
|
|
394
|
+
void rebuildTask;
|
|
196
395
|
}, RESTART_DEBOUNCE_MS);
|
|
197
396
|
};
|
|
198
|
-
|
|
199
|
-
// changes bindings, so it needs a rebuild too.
|
|
200
|
-
watchers.push(watch(dirname(input.sourcePath), { recursive: true }, onChange));
|
|
201
|
-
watchers.push(watch(resolve(input.projectDir, "sproutboat.jsonc"), onChange));
|
|
397
|
+
resetWatchers();
|
|
202
398
|
}
|
|
203
399
|
|
|
204
400
|
// Watching, we stay up until a signal: a crashed sprout is something to fix
|
|
@@ -208,8 +404,13 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
208
404
|
await new Promise<void>((resolve) => {
|
|
209
405
|
resolveShutdown = resolve;
|
|
210
406
|
});
|
|
407
|
+
// A signal can arrive while a candidate factory is building. Let that task
|
|
408
|
+
// return its newly-created resources to the coordinator, which observes
|
|
409
|
+
// `shuttingDown` and stops them, before declaring shutdown complete.
|
|
410
|
+
await rebuildTask;
|
|
211
411
|
} else {
|
|
212
412
|
await running.sprout.exited;
|
|
213
413
|
stop(running);
|
|
214
414
|
}
|
|
415
|
+
for (const signal of signals) process.off(signal, shutdown);
|
|
215
416
|
}
|
package/src/json.ts
CHANGED
|
@@ -1,38 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* boundary with `parseJsonValue`, then narrow with these guards — nothing
|
|
5
|
-
* downstream should see an unparsed value.
|
|
6
|
-
*/
|
|
7
|
-
export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
|
|
8
|
-
export type JsonObject = { [key: string]: JsonValue };
|
|
9
|
-
|
|
10
|
-
export function isString(value: JsonValue | undefined): value is string {
|
|
11
|
-
return value !== undefined && value === String(value);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function isSafeInteger(value: JsonValue | undefined): value is number {
|
|
15
|
-
return Number.isSafeInteger(value);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function isBoolean(value: JsonValue | undefined): value is boolean {
|
|
19
|
-
return value === true || value === false;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function parseJsonValue(source: string): JsonValue {
|
|
23
|
-
const value = JSON.parse(source);
|
|
24
|
-
if (
|
|
25
|
-
value === null ||
|
|
26
|
-
value === true ||
|
|
27
|
-
value === false ||
|
|
28
|
-
value === String(value) ||
|
|
29
|
-
Number.isFinite(value) ||
|
|
30
|
-
value instanceof Object
|
|
31
|
-
)
|
|
32
|
-
return value;
|
|
33
|
-
throw new Error("response was not valid JSON");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function jsonObject(value: JsonValue): JsonObject | undefined {
|
|
37
|
-
return value instanceof Object && !Array.isArray(value) ? value : undefined;
|
|
38
|
-
}
|
|
1
|
+
// Re-exported from @sproutboat/wire (moved verbatim with broker.ts).
|
|
2
|
+
// This shim keeps every `./json` importer working.
|
|
3
|
+
export * from "@sproutboat/wire";
|
package/src/main.ts
CHANGED
|
@@ -18,6 +18,19 @@ import { helpText, STORAGE_PRODUCTS, STORAGE_VERBS, type StorageProduct } from "
|
|
|
18
18
|
import { notifyIfOutdated } from "./update-check";
|
|
19
19
|
import { controlVersionWarning } from "./api-version";
|
|
20
20
|
import { amber, bold, dim, leaf, ok, rose } from "./style";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
// A compiled Bun executable is not a general-purpose `bun` command. Porffor
|
|
24
|
+
// is acquired after installation, so run its external ESM launcher through an
|
|
25
|
+
// explicit private mode inside the packaged runtime.
|
|
26
|
+
const internalModeAt = process.argv.slice(1, 3).indexOf("__porffor") + 1;
|
|
27
|
+
if (internalModeAt > 0) {
|
|
28
|
+
const launcher = process.argv[internalModeAt + 1];
|
|
29
|
+
if (!launcher) throw new Error("internal Porffor mode requires a launcher path");
|
|
30
|
+
process.argv.splice(1, internalModeAt + 1, launcher);
|
|
31
|
+
await import(pathToFileURL(launcher).href);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
21
34
|
|
|
22
35
|
const defaultApiUrl = "https://dashboard.sproutboat.com";
|
|
23
36
|
|
|
@@ -165,7 +178,9 @@ function usageError(what: string, usage: string): never {
|
|
|
165
178
|
process.exit(2);
|
|
166
179
|
}
|
|
167
180
|
|
|
168
|
-
|
|
181
|
+
/** Read the project identity and configuration without touching application code.
|
|
182
|
+
* Management commands must remain usable when a local checkout is incomplete. */
|
|
183
|
+
async function readProjectConfig(directory = process.cwd()) {
|
|
169
184
|
const projectDirectory = resolve(directory);
|
|
170
185
|
const configPath = resolve(projectDirectory, "sproutboat.jsonc");
|
|
171
186
|
let configSource: string;
|
|
@@ -176,25 +191,31 @@ async function readProject(directory = process.cwd()) {
|
|
|
176
191
|
}
|
|
177
192
|
const parsed = parseConfig(configSource);
|
|
178
193
|
if (!parsed.ok) fail(parsed.errors.join("\n"));
|
|
179
|
-
|
|
194
|
+
return { directory: projectDirectory, config: parsed.value };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Full build input: configuration plus entry point, resolved imports and checks. */
|
|
198
|
+
async function readProject(directory = process.cwd()) {
|
|
199
|
+
const project = await readProjectConfig(directory);
|
|
200
|
+
const sourcePath = resolve(project.directory, project.config.main);
|
|
180
201
|
let source: string;
|
|
181
202
|
try {
|
|
182
203
|
source = await readFile(sourcePath, "utf8");
|
|
183
204
|
} catch {
|
|
184
|
-
fail(`entry point not found: ${
|
|
205
|
+
fail(`entry point not found: ${project.config.main}`);
|
|
185
206
|
}
|
|
186
207
|
// #89 — resolve imports first, then hold the *bundled* module to the
|
|
187
208
|
// capability rules. Validating the entry file instead would let a dependency
|
|
188
209
|
// smuggle in a Node API the handler is not allowed to touch.
|
|
189
210
|
let bundle: BundleResult;
|
|
190
211
|
try {
|
|
191
|
-
bundle = await bundleHandler(sourcePath,
|
|
212
|
+
bundle = await bundleHandler(sourcePath, project.directory);
|
|
192
213
|
} catch (cause) {
|
|
193
214
|
fail(cause instanceof BundleError ? cause.message : String(cause));
|
|
194
215
|
}
|
|
195
|
-
const supported = validateHttpSyncSource(bundle.code, (
|
|
216
|
+
const supported = validateHttpSyncSource(bundle.code, (project.config.outbound ?? []).length > 0);
|
|
196
217
|
if (!supported.ok) fail(supported.errors.join("\n"));
|
|
197
|
-
return {
|
|
218
|
+
return { ...project, sourcePath, source, bundle };
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
async function init(name = "hello") {
|
|
@@ -329,7 +350,11 @@ async function dev(args: string[]) {
|
|
|
329
350
|
watch: !args.includes("--no-watch"),
|
|
330
351
|
// Re-read from disk on every rebuild: the point of watching is that the
|
|
331
352
|
// files changed, so the bundle captured at startup is stale by definition.
|
|
332
|
-
rebuild: async () =>
|
|
353
|
+
rebuild: async () => {
|
|
354
|
+
const next = await readProject(directory);
|
|
355
|
+
await refreshTypes(next.directory, next.config);
|
|
356
|
+
return { config: next.config, sourcePath: next.sourcePath, source: next.bundle.code };
|
|
357
|
+
},
|
|
333
358
|
});
|
|
334
359
|
}
|
|
335
360
|
|
|
@@ -631,7 +656,7 @@ async function versions(args: string[]) {
|
|
|
631
656
|
if (sub === "view") {
|
|
632
657
|
const id = args.shift();
|
|
633
658
|
if (!id) usageError("versions view: missing <version-id>", "versions view <version-id> [project-dir]");
|
|
634
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
659
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[0]), apiCredentials()]);
|
|
635
660
|
const body = await responseText(
|
|
636
661
|
await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${encodeURIComponent(id)}`, {
|
|
637
662
|
headers: { "x-api-key": token },
|
|
@@ -662,7 +687,7 @@ async function versions(args: string[]) {
|
|
|
662
687
|
return;
|
|
663
688
|
}
|
|
664
689
|
|
|
665
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
690
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[0]), apiCredentials()]);
|
|
666
691
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments`, {
|
|
667
692
|
headers: { "x-api-key": token },
|
|
668
693
|
});
|
|
@@ -677,7 +702,7 @@ async function versions(args: string[]) {
|
|
|
677
702
|
async function rollback(args: string[]) {
|
|
678
703
|
const id = args[0];
|
|
679
704
|
if (!id) usageError("rollback: missing <version-id>", "rollback <version-id> [project-dir]");
|
|
680
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
705
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[1]), apiCredentials()]);
|
|
681
706
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/deployments/${id}/activate`, {
|
|
682
707
|
method: "POST",
|
|
683
708
|
headers: { "x-api-key": token },
|
|
@@ -691,7 +716,7 @@ async function rollback(args: string[]) {
|
|
|
691
716
|
async function tail(args: string[]) {
|
|
692
717
|
const sproutLog = args.includes("--sprout");
|
|
693
718
|
const dir = args.find((arg) => !arg.startsWith("-"));
|
|
694
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
719
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(dir), apiCredentials()]);
|
|
695
720
|
const path = sproutLog ? "logs/sprout" : "logs/recent";
|
|
696
721
|
const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/${path}`, {
|
|
697
722
|
headers: { "x-api-key": token },
|
|
@@ -746,7 +771,7 @@ async function domains(args: string[]) {
|
|
|
746
771
|
const host = sub === "list" ? undefined : args.shift();
|
|
747
772
|
if (sub !== "list" && !host)
|
|
748
773
|
usageError(`domains ${sub}: missing <hostname>`, `domains ${sub} <hostname> [project-dir]`);
|
|
749
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
774
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(args[0]), apiCredentials()]);
|
|
750
775
|
const base = `${apiUrl}/api/projects/${project.config.name}/domains`;
|
|
751
776
|
const auth = { "x-api-key": token };
|
|
752
777
|
|
|
@@ -803,7 +828,7 @@ async function secrets(args: string[]) {
|
|
|
803
828
|
const name = sub === "list" ? undefined : positional.shift();
|
|
804
829
|
if (sub !== "list" && !name) usageError(`secrets ${sub}: missing <NAME>`, `secrets ${sub} <NAME> [project-dir]`);
|
|
805
830
|
if (name && !/^[A-Z][A-Z0-9_]*$/.test(name)) fail("secret name must be UPPER_SNAKE_CASE");
|
|
806
|
-
const [project, { apiUrl, token }] = await Promise.all([
|
|
831
|
+
const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(positional[0]), apiCredentials()]);
|
|
807
832
|
const base = `${apiUrl}/api/projects/${project.config.name}/secrets`;
|
|
808
833
|
const auth = { "x-api-key": token };
|
|
809
834
|
|
|
@@ -1133,7 +1158,7 @@ async function deleteProject(args: string[]) {
|
|
|
1133
1158
|
}
|
|
1134
1159
|
|
|
1135
1160
|
const { apiUrl, token } = await apiCredentials();
|
|
1136
|
-
const name = explicitName ?? (await
|
|
1161
|
+
const name = explicitName ?? (await readProjectConfig(positional[0])).config.name;
|
|
1137
1162
|
if (!confirmed) fail(`this permanently removes "${name}", every version, and its route — re-run with --yes`);
|
|
1138
1163
|
|
|
1139
1164
|
const url = `${apiUrl}/api/projects/${encodeURIComponent(name)}?confirm=${encodeURIComponent(name)}`;
|