sproutboat 0.11.12 → 0.11.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -1
- package/SURFACE.md +1 -1
- package/package.json +7 -7
- package/src/bearssl.ts +2 -1
- package/src/build.ts +206 -119
- package/src/bundle.ts +28 -0
- package/src/compile-cache.ts +98 -0
- package/src/compile.ts +23 -19
- package/src/main.ts +1 -0
- package/src/node-shims.ts +378 -0
- package/src/standalone-build.ts +4 -14
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,23 @@ a breaking change.
|
|
|
8
8
|
Reconstructed from git history on 2026-09-03 for everything through v0.4.11;
|
|
9
9
|
maintained going forward by the `release` skill.
|
|
10
10
|
|
|
11
|
+
## Unreleased
|
|
12
|
+
|
|
13
|
+
## [0.11.13] - 2026-09-26
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Record compile duration in the artifact manifest so the platform can show it per deployment.
|
|
17
|
+
- Support selected `node:path`, `node:url`, and `node:querystring` APIs in handlers.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Reuse checksum-verified native binaries for identical release builds and
|
|
21
|
+
refresh broker asset sidecars without recompiling the handler.
|
|
22
|
+
- Report native compilation time and binary cache status after `build`.
|
|
23
|
+
- Reject unsupported service bindings before starting a standalone build.
|
|
24
|
+
- Fetch the pinned Mozilla CA bundle from curl's dated archive so new upstream
|
|
25
|
+
revisions do not break fresh standalone builds.
|
|
26
|
+
- Update the pinned toolchain to `0.4.13` and the artifact contract to `0.3.0`.
|
|
27
|
+
|
|
11
28
|
## [0.11.12] - 2026-09-24
|
|
12
29
|
|
|
13
30
|
### Changed
|
|
@@ -610,7 +627,8 @@ its own package.
|
|
|
610
627
|
- Renamed the package to `sproutboat` (was `@sproutboat/cli`); dropped the
|
|
611
628
|
`sprout` bin alias in favour of a user-defined shell alias.
|
|
612
629
|
|
|
613
|
-
[Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.
|
|
630
|
+
[Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.13...HEAD
|
|
631
|
+
[0.11.13]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.12...v0.11.13
|
|
614
632
|
[0.11.12]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.11...v0.11.12
|
|
615
633
|
[0.11.11]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.10...v0.11.11
|
|
616
634
|
[0.11.10]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.9...v0.11.10
|
package/SURFACE.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
|
|
4
4
|
> toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
|
|
5
5
|
|
|
6
|
-
**Package:** `sproutboat` 0.11.
|
|
6
|
+
**Package:** `sproutboat` 0.11.13 · runs on Bun (use `bunx`, not `npx`)
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sproutboat",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.13",
|
|
4
4
|
"description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"kitchen-sink:standalone": "bun examples/kitchen-sink/harness-standalone.ts"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@sproutboat/artifact": "^0.
|
|
64
|
+
"@sproutboat/artifact": "^0.3.0",
|
|
65
65
|
"@sproutboat/assets": "^0.3.0",
|
|
66
66
|
"@sproutboat/config": "^0.4.0",
|
|
67
67
|
"@sproutboat/runtime": "^0.12.1",
|
|
68
|
-
"@sproutboat/toolchain": "^0.4.
|
|
68
|
+
"@sproutboat/toolchain": "^0.4.13",
|
|
69
69
|
"@sproutboat/wire": "^0.9.1"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
@@ -77,10 +77,10 @@
|
|
|
77
77
|
"typescript": "7.0.2"
|
|
78
78
|
},
|
|
79
79
|
"optionalDependencies": {
|
|
80
|
-
"@sproutboat/cli-darwin-arm64": "0.11.
|
|
81
|
-
"@sproutboat/cli-darwin-x64": "0.11.
|
|
82
|
-
"@sproutboat/cli-linux-arm64": "0.11.
|
|
83
|
-
"@sproutboat/cli-linux-x64": "0.11.
|
|
80
|
+
"@sproutboat/cli-darwin-arm64": "0.11.13",
|
|
81
|
+
"@sproutboat/cli-darwin-x64": "0.11.13",
|
|
82
|
+
"@sproutboat/cli-linux-arm64": "0.11.13",
|
|
83
|
+
"@sproutboat/cli-linux-x64": "0.11.13"
|
|
84
84
|
},
|
|
85
85
|
"engines": {
|
|
86
86
|
"node": ">=18"
|
package/src/bearssl.ts
CHANGED
|
@@ -34,7 +34,8 @@ const BEARSSL_SHA256 = "6705bba1714961b41a728dfc5debbe348d2966c117649392f8c8139e
|
|
|
34
34
|
* release is unknown until the next one. Fine while releases are frequent;
|
|
35
35
|
* revisit if a user is ever stuck on an old CLI for months.
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
// Use the dated archive so its bytes stay equal to the pinned digest.
|
|
38
|
+
const CACERT_URL = "https://curl.se/ca/cacert-2026-08-13.pem";
|
|
38
39
|
const CACERT_SHA256 = "f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9";
|
|
39
40
|
|
|
40
41
|
const cacheDir = (): string =>
|
package/src/build.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { walkAssets, type AssetManifest } from "./assets";
|
|
5
5
|
import { resourceRefs, type SproutboatConfig } from "./config";
|
|
6
6
|
import { ensureSqliteObject } from "./sqlite";
|
|
7
7
|
import { sqliteStamp } from "./sqlite";
|
|
8
8
|
import { bearsslStamp, ensureBearssl } from "./bearssl";
|
|
9
|
-
import { compileSprout, type Transport } from "./compile";
|
|
9
|
+
import { compileSprout, loadPrelude, type Transport } from "./compile";
|
|
10
|
+
import { compileCached } from "./compile-cache";
|
|
10
11
|
import {
|
|
11
12
|
ARTIFACT_SCHEMA_VERSION,
|
|
12
13
|
CAPABILITY_PROFILE,
|
|
@@ -16,6 +17,10 @@ import {
|
|
|
16
17
|
type ArtifactManifest,
|
|
17
18
|
} from "./manifest";
|
|
18
19
|
import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
|
|
20
|
+
import { PORFFOR_ARCHIVE_SHA256, PORFFOR_COMMIT_FULL } from "./porffor-toolchain";
|
|
21
|
+
import { version as toolchainPackageVersion } from "@sproutboat/toolchain/package.json" with { type: "json" };
|
|
22
|
+
import { version as cliVersion } from "../package.json" with { type: "json" };
|
|
23
|
+
import { wrapNativeFetchHandler } from "./wrap";
|
|
19
24
|
|
|
20
25
|
export type BuildInput = {
|
|
21
26
|
projectDir: string;
|
|
@@ -54,6 +59,8 @@ export type BuildInput = {
|
|
|
54
59
|
export type BuildOutput = {
|
|
55
60
|
artifactDir: string;
|
|
56
61
|
manifest: ArtifactManifest;
|
|
62
|
+
compileCache: "hit" | "miss" | "bypass";
|
|
63
|
+
compileMs: number;
|
|
57
64
|
};
|
|
58
65
|
|
|
59
66
|
/** Baking megabytes of assets into the module makes the Porffor compile crawl;
|
|
@@ -64,6 +71,39 @@ function digest(value: Uint8Array | string): `sha256:${string}` {
|
|
|
64
71
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
async function integrationIdentity(): Promise<string> {
|
|
75
|
+
// A published CLI has immutable bundled integration code and a release
|
|
76
|
+
// version. Source checkouts also hash their live files, so local edits never
|
|
77
|
+
// accidentally reuse a binary compiled by an earlier checkout state.
|
|
78
|
+
if (import.meta.url.includes("/$bunfs/")) {
|
|
79
|
+
const executable = await stat(process.execPath);
|
|
80
|
+
return `release:${cliVersion}:${executable.size}:${executable.mtimeMs}`;
|
|
81
|
+
}
|
|
82
|
+
const files = ["build.ts", "compile.ts", "sqlite.ts", "bearssl.ts", "toolchain.ts"].map((file) =>
|
|
83
|
+
resolve(import.meta.dir, file),
|
|
84
|
+
);
|
|
85
|
+
files.push(Bun.resolveSync("@sproutboat/toolchain/patch", import.meta.dir));
|
|
86
|
+
return digest(Buffer.concat(await Promise.all(files.map((file) => readFile(file)))));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function acquireArtifactLock(artifactDir: string): Promise<string> {
|
|
90
|
+
const lock = `${artifactDir}.lock`;
|
|
91
|
+
await mkdir(dirname(lock), { recursive: true });
|
|
92
|
+
const deadline = Date.now() + 12 * 60_000;
|
|
93
|
+
while (true) {
|
|
94
|
+
try {
|
|
95
|
+
await mkdir(lock);
|
|
96
|
+
return lock;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
99
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for artifact lock ${lock}`);
|
|
100
|
+
const info = await stat(lock).catch(() => null);
|
|
101
|
+
if (info && Date.now() - info.mtimeMs > 15 * 60_000) await rm(lock, { recursive: true, force: true });
|
|
102
|
+
else await Bun.sleep(100);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
/**
|
|
68
108
|
* Cross-compile a handler into a linux-x86_64 native-fetch server binary with
|
|
69
109
|
* Porffor + Zig — no Docker. The binary is not run here (the build host may not
|
|
@@ -75,30 +115,6 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
75
115
|
const sourceHash = digest(source);
|
|
76
116
|
const target = input.target ?? "linux-x86_64";
|
|
77
117
|
const embedded = input.transport === "embedded";
|
|
78
|
-
// The directory identity must change whenever an input that changes the
|
|
79
|
-
// generated C or link line changes. A source digest alone reused an artifact
|
|
80
|
-
// path across compiler pins, targets, and standalone ABI settings.
|
|
81
|
-
const artifactId = digest(
|
|
82
|
-
JSON.stringify({
|
|
83
|
-
sourceHash,
|
|
84
|
-
target,
|
|
85
|
-
transport: input.transport ?? "broker",
|
|
86
|
-
optimize: input.optimize ?? "release",
|
|
87
|
-
compatibilityDate: input.config.compatibility_date,
|
|
88
|
-
config: input.config,
|
|
89
|
-
toolchain: toolchainStamp(),
|
|
90
|
-
compiler:
|
|
91
|
-
target === "host"
|
|
92
|
-
? `host:${process.env.CC ?? "cc"}`
|
|
93
|
-
: process.env.SPROUTBOAT_ZIG
|
|
94
|
-
? `override:${process.env.SPROUTBOAT_ZIG}`
|
|
95
|
-
: "managed-zig",
|
|
96
|
-
native: embedded ? [sqliteStamp(), bearsslStamp()] : [],
|
|
97
|
-
}),
|
|
98
|
-
).slice("sha256:".length, 24);
|
|
99
|
-
const artifactDir = input.outputDirectory ?? resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
100
|
-
const sproutPath = resolve(artifactDir, "sprout");
|
|
101
|
-
await mkdir(artifactDir, { recursive: true });
|
|
102
118
|
|
|
103
119
|
// #74 — split each storage-binding array into its binding-name list (the
|
|
104
120
|
// legacy shape the prelude/broker read) plus a `resources` map { binding ->
|
|
@@ -134,33 +150,34 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
134
150
|
resources,
|
|
135
151
|
};
|
|
136
152
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
153
|
+
const host = target === "host";
|
|
154
|
+
const assetDir = input.config.assets ? resolve(input.projectDir, input.config.assets.directory) : undefined;
|
|
155
|
+
if (
|
|
156
|
+
assetDir &&
|
|
157
|
+
!(await stat(assetDir)
|
|
158
|
+
.then((s) => s.isDirectory())
|
|
159
|
+
.catch(() => false))
|
|
160
|
+
) {
|
|
161
|
+
throw new Error(`assets.directory "${input.config.assets?.directory}" not found — run your site build first`);
|
|
162
|
+
}
|
|
163
|
+
const assetManifest: AssetManifest | undefined =
|
|
164
|
+
assetDir && input.config.assets
|
|
165
|
+
? {
|
|
166
|
+
notFound: input.config.assets.not_found_handling ?? "none",
|
|
167
|
+
runSproutFirst: input.config.assets.run_sprout_first ?? false,
|
|
168
|
+
files: walkAssets(assetDir),
|
|
169
|
+
}
|
|
170
|
+
: undefined;
|
|
148
171
|
// #15 — an embedded binary has no files beside it, so assets are baked into
|
|
149
172
|
// the module. Read them from the source directory: the artifact copy happens
|
|
150
173
|
// after the compile, and the compile is what needs them. Bytes travel as a
|
|
151
174
|
// latin1 string, one char per byte, which is what the asset shim hands back.
|
|
152
175
|
let bakedAssets: { manifest: AssetManifest; files: Record<string, string> } | undefined;
|
|
153
|
-
if (
|
|
154
|
-
const dir = resolve(input.projectDir, input.config.assets.directory);
|
|
155
|
-
const manifest: AssetManifest = {
|
|
156
|
-
notFound: input.config.assets.not_found_handling ?? "none",
|
|
157
|
-
runSproutFirst: input.config.assets.run_sprout_first ?? false,
|
|
158
|
-
files: walkAssets(dir),
|
|
159
|
-
};
|
|
176
|
+
if (embedded && assetDir && assetManifest) {
|
|
160
177
|
const files: Record<string, string> = {};
|
|
161
178
|
let total = 0;
|
|
162
|
-
for (const key of Object.keys(
|
|
163
|
-
const bytes = await readFile(resolve(
|
|
179
|
+
for (const key of Object.keys(assetManifest.files)) {
|
|
180
|
+
const bytes = await readFile(resolve(assetDir, `.${key}`));
|
|
164
181
|
total += bytes.byteLength;
|
|
165
182
|
if (total > MAX_BAKED_ASSET_BYTES) {
|
|
166
183
|
throw new Error(
|
|
@@ -170,86 +187,156 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
170
187
|
}
|
|
171
188
|
files[key] = bytes.toString("latin1");
|
|
172
189
|
}
|
|
173
|
-
bakedAssets = { manifest, files };
|
|
190
|
+
bakedAssets = { manifest: assetManifest, files };
|
|
174
191
|
}
|
|
175
192
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
193
|
+
// Version metadata is compiled into the executable. Its timestamp makes a
|
|
194
|
+
// new binary necessary on every build when this binding is configured.
|
|
178
195
|
const builtAt = new Date().toISOString();
|
|
196
|
+
const versionId = digest(
|
|
197
|
+
JSON.stringify({ sourceHash, target, config: input.config, assets: assetManifest, toolchain: toolchainStamp() }),
|
|
198
|
+
).slice("sha256:".length, 24);
|
|
179
199
|
const versionMetadata = input.config.version_metadata
|
|
180
|
-
? { binding: input.config.version_metadata, id:
|
|
200
|
+
? { binding: input.config.version_metadata, id: versionId, tag: input.config.name, timestamp: builtAt }
|
|
181
201
|
: undefined;
|
|
202
|
+
const generatedSource = wrapNativeFetchHandler(
|
|
203
|
+
source.toString(),
|
|
204
|
+
await loadPrelude(input.transport ?? "broker"),
|
|
205
|
+
input.config.vars ?? {},
|
|
206
|
+
bindings,
|
|
207
|
+
undefined,
|
|
208
|
+
input.config.compatibility_date,
|
|
209
|
+
input.config.name,
|
|
210
|
+
bakedAssets,
|
|
211
|
+
input.transport,
|
|
212
|
+
versionMetadata,
|
|
213
|
+
);
|
|
214
|
+
const compileKey = digest(
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
generatedSource,
|
|
217
|
+
target,
|
|
218
|
+
optimize: input.optimize ?? "release",
|
|
219
|
+
toolchain: toolchainStamp(),
|
|
220
|
+
toolchainPackageVersion,
|
|
221
|
+
porffor: [PORFFOR_COMMIT_FULL, PORFFOR_ARCHIVE_SHA256],
|
|
222
|
+
native: embedded ? [sqliteStamp(), bearsslStamp()] : [],
|
|
223
|
+
integration: await integrationIdentity(),
|
|
224
|
+
}),
|
|
225
|
+
).slice("sha256:".length);
|
|
226
|
+
const artifactId = versionMetadata
|
|
227
|
+
? versionId
|
|
228
|
+
: digest(JSON.stringify({ compileKey, config: input.config, assets: assetManifest })).slice("sha256:".length, 24);
|
|
229
|
+
const artifactDir = input.outputDirectory ?? resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
230
|
+
const sproutPath = resolve(artifactDir, "sprout");
|
|
231
|
+
const artifactLock = await acquireArtifactLock(artifactDir);
|
|
232
|
+
try {
|
|
233
|
+
await mkdir(artifactDir, { recursive: true });
|
|
182
234
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
porfforVersion: porfforVersion(),
|
|
212
|
-
esbuildVersion: esbuildVersion(),
|
|
213
|
-
buildImage: toolchainStamp(),
|
|
214
|
-
compatibilityDate: input.config.compatibility_date,
|
|
215
|
-
sourceHash,
|
|
216
|
-
binaryHash: digest(sprout),
|
|
217
|
-
binarySize: (await stat(sproutPath)).size,
|
|
218
|
-
builtAt,
|
|
219
|
-
};
|
|
220
|
-
await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
221
|
-
// Bindings live beside the manifest, not in it: the artifact manifest schema is
|
|
222
|
-
// frozen at v2. The control plane reads this to configure the per-deployment
|
|
223
|
-
// broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
|
|
224
|
-
// schedules, Durable Object classes).
|
|
225
|
-
const hasBindings =
|
|
226
|
-
Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0) ||
|
|
227
|
-
Object.keys(bindings.resources).length > 0 ||
|
|
228
|
-
Object.keys(bindings.vars).length > 0;
|
|
229
|
-
if (hasBindings) {
|
|
230
|
-
await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Static assets: copy the directory next to the artifact and record a manifest
|
|
234
|
-
// the edge serves from directly (assets-first) and the broker reads for
|
|
235
|
-
// `env.<ASSETS>.fetch()`.
|
|
236
|
-
if (input.config.assets) {
|
|
237
|
-
const srcDir = resolve(input.projectDir, input.config.assets.directory);
|
|
238
|
-
const outDir = resolve(artifactDir, "assets");
|
|
235
|
+
const compile = async (outPath: string) => {
|
|
236
|
+
// Acquire native inputs only after a cache miss. A host build never needs
|
|
237
|
+
// the cross-compiler; embedded builds link SQLite and BearSSL as well.
|
|
238
|
+
const zigBin = host ? undefined : await ensureZig();
|
|
239
|
+
const [sqliteObject, tls] = embedded
|
|
240
|
+
? await Promise.all([ensureSqliteObject({ target, zigBin }), ensureBearssl({ target, zigBin })])
|
|
241
|
+
: [null, null];
|
|
242
|
+
await compileSprout({
|
|
243
|
+
sourcePath: input.sourcePath,
|
|
244
|
+
generatedPath: input.generatedPath,
|
|
245
|
+
generatedSource,
|
|
246
|
+
outPath,
|
|
247
|
+
vars: input.config.vars ?? {},
|
|
248
|
+
bindings,
|
|
249
|
+
zigBin,
|
|
250
|
+
target: input.target,
|
|
251
|
+
compatibilityDate: input.config.compatibility_date,
|
|
252
|
+
transport: input.transport,
|
|
253
|
+
appName: input.config.name,
|
|
254
|
+
assets: bakedAssets,
|
|
255
|
+
extraLink: [...(sqliteObject ? [sqliteObject] : []), ...(tls ? tls.objects : [])],
|
|
256
|
+
extraCflags: tls ? ["-I", tls.includeDir] : [],
|
|
257
|
+
optimize: input.optimize,
|
|
258
|
+
versionMetadata,
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
let compileCache: BuildOutput["compileCache"] = "bypass";
|
|
262
|
+
const compileStartedAt = performance.now();
|
|
239
263
|
if (
|
|
240
|
-
!
|
|
241
|
-
|
|
242
|
-
|
|
264
|
+
!input.reuseSproutPath &&
|
|
265
|
+
!host &&
|
|
266
|
+
input.optimize !== "dev" &&
|
|
267
|
+
!input.outputDirectory &&
|
|
268
|
+
!versionMetadata &&
|
|
269
|
+
!process.env.SPROUTBOAT_PORFFOR_DIR &&
|
|
270
|
+
!process.env.SPROUTBOAT_ZIG &&
|
|
271
|
+
!process.env.SPROUTBOAT_UWS_TARBALL &&
|
|
272
|
+
!process.env.SPROUTBOAT_BUILD_UWS_FROM_SOURCE &&
|
|
273
|
+
!process.env.PORFFOR_VERSION
|
|
243
274
|
) {
|
|
244
|
-
|
|
275
|
+
compileCache = await compileCached(
|
|
276
|
+
resolve(input.projectDir, ".sproutboat/compile-cache"),
|
|
277
|
+
compileKey,
|
|
278
|
+
sproutPath,
|
|
279
|
+
compile,
|
|
280
|
+
);
|
|
281
|
+
} else {
|
|
282
|
+
const candidate = resolve(artifactDir, `.sprout-${randomUUID()}`);
|
|
283
|
+
try {
|
|
284
|
+
if (input.reuseSproutPath) await cp(input.reuseSproutPath, candidate);
|
|
285
|
+
else await compile(candidate);
|
|
286
|
+
await rename(candidate, sproutPath);
|
|
287
|
+
} finally {
|
|
288
|
+
await rm(candidate, { force: true });
|
|
289
|
+
}
|
|
245
290
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
291
|
+
const compileMs = Math.round(performance.now() - compileStartedAt);
|
|
292
|
+
|
|
293
|
+
const sprout = await readFile(sproutPath);
|
|
294
|
+
const manifest: ArtifactManifest & { compileMs: number } = {
|
|
295
|
+
schemaVersion: ARTIFACT_SCHEMA_VERSION,
|
|
296
|
+
project: input.config.name,
|
|
297
|
+
target: host ? hostTarget() : DEPLOY_TARGET,
|
|
298
|
+
runtime: RUNTIME,
|
|
299
|
+
capabilityProfile: CAPABILITY_PROFILE,
|
|
300
|
+
porfforVersion: porfforVersion(),
|
|
301
|
+
esbuildVersion: esbuildVersion(),
|
|
302
|
+
buildImage: toolchainStamp(),
|
|
303
|
+
compatibilityDate: input.config.compatibility_date,
|
|
304
|
+
sourceHash,
|
|
305
|
+
binaryHash: digest(sprout),
|
|
306
|
+
binarySize: (await stat(sproutPath)).size,
|
|
307
|
+
compileMs,
|
|
308
|
+
builtAt,
|
|
251
309
|
};
|
|
252
|
-
await writeFile(resolve(artifactDir, "
|
|
310
|
+
await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
311
|
+
// Bindings live beside the manifest, not in it: the artifact manifest schema is
|
|
312
|
+
// frozen at v2. The control plane reads this to configure the per-deployment
|
|
313
|
+
// broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
|
|
314
|
+
// schedules, Durable Object classes).
|
|
315
|
+
const hasBindings =
|
|
316
|
+
Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0) ||
|
|
317
|
+
Object.keys(bindings.resources).length > 0 ||
|
|
318
|
+
Object.keys(bindings.vars).length > 0;
|
|
319
|
+
if (hasBindings) {
|
|
320
|
+
await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
|
|
321
|
+
} else await rm(resolve(artifactDir, "bindings.json"), { force: true });
|
|
322
|
+
|
|
323
|
+
// Static assets: copy the directory next to the artifact and record a manifest
|
|
324
|
+
// the edge serves from directly (assets-first) and the broker reads for
|
|
325
|
+
// `env.<ASSETS>.fetch()`.
|
|
326
|
+
if (assetDir && assetManifest) {
|
|
327
|
+
const outDir = resolve(artifactDir, "assets");
|
|
328
|
+
await rm(outDir, { recursive: true, force: true });
|
|
329
|
+
await cp(assetDir, outDir, { recursive: true });
|
|
330
|
+
if (JSON.stringify(walkAssets(outDir)) !== JSON.stringify(assetManifest.files)) {
|
|
331
|
+
throw new Error("assets changed during the build; retry after the asset build finishes");
|
|
332
|
+
}
|
|
333
|
+
await writeFile(resolve(artifactDir, "assets.json"), `${JSON.stringify(assetManifest, null, 2)}\n`);
|
|
334
|
+
} else {
|
|
335
|
+
await rm(resolve(artifactDir, "assets.json"), { force: true });
|
|
336
|
+
await rm(resolve(artifactDir, "assets"), { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
return { artifactDir, manifest, compileCache, compileMs };
|
|
339
|
+
} finally {
|
|
340
|
+
await rm(artifactLock, { recursive: true, force: true });
|
|
253
341
|
}
|
|
254
|
-
return { artifactDir, manifest };
|
|
255
342
|
}
|
package/src/bundle.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* reaching for a Node API fails exactly as user code would.
|
|
13
13
|
*/
|
|
14
14
|
import { relative } from "node:path";
|
|
15
|
+
import { NODE_SHIMS } from "./node-shims";
|
|
15
16
|
|
|
16
17
|
export type BundleResult = {
|
|
17
18
|
/** One self-contained ESM module: what gets validated, hashed, and compiled. */
|
|
@@ -27,6 +28,32 @@ export class BundleError extends Error {
|
|
|
27
28
|
|
|
28
29
|
const entryLabel = (entryPath: string, projectDir: string): string => relative(projectDir, entryPath) || entryPath;
|
|
29
30
|
|
|
31
|
+
const shimSpecifiers = new RegExp(
|
|
32
|
+
`^(${Object.keys(NODE_SHIMS)
|
|
33
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
34
|
+
.join("|")})$`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bun already bundles working polyfills for most node:* built-ins under
|
|
39
|
+
* `target: "browser"` (verified for node:url, node:querystring). This
|
|
40
|
+
* plugin only overrides the ones in NODE_SHIMS — currently just node:path,
|
|
41
|
+
* whose bundled resolve() otherwise falls back to `process.cwd()`.
|
|
42
|
+
*/
|
|
43
|
+
const nodeShimsPlugin: import("bun").BunPlugin = {
|
|
44
|
+
name: "sproutboat-node-shims",
|
|
45
|
+
setup(build) {
|
|
46
|
+
build.onResolve({ filter: shimSpecifiers }, (args) => ({ path: args.path, namespace: "sb-node-shim" }));
|
|
47
|
+
build.onLoad({ filter: /.*/, namespace: "sb-node-shim" }, (args) => ({
|
|
48
|
+
// SAFETY: onResolve above only ever routes a specifier matched by
|
|
49
|
+
// shimSpecifiers (built from NODE_SHIMS's own keys) into this
|
|
50
|
+
// namespace, so args.path is always one of NODE_SHIMS's keys here.
|
|
51
|
+
contents: (NODE_SHIMS as Record<string, string>)[args.path],
|
|
52
|
+
loader: "js",
|
|
53
|
+
}));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
30
57
|
/** Bun reports resolution failures on `AggregateError.errors`; its own message is just "Bundle failed". */
|
|
31
58
|
function bundleDetail(cause: unknown): string {
|
|
32
59
|
const errors = cause instanceof AggregateError ? cause.errors : [];
|
|
@@ -51,6 +78,7 @@ export async function bundleHandler(entryPath: string, projectDir: string): Prom
|
|
|
51
78
|
minify: false,
|
|
52
79
|
splitting: false,
|
|
53
80
|
sourcemap: "none",
|
|
81
|
+
plugins: [nodeShimsPlugin],
|
|
54
82
|
});
|
|
55
83
|
} catch (cause) {
|
|
56
84
|
// An unresolvable specifier arrives as an AggregateError whose `errors`
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
type CacheRecord = { key: string; hash: string; size: number };
|
|
6
|
+
|
|
7
|
+
function digest(bytes: Uint8Array): string {
|
|
8
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function verified(entry: string, key: string): Promise<Buffer | null> {
|
|
12
|
+
try {
|
|
13
|
+
// SAFETY: cache.json is private cache data; the key, metadata and binary
|
|
14
|
+
// digest are all checked before this entry can be used.
|
|
15
|
+
const record = JSON.parse(await readFile(resolve(entry, "cache.json"), "utf8")) as CacheRecord;
|
|
16
|
+
if (record.key !== key) return null;
|
|
17
|
+
const path = resolve(entry, "sprout");
|
|
18
|
+
const info = await stat(path);
|
|
19
|
+
if (!info.isFile() || (info.mode & 0o111) === 0 || info.size !== record.size) return null;
|
|
20
|
+
const bytes = await readFile(path);
|
|
21
|
+
return digest(bytes) === record.hash ? bytes : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function publish(bytes: Uint8Array, outPath: string): Promise<void> {
|
|
28
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
29
|
+
const candidate = `${outPath}.${randomUUID()}.tmp`;
|
|
30
|
+
try {
|
|
31
|
+
await writeFile(candidate, bytes);
|
|
32
|
+
await chmod(candidate, 0o555);
|
|
33
|
+
await rename(candidate, outPath);
|
|
34
|
+
} finally {
|
|
35
|
+
await rm(candidate, { force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A per-project binary cache. The lock covers compilation and publication, so
|
|
40
|
+
* another process can only read a complete, checksum-verified entry. */
|
|
41
|
+
export async function compileCached(
|
|
42
|
+
cacheRoot: string,
|
|
43
|
+
key: string,
|
|
44
|
+
outPath: string,
|
|
45
|
+
compile: (candidatePath: string) => Promise<void>,
|
|
46
|
+
): Promise<"hit" | "miss"> {
|
|
47
|
+
const entry = resolve(cacheRoot, key);
|
|
48
|
+
const cached = await verified(entry, key);
|
|
49
|
+
if (cached) {
|
|
50
|
+
await publish(cached, outPath);
|
|
51
|
+
return "hit";
|
|
52
|
+
}
|
|
53
|
+
await mkdir(cacheRoot, { recursive: true });
|
|
54
|
+
const lock = `${entry}.lock`;
|
|
55
|
+
const deadline = Date.now() + 12 * 60_000;
|
|
56
|
+
while (true) {
|
|
57
|
+
try {
|
|
58
|
+
await mkdir(lock);
|
|
59
|
+
break;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// SAFETY: Node filesystem errors expose code; unrelated failures are
|
|
62
|
+
// rethrown instead of being treated as a held lock.
|
|
63
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
64
|
+
const ready = await verified(entry, key);
|
|
65
|
+
if (ready) {
|
|
66
|
+
await publish(ready, outPath);
|
|
67
|
+
return "hit";
|
|
68
|
+
}
|
|
69
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for compile cache lock ${lock}`);
|
|
70
|
+
const lockInfo = await stat(lock).catch(() => null);
|
|
71
|
+
if (lockInfo && Date.now() - lockInfo.mtimeMs > 15 * 60_000) await rm(lock, { recursive: true, force: true });
|
|
72
|
+
else await Bun.sleep(100);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let stage: string | undefined;
|
|
76
|
+
try {
|
|
77
|
+
const ready = await verified(entry, key);
|
|
78
|
+
if (ready) {
|
|
79
|
+
await publish(ready, outPath);
|
|
80
|
+
return "hit";
|
|
81
|
+
}
|
|
82
|
+
await rm(entry, { recursive: true, force: true });
|
|
83
|
+
stage = await mkdtemp(resolve(cacheRoot, `.candidate-${key}-`));
|
|
84
|
+
const candidate = resolve(stage, "sprout");
|
|
85
|
+
await compile(candidate);
|
|
86
|
+
const bytes = await readFile(candidate);
|
|
87
|
+
const record: CacheRecord = { key, hash: digest(bytes), size: bytes.byteLength };
|
|
88
|
+
await chmod(candidate, 0o555);
|
|
89
|
+
await writeFile(resolve(stage, "cache.json"), JSON.stringify(record));
|
|
90
|
+
await rename(stage, entry);
|
|
91
|
+
stage = undefined;
|
|
92
|
+
await publish(bytes, outPath);
|
|
93
|
+
return "miss";
|
|
94
|
+
} finally {
|
|
95
|
+
if (stage) await rm(stage, { recursive: true, force: true });
|
|
96
|
+
await rm(lock, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/compile.ts
CHANGED
|
@@ -53,6 +53,8 @@ export type CompileInput = {
|
|
|
53
53
|
generatedPath?: string;
|
|
54
54
|
/** The bundled module (#89). Falls back to reading `sourcePath` verbatim. */
|
|
55
55
|
source?: string;
|
|
56
|
+
/** The exact wrapped module used to compute a verified compile-cache key. */
|
|
57
|
+
generatedSource?: string;
|
|
56
58
|
outPath: string;
|
|
57
59
|
vars: Record<string, string>;
|
|
58
60
|
bindings?: Bindings;
|
|
@@ -198,25 +200,27 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
198
200
|
await rm(input.outPath, { force: true });
|
|
199
201
|
const generatedPath = input.generatedPath ?? resolve(outDir, "sprout.generated.js");
|
|
200
202
|
await mkdir(dirname(generatedPath), { recursive: true });
|
|
201
|
-
const
|
|
202
|
-
input.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
203
|
+
const generatedSource =
|
|
204
|
+
input.generatedSource ??
|
|
205
|
+
(await (async () => {
|
|
206
|
+
const [source, prelude] = await Promise.all([
|
|
207
|
+
input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
|
|
208
|
+
loadPrelude(input.transport ?? "broker"),
|
|
209
|
+
]);
|
|
210
|
+
return wrapNativeFetchHandler(
|
|
211
|
+
source,
|
|
212
|
+
prelude,
|
|
213
|
+
input.vars,
|
|
214
|
+
input.bindings ?? EMPTY_BINDINGS,
|
|
215
|
+
undefined,
|
|
216
|
+
input.compatibilityDate,
|
|
217
|
+
input.appName,
|
|
218
|
+
input.assets,
|
|
219
|
+
input.transport,
|
|
220
|
+
input.versionMetadata,
|
|
221
|
+
);
|
|
222
|
+
})());
|
|
223
|
+
await writeFile(generatedPath, generatedSource);
|
|
220
224
|
|
|
221
225
|
const launcher = resolve(porffor, "runtime/index.js");
|
|
222
226
|
// A musl build shells `zig` bare (Porffor hardcodes the literal command), so
|
package/src/main.ts
CHANGED
|
@@ -319,6 +319,7 @@ async function build(directory?: string, target: "linux-x86_64" | "host" = "linu
|
|
|
319
319
|
target,
|
|
320
320
|
});
|
|
321
321
|
console.log(ok(`built ${project.config.name}`));
|
|
322
|
+
console.log(dim(` compile ${artifact.compileMs} ms · binary cache ${artifact.compileCache}`));
|
|
322
323
|
if (target === "host")
|
|
323
324
|
console.log(dim(" host build — runs here, not deployable; drop --target host to build for a box"));
|
|
324
325
|
console.log(artifact.artifactDir);
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun's bundler ships full Node-compatible polyfills for node:path,
|
|
3
|
+
* node:url, and node:querystring under `target: "browser"`, but each has
|
|
4
|
+
* exactly one thing wrong with it for a Porffor-compiled handler — found
|
|
5
|
+
* by auditing bundled output against the capability check (source.ts) and,
|
|
6
|
+
* for node:url, by an actual failure through the real Porffor compiler.
|
|
7
|
+
* Auditing the bundle alone was not enough:
|
|
8
|
+
*
|
|
9
|
+
* - node:path's bundled `resolve()` falls back to `process.cwd()`, which
|
|
10
|
+
* trips the `process.` ban in `../packages/runtime/src/source.ts`.
|
|
11
|
+
* - node:querystring's bundled polyfill pulls in Bun's full Buffer
|
|
12
|
+
* implementation internally (for percent-decoding), tripping the
|
|
13
|
+
* `Buffer.` ban.
|
|
14
|
+
* - node:url's bundled `URL`/`URLSearchParams` exports are
|
|
15
|
+
* `var { URL, URLSearchParams } = globalThis;` — passes every capability
|
|
16
|
+
* check (no banned pattern in it) but Porffor's compiler rejects it at
|
|
17
|
+
* parse time as `Identifier 'URL' has already been declared`: Porffor
|
|
18
|
+
* pre-binds `URL` as a global, and a second top-level declaration of
|
|
19
|
+
* that exact name is a compile error, confirmed by actually building a
|
|
20
|
+
* handler through `sproutboat build` rather than just checking the
|
|
21
|
+
* bundle. `URL`/`URLSearchParams` are already global in a compiled
|
|
22
|
+
* handler (see `docs/concepts/handler.mdx`), so nothing needs importing
|
|
23
|
+
* — the fix is a shim that re-exports the globals under aliased local
|
|
24
|
+
* names instead of declaring anything literally named `URL`.
|
|
25
|
+
*
|
|
26
|
+
* None of these are the capability check being wrong — every case is a
|
|
27
|
+
* real thing (`process.cwd()`, `Buffer`, a name Porffor itself reserves)
|
|
28
|
+
* that doesn't exist the way a compiled handler needs it to, so the shims
|
|
29
|
+
* replace it rather than the check being loosened.
|
|
30
|
+
*/
|
|
31
|
+
const NODE_PATH_SHIM = `
|
|
32
|
+
function assertPath(path) {
|
|
33
|
+
if (typeof path !== "string")
|
|
34
|
+
throw TypeError("Path must be a string. Received " + JSON.stringify(path));
|
|
35
|
+
}
|
|
36
|
+
function normalizeStringPosix(path, allowAboveRoot) {
|
|
37
|
+
var res = "", lastSegmentLength = 0, lastSlash = -1, dots = 0, code;
|
|
38
|
+
for (var i = 0; i <= path.length; ++i) {
|
|
39
|
+
if (i < path.length) code = path.charCodeAt(i);
|
|
40
|
+
else if (code === 47) break;
|
|
41
|
+
else code = 47;
|
|
42
|
+
if (code === 47) {
|
|
43
|
+
if (lastSlash === i - 1 || dots === 1) {
|
|
44
|
+
} else if (lastSlash !== i - 1 && dots === 2) {
|
|
45
|
+
if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== 46 || res.charCodeAt(res.length - 2) !== 46) {
|
|
46
|
+
if (res.length > 2) {
|
|
47
|
+
var lastSlashIndex = res.lastIndexOf("/");
|
|
48
|
+
if (lastSlashIndex !== res.length - 1) {
|
|
49
|
+
if (lastSlashIndex === -1) res = "", lastSegmentLength = 0;
|
|
50
|
+
else res = res.slice(0, lastSlashIndex), lastSegmentLength = res.length - 1 - res.lastIndexOf("/");
|
|
51
|
+
lastSlash = i, dots = 0;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
} else if (res.length === 2 || res.length === 1) {
|
|
55
|
+
res = "", lastSegmentLength = 0, lastSlash = i, dots = 0;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (allowAboveRoot) {
|
|
60
|
+
if (res.length > 0) res += "/..";
|
|
61
|
+
else res = "..";
|
|
62
|
+
lastSegmentLength = 2;
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
if (res.length > 0) res += "/" + path.slice(lastSlash + 1, i);
|
|
66
|
+
else res = path.slice(lastSlash + 1, i);
|
|
67
|
+
lastSegmentLength = i - lastSlash - 1;
|
|
68
|
+
}
|
|
69
|
+
lastSlash = i, dots = 0;
|
|
70
|
+
} else if (code === 46 && dots !== -1) ++dots;
|
|
71
|
+
else dots = -1;
|
|
72
|
+
}
|
|
73
|
+
return res;
|
|
74
|
+
}
|
|
75
|
+
function _format(sep, pathObject) {
|
|
76
|
+
var dir = pathObject.dir || pathObject.root, base = pathObject.base || (pathObject.name || "") + (pathObject.ext || "");
|
|
77
|
+
if (!dir) return base;
|
|
78
|
+
if (dir === pathObject.root) return dir + base;
|
|
79
|
+
return dir + sep + base;
|
|
80
|
+
}
|
|
81
|
+
function resolve() {
|
|
82
|
+
// Node falls back to process.cwd() here; a compiled handler has no real
|
|
83
|
+
// filesystem or cwd, so an all-relative resolve() resolves against a
|
|
84
|
+
// fixed virtual root instead.
|
|
85
|
+
var resolvedPath = "", resolvedAbsolute = false, cwd;
|
|
86
|
+
for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) {
|
|
87
|
+
var path;
|
|
88
|
+
if (i >= 0) path = arguments[i];
|
|
89
|
+
else {
|
|
90
|
+
if (cwd === undefined) cwd = "/";
|
|
91
|
+
path = cwd;
|
|
92
|
+
}
|
|
93
|
+
if (assertPath(path), path.length === 0) continue;
|
|
94
|
+
resolvedPath = path + "/" + resolvedPath, resolvedAbsolute = path.charCodeAt(0) === 47;
|
|
95
|
+
}
|
|
96
|
+
if (resolvedPath = normalizeStringPosix(resolvedPath, !resolvedAbsolute), resolvedAbsolute)
|
|
97
|
+
if (resolvedPath.length > 0) return "/" + resolvedPath;
|
|
98
|
+
else return "/";
|
|
99
|
+
else if (resolvedPath.length > 0) return resolvedPath;
|
|
100
|
+
else return ".";
|
|
101
|
+
}
|
|
102
|
+
function normalize(path) {
|
|
103
|
+
if (assertPath(path), path.length === 0) return ".";
|
|
104
|
+
var isAbsolute = path.charCodeAt(0) === 47, trailingSeparator = path.charCodeAt(path.length - 1) === 47;
|
|
105
|
+
if (path = normalizeStringPosix(path, !isAbsolute), path.length === 0 && !isAbsolute) path = ".";
|
|
106
|
+
if (path.length > 0 && trailingSeparator) path += "/";
|
|
107
|
+
if (isAbsolute) return "/" + path;
|
|
108
|
+
return path;
|
|
109
|
+
}
|
|
110
|
+
function isAbsolute(path) {
|
|
111
|
+
return assertPath(path), path.length > 0 && path.charCodeAt(0) === 47;
|
|
112
|
+
}
|
|
113
|
+
function join() {
|
|
114
|
+
if (arguments.length === 0) return ".";
|
|
115
|
+
var joined;
|
|
116
|
+
for (var i = 0; i < arguments.length; ++i) {
|
|
117
|
+
var arg = arguments[i];
|
|
118
|
+
if (assertPath(arg), arg.length > 0)
|
|
119
|
+
if (joined === undefined) joined = arg;
|
|
120
|
+
else joined += "/" + arg;
|
|
121
|
+
}
|
|
122
|
+
if (joined === undefined) return ".";
|
|
123
|
+
return normalize(joined);
|
|
124
|
+
}
|
|
125
|
+
function relative(from, to) {
|
|
126
|
+
if (assertPath(from), assertPath(to), from === to) return "";
|
|
127
|
+
if (from = resolve(from), to = resolve(to), from === to) return "";
|
|
128
|
+
var fromStart = 1;
|
|
129
|
+
for (; fromStart < from.length; ++fromStart) if (from.charCodeAt(fromStart) !== 47) break;
|
|
130
|
+
var fromEnd = from.length, fromLen = fromEnd - fromStart, toStart = 1;
|
|
131
|
+
for (; toStart < to.length; ++toStart) if (to.charCodeAt(toStart) !== 47) break;
|
|
132
|
+
var toEnd = to.length, toLen = toEnd - toStart, length = fromLen < toLen ? fromLen : toLen, lastCommonSep = -1, i = 0;
|
|
133
|
+
for (; i <= length; ++i) {
|
|
134
|
+
if (i === length) {
|
|
135
|
+
if (toLen > length) {
|
|
136
|
+
if (to.charCodeAt(toStart + i) === 47) return to.slice(toStart + i + 1);
|
|
137
|
+
else if (i === 0) return to.slice(toStart + i);
|
|
138
|
+
} else if (fromLen > length) {
|
|
139
|
+
if (from.charCodeAt(fromStart + i) === 47) lastCommonSep = i;
|
|
140
|
+
else if (i === 0) lastCommonSep = 0;
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
var fromCode = from.charCodeAt(fromStart + i), toCode = to.charCodeAt(toStart + i);
|
|
145
|
+
if (fromCode !== toCode) break;
|
|
146
|
+
else if (fromCode === 47) lastCommonSep = i;
|
|
147
|
+
}
|
|
148
|
+
var out = "";
|
|
149
|
+
for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i)
|
|
150
|
+
if (i === fromEnd || from.charCodeAt(i) === 47)
|
|
151
|
+
if (out.length === 0) out += "..";
|
|
152
|
+
else out += "/..";
|
|
153
|
+
if (out.length > 0) return out + to.slice(toStart + lastCommonSep);
|
|
154
|
+
else {
|
|
155
|
+
if (toStart += lastCommonSep, to.charCodeAt(toStart) === 47) ++toStart;
|
|
156
|
+
return to.slice(toStart);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function dirname(path) {
|
|
160
|
+
if (assertPath(path), path.length === 0) return ".";
|
|
161
|
+
var code = path.charCodeAt(0), hasRoot = code === 47, end = -1, matchedSlash = true;
|
|
162
|
+
for (var i = path.length - 1; i >= 1; --i)
|
|
163
|
+
if (code = path.charCodeAt(i), code === 47) {
|
|
164
|
+
if (!matchedSlash) { end = i; break; }
|
|
165
|
+
} else matchedSlash = false;
|
|
166
|
+
if (end === -1) return hasRoot ? "/" : ".";
|
|
167
|
+
if (hasRoot && end === 1) return "//";
|
|
168
|
+
return path.slice(0, end);
|
|
169
|
+
}
|
|
170
|
+
function basename(path, ext) {
|
|
171
|
+
if (ext !== undefined && typeof ext !== "string") throw TypeError('"ext" argument must be a string');
|
|
172
|
+
assertPath(path);
|
|
173
|
+
var start = 0, end = -1, matchedSlash = true, i;
|
|
174
|
+
if (ext !== undefined && ext.length > 0 && ext.length <= path.length) {
|
|
175
|
+
if (ext.length === path.length && ext === path) return "";
|
|
176
|
+
var extIdx = ext.length - 1, firstNonSlashEnd = -1;
|
|
177
|
+
for (i = path.length - 1; i >= 0; --i) {
|
|
178
|
+
var code = path.charCodeAt(i);
|
|
179
|
+
if (code === 47) {
|
|
180
|
+
if (!matchedSlash) { start = i + 1; break; }
|
|
181
|
+
} else {
|
|
182
|
+
if (firstNonSlashEnd === -1) matchedSlash = false, firstNonSlashEnd = i + 1;
|
|
183
|
+
if (extIdx >= 0)
|
|
184
|
+
if (code === ext.charCodeAt(extIdx)) { if (--extIdx === -1) end = i; }
|
|
185
|
+
else extIdx = -1, end = firstNonSlashEnd;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (start === end) end = firstNonSlashEnd;
|
|
189
|
+
else if (end === -1) end = path.length;
|
|
190
|
+
return path.slice(start, end);
|
|
191
|
+
} else {
|
|
192
|
+
for (i = path.length - 1; i >= 0; --i)
|
|
193
|
+
if (path.charCodeAt(i) === 47) {
|
|
194
|
+
if (!matchedSlash) { start = i + 1; break; }
|
|
195
|
+
} else if (end === -1) matchedSlash = false, end = i + 1;
|
|
196
|
+
if (end === -1) return "";
|
|
197
|
+
return path.slice(start, end);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function extname(path) {
|
|
201
|
+
assertPath(path);
|
|
202
|
+
var startDot = -1, startPart = 0, end = -1, matchedSlash = true, preDotState = 0;
|
|
203
|
+
for (var i = path.length - 1; i >= 0; --i) {
|
|
204
|
+
var code = path.charCodeAt(i);
|
|
205
|
+
if (code === 47) {
|
|
206
|
+
if (!matchedSlash) { startPart = i + 1; break; }
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (end === -1) matchedSlash = false, end = i + 1;
|
|
210
|
+
if (code === 46) { if (startDot === -1) startDot = i; else if (preDotState !== 1) preDotState = 1; }
|
|
211
|
+
else if (startDot !== -1) preDotState = -1;
|
|
212
|
+
}
|
|
213
|
+
if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) return "";
|
|
214
|
+
return path.slice(startDot, end);
|
|
215
|
+
}
|
|
216
|
+
function format(pathObject) {
|
|
217
|
+
if (pathObject === null || typeof pathObject !== "object")
|
|
218
|
+
throw TypeError('The "pathObject" argument must be of type Object. Received type ' + typeof pathObject);
|
|
219
|
+
return _format("/", pathObject);
|
|
220
|
+
}
|
|
221
|
+
function parse(path) {
|
|
222
|
+
assertPath(path);
|
|
223
|
+
var ret = { root: "", dir: "", base: "", ext: "", name: "" };
|
|
224
|
+
if (path.length === 0) return ret;
|
|
225
|
+
var code = path.charCodeAt(0), isAbsolute2 = code === 47, start;
|
|
226
|
+
if (isAbsolute2) ret.root = "/", start = 1;
|
|
227
|
+
else start = 0;
|
|
228
|
+
var startDot = -1, startPart = 0, end = -1, matchedSlash = true, i = path.length - 1, preDotState = 0;
|
|
229
|
+
for (; i >= start; --i) {
|
|
230
|
+
if (code = path.charCodeAt(i), code === 47) {
|
|
231
|
+
if (!matchedSlash) { startPart = i + 1; break; }
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (end === -1) matchedSlash = false, end = i + 1;
|
|
235
|
+
if (code === 46) { if (startDot === -1) startDot = i; else if (preDotState !== 1) preDotState = 1; }
|
|
236
|
+
else if (startDot !== -1) preDotState = -1;
|
|
237
|
+
}
|
|
238
|
+
if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {
|
|
239
|
+
if (end !== -1)
|
|
240
|
+
if (startPart === 0 && isAbsolute2) ret.base = ret.name = path.slice(1, end);
|
|
241
|
+
else ret.base = ret.name = path.slice(startPart, end);
|
|
242
|
+
} else {
|
|
243
|
+
if (startPart === 0 && isAbsolute2) ret.name = path.slice(1, startDot), ret.base = path.slice(1, end);
|
|
244
|
+
else ret.name = path.slice(startPart, startDot), ret.base = path.slice(startPart, end);
|
|
245
|
+
ret.ext = path.slice(startDot, end);
|
|
246
|
+
}
|
|
247
|
+
if (startPart > 0) ret.dir = path.slice(0, startPart - 1);
|
|
248
|
+
else if (isAbsolute2) ret.dir = "/";
|
|
249
|
+
return ret;
|
|
250
|
+
}
|
|
251
|
+
var sep = "/";
|
|
252
|
+
var delimiter = ":";
|
|
253
|
+
var path = { resolve, normalize, isAbsolute, join, relative, dirname, basename, extname, format, parse, sep, delimiter };
|
|
254
|
+
path.posix = path;
|
|
255
|
+
path.win32 = path;
|
|
256
|
+
export default path;
|
|
257
|
+
export { resolve, normalize, isAbsolute, join, relative, dirname, basename, extname, format, parse, sep, delimiter };
|
|
258
|
+
`;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* node:querystring's bundled polyfill pulls in Bun's full Buffer
|
|
262
|
+
* implementation (used internally for percent-decoding), which the
|
|
263
|
+
* capability check correctly rejects — Buffer isn't something Porffor's
|
|
264
|
+
* compiled output can rely on. This shim implements parse/stringify/
|
|
265
|
+
* escape/unescape (plus the encode/decode aliases Node exports) over
|
|
266
|
+
* plain string ops and encodeURIComponent/decodeURIComponent instead.
|
|
267
|
+
*/
|
|
268
|
+
const NODE_QUERYSTRING_SHIM = `
|
|
269
|
+
function qsUnescape(str) {
|
|
270
|
+
try { return decodeURIComponent(String(str).replace(/\\+/g, " ")); }
|
|
271
|
+
catch { return String(str); }
|
|
272
|
+
}
|
|
273
|
+
function qsEscape(str) {
|
|
274
|
+
return encodeURIComponent(String(str));
|
|
275
|
+
}
|
|
276
|
+
function parse(qs, sep, eq, options) {
|
|
277
|
+
sep = sep || "&";
|
|
278
|
+
eq = eq || "=";
|
|
279
|
+
var obj = Object.create(null);
|
|
280
|
+
if (typeof qs !== "string" || qs.length === 0) return obj;
|
|
281
|
+
var decode = (options && options.decodeURIComponent) || qsUnescape;
|
|
282
|
+
var maxKeys = options && typeof options.maxKeys === "number" ? options.maxKeys : 1000;
|
|
283
|
+
var pairs = qs.split(sep);
|
|
284
|
+
var limit = maxKeys > 0 ? Math.min(pairs.length, maxKeys) : pairs.length;
|
|
285
|
+
for (var i = 0; i < limit; i++) {
|
|
286
|
+
var pair = pairs[i];
|
|
287
|
+
var eqIdx = pair.indexOf(eq);
|
|
288
|
+
var key, value;
|
|
289
|
+
if (eqIdx >= 0) { key = pair.slice(0, eqIdx); value = pair.slice(eqIdx + eq.length); }
|
|
290
|
+
else { key = pair; value = ""; }
|
|
291
|
+
key = decode(key);
|
|
292
|
+
value = decode(value);
|
|
293
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
294
|
+
if (Array.isArray(obj[key])) obj[key].push(value);
|
|
295
|
+
else obj[key] = [obj[key], value];
|
|
296
|
+
} else obj[key] = value;
|
|
297
|
+
}
|
|
298
|
+
return obj;
|
|
299
|
+
}
|
|
300
|
+
function stringify(obj, sep, eq, options) {
|
|
301
|
+
sep = sep || "&";
|
|
302
|
+
eq = eq || "=";
|
|
303
|
+
obj = obj || {};
|
|
304
|
+
var encode = (options && options.encodeURIComponent) || qsEscape;
|
|
305
|
+
var parts = [];
|
|
306
|
+
for (var key in obj) {
|
|
307
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
308
|
+
var value = obj[key];
|
|
309
|
+
var encodedKey = encode(key);
|
|
310
|
+
if (Array.isArray(value)) {
|
|
311
|
+
for (var i = 0; i < value.length; i++) parts.push(encodedKey + eq + encode(value[i]));
|
|
312
|
+
} else {
|
|
313
|
+
parts.push(encodedKey + eq + encode(value));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return parts.join(sep);
|
|
317
|
+
}
|
|
318
|
+
export { parse, stringify, qsEscape as escape, qsUnescape as unescape, stringify as encode, parse as decode };
|
|
319
|
+
export default { parse, stringify, escape: qsEscape, unescape: qsUnescape, encode: stringify, decode: parse };
|
|
320
|
+
`;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* `URL`/`URLSearchParams` are re-exported under aliased local names
|
|
324
|
+
* (`NodeURL`/`NodeURLSearchParams`) rather than declared as top-level
|
|
325
|
+
* `URL`/`URLSearchParams` — Porffor pre-binds `URL` as a global and
|
|
326
|
+
* rejects a second top-level declaration of that name. legacy
|
|
327
|
+
* parse/format/resolve are hand-written over the WHATWG URL rather than
|
|
328
|
+
* Bun's internal legacy-Url implementation, which isn't something this
|
|
329
|
+
* shim can import from; good enough for the common absolute-URL case,
|
|
330
|
+
* not a byte-for-byte port of Node's edge cases (relative/protocol-less
|
|
331
|
+
* input, unusual schemes).
|
|
332
|
+
*/
|
|
333
|
+
const NODE_URL_SHIM = `
|
|
334
|
+
var NodeURL = globalThis.URL;
|
|
335
|
+
var NodeURLSearchParams = globalThis.URLSearchParams;
|
|
336
|
+
function urlFormat(urlObject) {
|
|
337
|
+
if (typeof urlObject === "string") return urlObject;
|
|
338
|
+
if (urlObject instanceof NodeURL) return urlObject.href;
|
|
339
|
+
var protocol = urlObject.protocol || "";
|
|
340
|
+
var slashes = urlObject.slashes !== false && !!(urlObject.host || urlObject.hostname);
|
|
341
|
+
var auth = urlObject.auth ? urlObject.auth + "@" : "";
|
|
342
|
+
var host = urlObject.host || ((urlObject.hostname || "") + (urlObject.port ? ":" + urlObject.port : ""));
|
|
343
|
+
var pathname = urlObject.pathname || "";
|
|
344
|
+
var search = urlObject.search || (urlObject.query ? "?" + (typeof urlObject.query === "string" ? urlObject.query : "") : "");
|
|
345
|
+
var hash = urlObject.hash || "";
|
|
346
|
+
return protocol + (slashes ? "//" : "") + auth + host + pathname + search + hash;
|
|
347
|
+
}
|
|
348
|
+
function legacyFromURL(u) {
|
|
349
|
+
return {
|
|
350
|
+
protocol: u.protocol,
|
|
351
|
+
slashes: true,
|
|
352
|
+
auth: u.username ? u.username + (u.password ? ":" + u.password : "") : null,
|
|
353
|
+
host: u.host,
|
|
354
|
+
port: u.port || null,
|
|
355
|
+
hostname: u.hostname,
|
|
356
|
+
hash: u.hash || null,
|
|
357
|
+
search: u.search || null,
|
|
358
|
+
query: u.search ? u.search.slice(1) : null,
|
|
359
|
+
pathname: u.pathname,
|
|
360
|
+
path: u.pathname + (u.search || ""),
|
|
361
|
+
href: u.href,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function urlParse(input) {
|
|
365
|
+
return legacyFromURL(new NodeURL(input));
|
|
366
|
+
}
|
|
367
|
+
function urlResolve(from, to) {
|
|
368
|
+
return new NodeURL(to, new NodeURL(from)).href;
|
|
369
|
+
}
|
|
370
|
+
export { NodeURL as URL, NodeURLSearchParams as URLSearchParams, urlFormat as format, urlParse as parse, urlResolve as resolve };
|
|
371
|
+
export default { URL: NodeURL, URLSearchParams: NodeURLSearchParams, format: urlFormat, parse: urlParse, resolve: urlResolve };
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
export const NODE_SHIMS = {
|
|
375
|
+
"node:path": NODE_PATH_SHIM,
|
|
376
|
+
"node:querystring": NODE_QUERYSTRING_SHIM,
|
|
377
|
+
"node:url": NODE_URL_SHIM,
|
|
378
|
+
} satisfies Record<string, string>;
|
package/src/standalone-build.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* embedded transport already produces exactly the binary we want, so this
|
|
8
8
|
* copies it out and enforces the one thing a standalone binary cannot do.
|
|
9
9
|
*/
|
|
10
|
-
import { chmod, cp, mkdir
|
|
10
|
+
import { chmod, cp, mkdir } from "node:fs/promises";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { buildArtifact, type BuildInput } from "./build";
|
|
13
13
|
import type { Bindings } from "./wrap";
|
|
@@ -29,7 +29,7 @@ export type StandaloneBuildResult = { outPath: string; bytes: number };
|
|
|
29
29
|
* Outbound `fetch` is absent from this list: http and https both work, with
|
|
30
30
|
* BearSSL and the Mozilla root set compiled in.
|
|
31
31
|
*/
|
|
32
|
-
export function unsupportedBindings(bindings: Partial<Bindings>): string[] {
|
|
32
|
+
export function unsupportedBindings(bindings: Pick<Partial<Bindings>, "services" | "outbound">): string[] {
|
|
33
33
|
const reasons: string[] = [];
|
|
34
34
|
if ((bindings.services ?? []).length > 0) {
|
|
35
35
|
reasons.push("service bindings call another deployment through an edge, which a standalone binary has none of");
|
|
@@ -38,21 +38,11 @@ export function unsupportedBindings(bindings: Partial<Bindings>): string[] {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export async function buildStandalone(input: StandaloneBuildInput): Promise<StandaloneBuildResult> {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
let bindings: Partial<Bindings> = {};
|
|
44
|
-
try {
|
|
45
|
-
// SAFETY: bindings.json is written by buildArtifact from the validated
|
|
46
|
-
// config in this same call — our own output, not user input.
|
|
47
|
-
bindings = JSON.parse(await readFile(resolve(artifact.artifactDir, "bindings.json"), "utf8")) as Partial<Bindings>;
|
|
48
|
-
} catch {
|
|
49
|
-
bindings = {}; // a project with no bindings at all
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const blocked = unsupportedBindings(bindings);
|
|
41
|
+
const blocked = unsupportedBindings(input.config);
|
|
53
42
|
if (blocked.length > 0) {
|
|
54
43
|
throw new Error(`cannot build a standalone binary for this project:\n - ${blocked.join("\n - ")}`);
|
|
55
44
|
}
|
|
45
|
+
const artifact = await buildArtifact({ ...input, transport: "embedded" });
|
|
56
46
|
|
|
57
47
|
// The sprout *is* the binary: assets and bindings are compiled into it, and
|
|
58
48
|
// everything else it needs (port, data dir, secrets) arrives at run time.
|