sparkbun 0.2.2 → 0.2.3

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkbun",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Build fast, lightweight, cross-platform desktop apps with TypeScript and Bun.",
5
5
  "license": "MIT",
6
6
  "author": "SparkBun Contributors",
@@ -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
- // Create a tar.gz file using system tar command
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 compressed with LZMA via NSIS and embedded directly
3211
- * in the binary. They stay embedded until the user triggers installation,
3212
- * at which point the app code writes them to disk and runs them as silent
3213
- * self-extractors.
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 archives: { name: string; path: string }[] = [];
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,28 +3327,11 @@ 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 name = arg.substring(0, eqIdx);
3234
- const archivePath = arg.substring(eqIdx + 1);
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
  const iconPath = getFlag("--icon=");
3257
3337
  const installerName = getFlag("--name=") || config.app.name;
@@ -3311,46 +3391,56 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3311
3391
  if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
3312
3392
  mkdirSync(stagingDir, { recursive: true });
3313
3393
 
3314
- const archiveManifest: Record<string, string> = {};
3394
+ type ArchiveInfo = {
3395
+ file: string;
3396
+ format: "zstd" | "gzip";
3397
+ level: number;
3398
+ uncompressedBytes: number;
3399
+ fileCount: number;
3400
+ compressedBytes: number;
3401
+ };
3402
+ const archiveManifest: Record<string, ArchiveInfo> = {};
3315
3403
 
3316
3404
  if (archives.length > 0) {
3317
- console.log("Creating archives (LZMA solid via NSIS)...");
3405
+ console.log("Creating archives (tar + streaming compression)...");
3318
3406
 
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
- }
3324
-
3325
- for (const { name, path: archiveSrcPath } of archives) {
3326
- const archiveFileName = `archive-${name}.exe`;
3407
+ for (const spec of archives) {
3408
+ const ext = spec.format === "gzip" ? "tar.gz" : "tar.zst";
3409
+ const archiveFileName = `archive-${spec.name}.${ext}`;
3327
3410
  const archiveDestPath = join(stagingDir, archiveFileName);
3328
- const nsiPath = join(stagingDir, `archive-${name}.nsi`);
3411
+ const tarTmpPath = join(stagingDir, `archive-${spec.name}.tar`);
3329
3412
 
3330
- console.log(` Archiving ${name}: ${archiveSrcPath}`);
3413
+ console.log(` Archiving ${spec.name}: ${spec.path}`);
3331
3414
 
3332
- const nsiContent = `SetCompressor /SOLID lzma\nSilentInstall silent\nName "${name}"\nOutFile "${archiveDestPath.replace(/\\/g, "\\\\")}"\nInstallDir "$TEMP\\sparkbun-extract-${name}"\nSection\n SetOutPath "$INSTDIR"\n File /r "${archiveSrcPath.replace(/\\/g, "\\\\")}\\*.*"\nSectionEnd\n`;
3333
- writeFileSync(nsiPath, nsiContent);
3415
+ // Build an uncompressed tar that preserves the source folder name as
3416
+ // the archive root (e.g. "Dune 2000/..."), so the installer extracts
3417
+ // into the correct subdirectory. tar -C <parent> <basename>.
3418
+ const parent = dirname(spec.path);
3419
+ const baseName = basename(spec.path);
3420
+ createTar(tarTmpPath, parent, [baseName]);
3334
3421
 
3335
- const result = Bun.spawnSync([makensisPath, nsiPath], {
3336
- cwd: nsisDir,
3337
- env: { ...process.env, NSISDIR: nsisDir },
3338
- stdout: "pipe",
3339
- stderr: "pipe",
3340
- });
3422
+ // Measure uncompressed bytes + file count so the installer can show
3423
+ // real per-file / byte progress without unpacking first.
3424
+ const { bytes: uncompressedBytes, fileCount } = measureDir(spec.path);
3341
3425
 
3342
- if (result.exitCode !== 0) {
3343
- const stderr = result.stderr.toString();
3344
- const stdout = result.stdout.toString();
3345
- console.error(`makensis failed for ${name}:`, stderr || stdout);
3346
- throw new Error(`Failed to compress archive: ${name}`);
3347
- }
3348
-
3349
- unlinkSync(nsiPath);
3426
+ // Stream-compress the tar at the requested level (bounded build memory).
3427
+ await streamCompressFile(tarTmpPath, archiveDestPath, spec.format, spec.level);
3428
+ unlinkSync(tarTmpPath);
3350
3429
 
3351
- const archiveSize = statSync(archiveDestPath).size;
3352
- console.log(` ${name}: ${(archiveSize / 1024 / 1024).toFixed(2)} MB`);
3353
- archiveManifest[name] = archiveFileName;
3430
+ const compressedBytes = statSync(archiveDestPath).size;
3431
+ console.log(
3432
+ ` ${spec.name}: ${(uncompressedBytes / 1024 / 1024).toFixed(0)} MB -> ` +
3433
+ `${(compressedBytes / 1024 / 1024).toFixed(0)} MB ` +
3434
+ `(${spec.format} level ${spec.level}, ${fileCount} files)`,
3435
+ );
3436
+ archiveManifest[spec.name] = {
3437
+ file: archiveFileName,
3438
+ format: spec.format,
3439
+ level: spec.level,
3440
+ uncompressedBytes,
3441
+ fileCount,
3442
+ compressedBytes,
3443
+ };
3354
3444
  }
3355
3445
  }
3356
3446
 
@@ -3521,7 +3611,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3521
3611
  const archiveImports: string[] = [];
3522
3612
  const archiveExports: string[] = [];
3523
3613
  for (const { name } of archives) {
3524
- const fileName = archiveManifest[name];
3614
+ const fileName = archiveManifest[name]!.file;
3525
3615
  archiveImports.push(`import archive_${name} from "./${fileName}" with { type: "file" };`);
3526
3616
  archiveExports.push(` "${name}": archive_${name},`);
3527
3617
  }
@@ -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 (LZMA compressed via NSIS)
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 write them to disk and run them (NSIS silent self-extractors).
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" };