sparkbun 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -141,6 +141,50 @@ export interface SparkBunConfig {
|
|
|
141
141
|
copy?: {
|
|
142
142
|
[sourcePath: string]: string;
|
|
143
143
|
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Configuration for the `sparkbun installer` command, which builds a
|
|
147
|
+
* single-file graphical installer with embedded payload archives.
|
|
148
|
+
*
|
|
149
|
+
* Each archive is tarred (preserving its source folder name as the
|
|
150
|
+
* archive root) and compressed independently. The compiled installer
|
|
151
|
+
* app reads the embedded `archives.json` manifest and extracts the
|
|
152
|
+
* payloads itself — the framework does not auto-extract, giving the
|
|
153
|
+
* installer full control over the process (streaming, progress,
|
|
154
|
+
* placement, ordering, conditional components, etc.).
|
|
155
|
+
*
|
|
156
|
+
* Archives can also be supplied on the CLI via repeated
|
|
157
|
+
* `--archive name=path` flags; config entries and flags are combined.
|
|
158
|
+
*/
|
|
159
|
+
installer?: {
|
|
160
|
+
archives?: Array<{
|
|
161
|
+
/**
|
|
162
|
+
* Logical identifier for the archive. Referenced by the app and
|
|
163
|
+
* used as the manifest key. Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/.
|
|
164
|
+
*/
|
|
165
|
+
name: string;
|
|
166
|
+
/**
|
|
167
|
+
* Source directory (relative to project root or absolute). The
|
|
168
|
+
* directory's own name becomes the archive's top-level folder,
|
|
169
|
+
* so it extracts into the correct subdirectory.
|
|
170
|
+
*/
|
|
171
|
+
path: string;
|
|
172
|
+
/**
|
|
173
|
+
* Compression algorithm.
|
|
174
|
+
* - "zstd" — best ratio at high levels, streaming decode (default)
|
|
175
|
+
* - "gzip" — widest compatibility, faster, larger output
|
|
176
|
+
* @default "zstd"
|
|
177
|
+
*/
|
|
178
|
+
format?: "zstd" | "gzip";
|
|
179
|
+
/**
|
|
180
|
+
* Compression level. zstd: 1–22 (22 = maximum, large window).
|
|
181
|
+
* gzip: 1–9. Higher is smaller but slower to compress.
|
|
182
|
+
* @default 19 for zstd, 9 for gzip
|
|
183
|
+
*/
|
|
184
|
+
level?: number;
|
|
185
|
+
}>;
|
|
186
|
+
};
|
|
187
|
+
|
|
144
188
|
/**
|
|
145
189
|
* Output folder for built application
|
|
146
190
|
* @default "build"
|
package/src/cli/index.ts
CHANGED
|
@@ -56,7 +56,47 @@ function createTar(tarPath: string, cwd: string, entries: string[]) {
|
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
59
|
+
// Recursively measure a directory's total file bytes and file count.
|
|
60
|
+
// Used to embed accurate progress denominators in the installer archive manifest.
|
|
61
|
+
function measureDir(dir: string): { bytes: number; fileCount: number } {
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
let fileCount = 0;
|
|
64
|
+
const stack = [dir];
|
|
65
|
+
while (stack.length) {
|
|
66
|
+
const d = stack.pop()!;
|
|
67
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
68
|
+
const full = join(d, entry.name);
|
|
69
|
+
if (entry.isDirectory()) stack.push(full);
|
|
70
|
+
else if (entry.isFile()) {
|
|
71
|
+
bytes += statSync(full).size;
|
|
72
|
+
fileCount++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { bytes, fileCount };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Stream-compress a file with zstd or gzip at a specific level, using node:zlib
|
|
80
|
+
// transforms so build memory stays bounded regardless of input size. Web
|
|
81
|
+
// CompressionStream("zstd") is intentionally not used — it has no level control,
|
|
82
|
+
// and the high compression ratio depends on level 22's large window.
|
|
83
|
+
async function streamCompressFile(
|
|
84
|
+
srcPath: string,
|
|
85
|
+
destPath: string,
|
|
86
|
+
format: "zstd" | "gzip",
|
|
87
|
+
level: number,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const zlib = require("node:zlib");
|
|
90
|
+
const { createReadStream, createWriteStream } = require("node:fs");
|
|
91
|
+
const { pipeline } = require("node:stream/promises");
|
|
92
|
+
const transform =
|
|
93
|
+
format === "gzip"
|
|
94
|
+
? zlib.createGzip({ level })
|
|
95
|
+
: zlib.createZstdCompress({
|
|
96
|
+
params: { [zlib.constants.ZSTD_c_compressionLevel]: level },
|
|
97
|
+
});
|
|
98
|
+
await pipeline(createReadStream(srcPath), transform, createWriteStream(destPath));
|
|
99
|
+
}
|
|
60
100
|
|
|
61
101
|
// this when run as an npm script this will be where the folder where package.json is.
|
|
62
102
|
const projectRoot = process.cwd();
|
|
@@ -3207,10 +3247,15 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3207
3247
|
* with admin elevation. On second launch (from temp, with DLLs present) it
|
|
3208
3248
|
* loads SparkBunCore and runs the webview-based installer UI.
|
|
3209
3249
|
*
|
|
3210
|
-
* Payload archives are
|
|
3211
|
-
*
|
|
3212
|
-
*
|
|
3213
|
-
*
|
|
3250
|
+
* Payload archives are tarred (preserving each source's folder name as the
|
|
3251
|
+
* archive root) and compressed with zstd (default, levels 1–22) or gzip,
|
|
3252
|
+
* then embedded directly in the binary. They stay embedded until the user
|
|
3253
|
+
* triggers installation; the app code reads the embedded `archives.json`
|
|
3254
|
+
* manifest and extracts them itself — typically by streaming the embedded
|
|
3255
|
+
* blob through DecompressionStream("zstd") into a tar reader, which bounds
|
|
3256
|
+
* memory to the decompression window regardless of archive size. The
|
|
3257
|
+
* framework never auto-extracts payloads, so the installer has full control
|
|
3258
|
+
* over progress, placement, ordering, and conditional components.
|
|
3214
3259
|
*
|
|
3215
3260
|
* This is a separate pipeline from `runBuild` — it does not produce update
|
|
3216
3261
|
* artifacts, delta patches, DMGs, or .deb packages.
|
|
@@ -3221,7 +3266,59 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3221
3266
|
) {
|
|
3222
3267
|
// --- Parse installer-specific CLI flags ---
|
|
3223
3268
|
|
|
3224
|
-
const
|
|
3269
|
+
const getFlag = (prefix: string): string | undefined =>
|
|
3270
|
+
process.argv.find((arg) => arg.startsWith(prefix))?.split("=").slice(1).join("=");
|
|
3271
|
+
|
|
3272
|
+
// Compression defaults for archives supplied via --archive flags.
|
|
3273
|
+
// (Archives declared in sparkbun.config.ts carry their own per-archive
|
|
3274
|
+
// format/level, so each project can mix tight and fast compression.)
|
|
3275
|
+
const globalFormat = ((getFlag("--format=") as "zstd" | "gzip") || "zstd");
|
|
3276
|
+
const globalLevelFlag = getFlag("--level=");
|
|
3277
|
+
const defaultLevelFor = (fmt: "zstd" | "gzip") => (fmt === "gzip" ? 9 : 19);
|
|
3278
|
+
|
|
3279
|
+
type ArchiveSpec = {
|
|
3280
|
+
name: string;
|
|
3281
|
+
path: string;
|
|
3282
|
+
format: "zstd" | "gzip";
|
|
3283
|
+
level: number;
|
|
3284
|
+
};
|
|
3285
|
+
const archives: ArchiveSpec[] = [];
|
|
3286
|
+
|
|
3287
|
+
const addArchive = (
|
|
3288
|
+
name: string,
|
|
3289
|
+
rawPath: string,
|
|
3290
|
+
fmt: "zstd" | "gzip",
|
|
3291
|
+
level: number,
|
|
3292
|
+
) => {
|
|
3293
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3294
|
+
console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
|
|
3295
|
+
process.exit(1);
|
|
3296
|
+
}
|
|
3297
|
+
if (archives.some((a) => a.name === name)) {
|
|
3298
|
+
console.error(`Duplicate archive name: "${name}".`);
|
|
3299
|
+
process.exit(1);
|
|
3300
|
+
}
|
|
3301
|
+
const resolvedPath = path.resolve(projectRoot, rawPath);
|
|
3302
|
+
if (!existsSync(resolvedPath)) {
|
|
3303
|
+
console.error(`Archive path does not exist: ${resolvedPath}`);
|
|
3304
|
+
process.exit(1);
|
|
3305
|
+
}
|
|
3306
|
+
archives.push({ name, path: resolvedPath, format: fmt, level });
|
|
3307
|
+
};
|
|
3308
|
+
|
|
3309
|
+
// Archives declared in sparkbun.config.ts (build.installer.archives)
|
|
3310
|
+
const configArchives = (config.build as any)?.installer?.archives as
|
|
3311
|
+
| Array<{ name: string; path: string; format?: "zstd" | "gzip"; level?: number }>
|
|
3312
|
+
| undefined;
|
|
3313
|
+
if (configArchives) {
|
|
3314
|
+
for (const a of configArchives) {
|
|
3315
|
+
const fmt = a.format || globalFormat;
|
|
3316
|
+
const level = a.level ?? (globalLevelFlag ? Number(globalLevelFlag) : defaultLevelFor(fmt));
|
|
3317
|
+
addArchive(a.name, a.path, fmt, level);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
// Archives passed via --archive name=path (level/format from global flags)
|
|
3225
3322
|
for (let i = 0; i < process.argv.length; i++) {
|
|
3226
3323
|
if (process.argv[i] === "--archive" && process.argv[i + 1]) {
|
|
3227
3324
|
const arg = process.argv[i + 1]!;
|
|
@@ -3230,30 +3327,18 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3230
3327
|
console.error(`Invalid --archive format: "${arg}". Expected: name=path`);
|
|
3231
3328
|
process.exit(1);
|
|
3232
3329
|
}
|
|
3233
|
-
const
|
|
3234
|
-
|
|
3235
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
3236
|
-
console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
|
|
3237
|
-
process.exit(1);
|
|
3238
|
-
}
|
|
3239
|
-
if (archives.some((a) => a.name === name)) {
|
|
3240
|
-
console.error(`Duplicate archive name: "${name}".`);
|
|
3241
|
-
process.exit(1);
|
|
3242
|
-
}
|
|
3243
|
-
const resolvedPath = path.resolve(projectRoot, archivePath);
|
|
3244
|
-
if (!existsSync(resolvedPath)) {
|
|
3245
|
-
console.error(`Archive path does not exist: ${resolvedPath}`);
|
|
3246
|
-
process.exit(1);
|
|
3247
|
-
}
|
|
3248
|
-
archives.push({ name, path: resolvedPath });
|
|
3330
|
+
const level = globalLevelFlag ? Number(globalLevelFlag) : defaultLevelFor(globalFormat);
|
|
3331
|
+
addArchive(arg.substring(0, eqIdx), arg.substring(eqIdx + 1), globalFormat, level);
|
|
3249
3332
|
}
|
|
3250
3333
|
}
|
|
3251
3334
|
|
|
3252
|
-
const getFlag = (prefix: string): string | undefined =>
|
|
3253
|
-
process.argv.find((arg) => arg.startsWith(prefix))?.split("=").slice(1).join("=");
|
|
3254
|
-
|
|
3255
3335
|
const outPath = getFlag("--out=");
|
|
3256
|
-
|
|
3336
|
+
// Fall back to the platform icon from sparkbun.config.ts (build.win.icon)
|
|
3337
|
+
// so the installer is branded without needing an explicit --icon flag.
|
|
3338
|
+
const iconPath =
|
|
3339
|
+
getFlag("--icon=") ||
|
|
3340
|
+
(config.build as any)?.win?.icon ||
|
|
3341
|
+
undefined;
|
|
3257
3342
|
const installerName = getFlag("--name=") || config.app.name;
|
|
3258
3343
|
const installerVersion = getFlag("--version=") || config.app.version;
|
|
3259
3344
|
const installerPublisher = getFlag("--publisher=") || (config.app as any).publisher || "";
|
|
@@ -3311,46 +3396,66 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3311
3396
|
if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
|
|
3312
3397
|
mkdirSync(stagingDir, { recursive: true });
|
|
3313
3398
|
|
|
3314
|
-
|
|
3399
|
+
type ArchiveInfo = {
|
|
3400
|
+
file: string;
|
|
3401
|
+
format: "zstd" | "gzip";
|
|
3402
|
+
level: number;
|
|
3403
|
+
uncompressedBytes: number;
|
|
3404
|
+
fileCount: number;
|
|
3405
|
+
compressedBytes: number;
|
|
3406
|
+
};
|
|
3407
|
+
const archiveManifest: Record<string, ArchiveInfo> = {};
|
|
3315
3408
|
|
|
3316
3409
|
if (archives.length > 0) {
|
|
3317
|
-
console.log("Creating archives (
|
|
3318
|
-
|
|
3319
|
-
const nsisDir = join(SPARKBUN_DEP_PATH, `dist-${targetOS}-${targetARCH}`, "nsis");
|
|
3320
|
-
const makensisPath = join(nsisDir, "Bin", "makensis.exe");
|
|
3321
|
-
if (!existsSync(makensisPath)) {
|
|
3322
|
-
throw new Error(`makensis not found at ${makensisPath}. LZMA compression requires NSIS binaries in dist-${targetOS}-${targetARCH}/nsis/`);
|
|
3323
|
-
}
|
|
3410
|
+
console.log("Creating archives (tar + streaming compression)...");
|
|
3324
3411
|
|
|
3325
|
-
for (const
|
|
3326
|
-
const
|
|
3412
|
+
for (const spec of archives) {
|
|
3413
|
+
const ext = spec.format === "gzip" ? "tar.gz" : "tar.zst";
|
|
3414
|
+
const archiveFileName = `archive-${spec.name}.${ext}`;
|
|
3327
3415
|
const archiveDestPath = join(stagingDir, archiveFileName);
|
|
3328
|
-
const
|
|
3329
|
-
|
|
3330
|
-
console.log(` Archiving ${name}: ${
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
const
|
|
3352
|
-
|
|
3353
|
-
|
|
3416
|
+
const tarTmpPath = join(stagingDir, `archive-${spec.name}.tar`);
|
|
3417
|
+
|
|
3418
|
+
console.log(` Archiving ${spec.name}: ${spec.path}`);
|
|
3419
|
+
|
|
3420
|
+
// Build the uncompressed tar with the system `tar`. It streams to disk
|
|
3421
|
+
// (bounded build memory) and preserves empty directories as real
|
|
3422
|
+
// entries. The source folder name is kept as the archive root (e.g.
|
|
3423
|
+
// "Dune 2000/...") via `tar -C <parent> <basename>`, so it extracts
|
|
3424
|
+
// into the correct subdirectory.
|
|
3425
|
+
//
|
|
3426
|
+
// The on-disk format may be GNU (Windows/Linux — long paths via
|
|
3427
|
+
// "././@LongLink") or pax (macOS bsdtar — long paths via extended
|
|
3428
|
+
// headers). The installer's streaming extractor understands both, plus
|
|
3429
|
+
// plain ustar, so the archive is portable regardless of build host.
|
|
3430
|
+
//
|
|
3431
|
+
// (Bun.Archive is intentionally not used here: its lazy Bun.file()
|
|
3432
|
+
// inputs write empty file bodies, and eager byte inputs would require
|
|
3433
|
+
// loading the entire payload into memory.)
|
|
3434
|
+
const parent = dirname(spec.path);
|
|
3435
|
+
const baseName = basename(spec.path);
|
|
3436
|
+
createTar(tarTmpPath, parent, [baseName]);
|
|
3437
|
+
|
|
3438
|
+
// Measure uncompressed bytes + file count for progress denominators.
|
|
3439
|
+
const { bytes: uncompressedBytes, fileCount } = measureDir(spec.path);
|
|
3440
|
+
|
|
3441
|
+
// Stream-compress the tar at the requested level (bounded build memory).
|
|
3442
|
+
await streamCompressFile(tarTmpPath, archiveDestPath, spec.format, spec.level);
|
|
3443
|
+
unlinkSync(tarTmpPath);
|
|
3444
|
+
|
|
3445
|
+
const compressedBytes = statSync(archiveDestPath).size;
|
|
3446
|
+
console.log(
|
|
3447
|
+
` ${spec.name}: ${(uncompressedBytes / 1024 / 1024).toFixed(0)} MB -> ` +
|
|
3448
|
+
`${(compressedBytes / 1024 / 1024).toFixed(0)} MB ` +
|
|
3449
|
+
`(${spec.format} level ${spec.level}, ${fileCount} files)`,
|
|
3450
|
+
);
|
|
3451
|
+
archiveManifest[spec.name] = {
|
|
3452
|
+
file: archiveFileName,
|
|
3453
|
+
format: spec.format,
|
|
3454
|
+
level: spec.level,
|
|
3455
|
+
uncompressedBytes,
|
|
3456
|
+
fileCount,
|
|
3457
|
+
compressedBytes,
|
|
3458
|
+
};
|
|
3354
3459
|
}
|
|
3355
3460
|
}
|
|
3356
3461
|
|
|
@@ -3521,7 +3626,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
|
|
|
3521
3626
|
const archiveImports: string[] = [];
|
|
3522
3627
|
const archiveExports: string[] = [];
|
|
3523
3628
|
for (const { name } of archives) {
|
|
3524
|
-
const fileName = archiveManifest[name];
|
|
3629
|
+
const fileName = archiveManifest[name]!.file;
|
|
3525
3630
|
archiveImports.push(`import archive_${name} from "./${fileName}" with { type: "file" };`);
|
|
3526
3631
|
archiveExports.push(` "${name}": archive_${name},`);
|
|
3527
3632
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* This file is the entrypoint for installers built with `bun sparkbun installer`.
|
|
5
5
|
* It gets compiled into a single executable that contains:
|
|
6
6
|
* - A small runtime bundle (DLLs, views, app code — ~1MB compressed)
|
|
7
|
-
* - The installer's payload archives (
|
|
7
|
+
* - The installer's payload archives (tar + zstd/gzip)
|
|
8
8
|
*
|
|
9
9
|
* The binary uses a two-phase execution model with a single Bun runtime:
|
|
10
10
|
*
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
*
|
|
26
26
|
* The payload archives stay embedded in the binary at all times — they are
|
|
27
27
|
* never extracted to temp. Only when the user clicks "Install" does the app
|
|
28
|
-
* code
|
|
28
|
+
* code read them (via Bun.embeddedFiles, guided by the Resources/archives.json
|
|
29
|
+
* manifest) and stream them to disk.
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
import runtimeArchive from "./runtime-bundle.tar.gz" with { type: "file" };
|