sproutboat 0.8.0 → 0.10.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/CHANGELOG.md +365 -0
- package/README.md +49 -4
- package/SURFACE.md +12 -5
- package/bin/sproutboat.cjs +32 -0
- package/package.json +27 -10
- package/scripts/build-cli.ts +44 -0
- package/src/assets.ts +4 -121
- package/src/broker.ts +4 -1139
- package/src/build.ts +31 -16
- package/src/compile.ts +80 -28
- package/src/config.ts +4 -379
- package/src/dev.ts +263 -60
- package/src/json.ts +3 -38
- package/src/main.ts +269 -16
- package/src/manifest.ts +4 -167
- package/src/patch-porffor.ts +5 -144
- package/src/porffor-toolchain.ts +5 -0
- package/src/report.ts +5 -7
- package/src/source.ts +4 -44
- package/src/surface.ts +33 -3
- package/src/toolchain.ts +228 -44
- package/src/types-gen.ts +93 -0
- package/src/wrap.ts +5 -267
- package/types/sproutboat.d.ts +248 -0
- package/src/native-fetch-prelude.js +0 -950
- package/src/transport-broker.js +0 -290
- package/src/transport-embedded.js +0 -1567
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,68 +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
|
-
|
|
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
|
+
}
|
|
89
127
|
const artifactDir = artifact.artifactDir;
|
|
90
128
|
const sproutPath = resolve(artifactDir, "sprout");
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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) {
|
|
124
166
|
server.stop();
|
|
125
167
|
broker.close();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}
|
|
129
192
|
}
|
|
130
193
|
|
|
131
194
|
function stop(running: Running): void {
|
|
195
|
+
if (running.expected) return;
|
|
132
196
|
running.expected = true;
|
|
197
|
+
running.disableDispatch();
|
|
133
198
|
running.sprout.kill(9);
|
|
134
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 });
|
|
135
203
|
}
|
|
136
204
|
|
|
137
205
|
/** Report a sprout that died on its own; a kill we asked for is not news. */
|
|
@@ -142,42 +210,173 @@ function watchExit(running: Running): void {
|
|
|
142
210
|
});
|
|
143
211
|
}
|
|
144
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
|
+
|
|
145
251
|
/** Build, run, and (optionally) rebuild on change. Resolves only on shutdown. */
|
|
146
252
|
export async function runDev(input: DevInput): Promise<void> {
|
|
147
|
-
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
|
+
}
|
|
148
287
|
watchExit(running);
|
|
149
288
|
console.log(ok(`${input.config.name} running on ${leaf(`http://127.0.0.1:${input.port}`)}`));
|
|
150
289
|
if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
|
|
151
290
|
|
|
152
|
-
|
|
291
|
+
let watchers: FSWatcher[] = [];
|
|
153
292
|
let shuttingDown = false;
|
|
154
293
|
let resolveShutdown: (() => void) | null = null;
|
|
294
|
+
let pendingCandidate: Running | null = null;
|
|
155
295
|
const shutdown = () => {
|
|
156
296
|
if (shuttingDown) return;
|
|
157
297
|
shuttingDown = true;
|
|
158
298
|
for (const watcher of watchers) watcher.close();
|
|
299
|
+
proxy.stop();
|
|
300
|
+
if (pendingCandidate) stop(pendingCandidate);
|
|
159
301
|
stop(running);
|
|
160
302
|
resolveShutdown?.();
|
|
161
|
-
process.exit(0);
|
|
303
|
+
if (input.exitOnShutdown !== false) process.exit(0);
|
|
162
304
|
};
|
|
163
|
-
|
|
305
|
+
const signals = ["SIGINT", "SIGTERM"] as const;
|
|
306
|
+
for (const signal of signals) process.on(signal, shutdown);
|
|
307
|
+
let rebuildTask: Promise<void> | null = null;
|
|
164
308
|
|
|
165
309
|
if (input.watch) {
|
|
166
310
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
167
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
|
+
};
|
|
168
330
|
const onChange = () => {
|
|
331
|
+
dirty = true;
|
|
169
332
|
if (pending !== null) clearTimeout(pending);
|
|
170
333
|
// Editors write a file in several syscalls; one save should be one build.
|
|
171
334
|
pending = setTimeout(() => {
|
|
172
|
-
|
|
335
|
+
rebuildTask = (async () => {
|
|
173
336
|
if (rebuilding || shuttingDown) return;
|
|
174
337
|
rebuilding = true;
|
|
175
338
|
try {
|
|
176
|
-
|
|
339
|
+
dirty = false;
|
|
340
|
+
const editStartedAt = now();
|
|
341
|
+
const next = await current.rebuild();
|
|
177
342
|
console.log(dim(" change detected, rebuilding…"));
|
|
178
|
-
|
|
179
|
-
|
|
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);
|
|
180
378
|
watchExit(running);
|
|
379
|
+
input.onReload?.({ editToReadyMs: switchedAt - editStartedAt, downtimeMs: now() - switchedAt });
|
|
181
380
|
console.log(ok(` reloaded on http://127.0.0.1:${input.port}`));
|
|
182
381
|
} catch (cause) {
|
|
183
382
|
// Keep the last good build serving; a typo should not take the
|
|
@@ -189,14 +388,13 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
189
388
|
);
|
|
190
389
|
} finally {
|
|
191
390
|
rebuilding = false;
|
|
391
|
+
if (dirty && !shuttingDown) onChange();
|
|
192
392
|
}
|
|
193
393
|
})();
|
|
394
|
+
void rebuildTask;
|
|
194
395
|
}, RESTART_DEBOUNCE_MS);
|
|
195
396
|
};
|
|
196
|
-
|
|
197
|
-
// changes bindings, so it needs a rebuild too.
|
|
198
|
-
watchers.push(watch(dirname(input.sourcePath), { recursive: true }, onChange));
|
|
199
|
-
watchers.push(watch(resolve(input.projectDir, "sproutboat.jsonc"), onChange));
|
|
397
|
+
resetWatchers();
|
|
200
398
|
}
|
|
201
399
|
|
|
202
400
|
// Watching, we stay up until a signal: a crashed sprout is something to fix
|
|
@@ -206,8 +404,13 @@ export async function runDev(input: DevInput): Promise<void> {
|
|
|
206
404
|
await new Promise<void>((resolve) => {
|
|
207
405
|
resolveShutdown = resolve;
|
|
208
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;
|
|
209
411
|
} else {
|
|
210
412
|
await running.sprout.exited;
|
|
211
413
|
stop(running);
|
|
212
414
|
}
|
|
415
|
+
for (const signal of signals) process.off(signal, shutdown);
|
|
213
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";
|