Compare commits

...

4 Commits

Author SHA1 Message Date
autofix-ci[bot]
3bfca764c4 [autofix.ci] apply automated fixes 2025-07-19 11:01:39 +00:00
Jarred Sumner
ff10adfac1 wip 2025-07-19 03:58:34 -07:00
autofix-ci[bot]
8f7b95a183 [autofix.ci] apply automated fixes 2025-07-19 02:44:06 +00:00
Claude Bot
c399c48610 Add --prefer-offline flag for bun install
This commit implements the --prefer-offline flag for `bun install` to support
offline development workflows by using locally cached package metadata even
when it has expired.

Changes:
- Add --prefer-offline CLI flag parsing in CommandLineArguments.zig
- Wire up ctx.debug.offline_mode_setting in PackageManagerOptions.zig
- Modify PackageManagerEnqueue.zig to respect prefer-offline for cache expiry
- Add logic to prevent network requests for uncached dependencies in offline mode
- Update all call sites to handle new function signatures
- Add comprehensive test suite with fake registry

The implementation follows standard package manager offline behavior:
- Uses expired cache when prefer-offline is set
- Fails gracefully when no cached data exists
- Works with both CLI flag and bunfig.toml configuration
- Integrates with existing Bun architecture patterns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 01:43:25 +00:00
13 changed files with 459 additions and 89 deletions

View File

@@ -874,20 +874,16 @@ pub fn init(
manager.lockfile = try ctx.allocator.create(Lockfile);
JSC.MiniEventLoop.global = &manager.event_loop.mini;
if (!manager.options.enable.cache) {
manager.options.enable.manifest_cache = false;
manager.options.enable.manifest_cache_control = false;
manager.options.enable.manifest_cache = .disabled;
}
if (env.get("BUN_MANIFEST_CACHE")) |manifest_cache| {
if (strings.eqlComptime(manifest_cache, "1")) {
manager.options.enable.manifest_cache = true;
manager.options.enable.manifest_cache_control = false;
manager.options.enable.manifest_cache = .write_only;
} else if (strings.eqlComptime(manifest_cache, "2")) {
manager.options.enable.manifest_cache = true;
manager.options.enable.manifest_cache_control = true;
manager.options.enable.manifest_cache = .ttl;
} else {
manager.options.enable.manifest_cache = false;
manager.options.enable.manifest_cache_control = false;
manager.options.enable.manifest_cache = .disabled;
}
}
@@ -898,6 +894,7 @@ pub fn init(
cli,
ctx.install,
subcommand,
ctx,
);
var ca: []stringZ = &.{};
@@ -1047,20 +1044,16 @@ pub fn initWithRuntimeOnce(
}
if (!manager.options.enable.cache) {
manager.options.enable.manifest_cache = false;
manager.options.enable.manifest_cache_control = false;
manager.options.enable.manifest_cache = .disabled;
}
if (env.get("BUN_MANIFEST_CACHE")) |manifest_cache| {
if (strings.eqlComptime(manifest_cache, "1")) {
manager.options.enable.manifest_cache = true;
manager.options.enable.manifest_cache_control = false;
manager.options.enable.manifest_cache = .write_only;
} else if (strings.eqlComptime(manifest_cache, "2")) {
manager.options.enable.manifest_cache = true;
manager.options.enable.manifest_cache_control = true;
manager.options.enable.manifest_cache = .ttl;
} else {
manager.options.enable.manifest_cache = false;
manager.options.enable.manifest_cache_control = false;
manager.options.enable.manifest_cache = .disabled;
}
}
@@ -1071,6 +1064,7 @@ pub fn initWithRuntimeOnce(
cli,
bun_install,
.install,
null,
) catch |err| {
switch (err) {
error.OutOfMemory => bun.outOfMemory(),

View File

@@ -48,6 +48,7 @@ const shared_params = [_]ParamType{
clap.parseParam("--save-text-lockfile Save a text-based lockfile") catch unreachable,
clap.parseParam("--omit <dev|optional|peer>... Exclude 'dev', 'optional', or 'peer' dependencies from install") catch unreachable,
clap.parseParam("--lockfile-only Generate a lockfile without installing dependencies") catch unreachable,
clap.parseParam("--prefer-offline Use local cache even if registry data has expired") catch unreachable,
clap.parseParam("--linker <STR> Linker strategy (one of \"isolated\" or \"hoisted\")") catch unreachable,
clap.parseParam("-h, --help Print this help menu") catch unreachable,
};
@@ -217,6 +218,7 @@ ca_file_name: string = "",
save_text_lockfile: ?bool = null,
lockfile_only: bool = false,
prefer_offline: bool = false,
node_linker: ?Options.NodeLinker = null,
@@ -730,6 +732,7 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com
cli.no_summary = args.flag("--no-summary");
cli.ca = args.options("--ca");
cli.lockfile_only = args.flag("--lockfile-only");
cli.prefer_offline = args.flag("--prefer-offline");
if (args.option("--linker")) |linker| {
cli.node_linker = .fromStr(linker);

View File

@@ -1,6 +1,9 @@
pub inline fn getCacheDirectory(this: *PackageManager) std.fs.Dir {
return this.cache_directory_ orelse brk: {
this.cache_directory_ = ensureCacheDirectory(this);
PackageManager.debug("cache directory: {s}", .{this.cache_directory_path});
break :brk this.cache_directory_.?;
};
}
@@ -83,7 +86,7 @@ noinline fn ensureTemporaryDirectory(this: *PackageManager) std.fs.Dir {
};
if (PackageManager.verbose_install) {
Output.prettyErrorln("<r><yellow>warn<r>: bun is unable to access tempdir: {s}, using fallback", .{@errorName(err2)});
Output.prettyErrorln("<r><yellow>warn<r>: bun is unable to access tempdir: {s}, using fallback. This may make bun install slower.", .{@errorName(err2)});
}
continue :brk;
@@ -104,7 +107,7 @@ noinline fn ensureTemporaryDirectory(this: *PackageManager) std.fs.Dir {
};
if (PackageManager.verbose_install) {
Output.prettyErrorln("<r><d>info<r>: cannot move files from tempdir: {s}, using fallback", .{@errorName(err)});
Output.prettyErrorln("<r><d>info<r>: cannot move files from tempdir: {s}, using fallback. This may make bun install slower.", .{@errorName(err)});
}
continue :brk;
@@ -714,7 +717,7 @@ pub fn writeYarnLock(this: *PackageManager) !void {
try tmpfile.promoteToCWD(tmpname, "yarn.lock");
}
const CacheVersion = struct {
pub const CacheVersion = struct {
pub const current = 1;
pub const Formatter = struct {
version_number: ?usize = null,
@@ -725,6 +728,20 @@ const CacheVersion = struct {
}
}
};
pub fn unversionedName(name: string) ?string {
const cache_version_suffix = bun.strings.lastIndexOf(name, "@@@") orelse return null;
comptime {
if (current != 1) {
@compileError("Update this to the current cache version");
}
}
if (cache_version_suffix > 0 and name[cache_version_suffix + 3] != '1') {
return null;
}
return name[0..cache_version_suffix];
}
};
const PatchHashFmt = struct {

View File

@@ -667,50 +667,61 @@ pub fn enqueueDependencyWithMainAndSuccessFn(
);
if (!dependency.behavior.isPeer() or install_peer) {
if (!this.hasCreatedNetworkTask(task_id, dependency.behavior.isRequired())) {
if (this.options.enable.manifest_cache) {
var expired = false;
if (this.manifests.byNameHashAllowExpired(
this,
this.scopeForPackageName(name_str),
name_hash,
&expired,
.load_from_memory_fallback_to_disk,
)) |manifest| {
loaded_manifest = manifest.*;
const has_created_network_task = this.hasCreatedNetworkTask(task_id, dependency.behavior.isRequired());
const should_read_from_cache = switch (this.options.enable.manifest_cache) {
.prefer_offline, .ttl => !has_created_network_task,
.write_only, .disabled => false,
};
// If it's an exact package version already living in the cache
// We can skip the network request, even if it's beyond the caching period
if (version.tag == .npm and version.value.npm.version.isExact()) {
if (loaded_manifest.?.findByVersion(version.value.npm.version.head.head.range.left.version)) |find_result| {
if (getOrPutResolvedPackageWithFindResult(
this,
name_hash,
name,
dependency,
version,
id,
dependency.behavior,
&loaded_manifest.?,
find_result,
install_peer,
successFn,
) catch null) |new_resolve_result| {
resolve_result_ = new_resolve_result;
_ = this.network_dedupe_map.remove(task_id);
continue :retry_with_new_resolve_result;
}
if (should_read_from_cache) {
var expired = false;
if (this.manifests.byNameHashAllowExpired(
this,
this.scopeForPackageName(name_str),
name_hash,
&expired,
.load_from_memory_fallback_to_disk,
)) |manifest| {
loaded_manifest = manifest.*;
// If it's an exact package version already living in the cache
// We can skip the network request, even if it's beyond the caching period
if (version.tag == .npm and version.value.npm.version.isExact()) {
if (loaded_manifest.?.findByVersion(version.value.npm.version.head.head.range.left.version)) |find_result| {
if (getOrPutResolvedPackageWithFindResult(
this,
name_hash,
name,
dependency,
version,
id,
dependency.behavior,
&loaded_manifest.?,
find_result,
install_peer,
successFn,
) catch null) |new_resolve_result| {
resolve_result_ = new_resolve_result;
_ = this.network_dedupe_map.remove(task_id);
continue :retry_with_new_resolve_result;
}
}
}
// Was it recent enough to just load it without the network call?
if (this.options.enable.manifest_cache_control and !expired) {
_ = this.network_dedupe_map.remove(task_id);
continue :retry_from_manifests_ptr;
}
// Either:
// - Was it recent enough to just load it without the network call?
// - Did the user pass `--prefer-offline`?
if (!expired) {
_ = this.network_dedupe_map.remove(task_id);
debug("manifest cache hit for {s}@{s}", .{ name_str, this.lockfile.str(&version.literal) });
continue :retry_from_manifests_ptr;
} else {
debug("manifest cache expired for {s}@{s}", .{ name_str, this.lockfile.str(&version.literal) });
}
}
}
if (!has_created_network_task) {
if (PackageManager.verbose_install) {
Output.prettyErrorln("Enqueue package manifest for download: {s}", .{name_str});
}
@@ -1561,6 +1572,7 @@ fn getOrPutResolvedPackage(
// Resolve the version from the loaded NPM manifest
const name_str = this.lockfile.str(&name);
const manifest = this.manifests.byNameHash(
this,
this.scopeForPackageName(name_str),

View File

@@ -230,6 +230,7 @@ pub fn load(
maybe_cli: ?CommandLineArguments,
bun_install_: ?*Api.BunInstall,
subcommand: Subcommand,
ctx: ?Command.Context,
) bun.OOM!void {
var base = Api.NpmRegistry{
.url = "",
@@ -288,11 +289,11 @@ pub fn load(
}
if (config.disable_manifest_cache orelse false) {
this.enable.manifest_cache = false;
this.enable.manifest_cache = .disabled;
}
if (config.force orelse false) {
this.enable.manifest_cache_control = false;
this.enable.manifest_cache = .disabled;
this.enable.force_install = true;
}
@@ -452,8 +453,18 @@ pub fn load(
// Update should never read from manifest cache
if (subcommand == .update) {
this.enable.manifest_cache = false;
this.enable.manifest_cache_control = false;
this.enable.manifest_cache = .disabled;
}
// Override prefer_offline from bunfig or CLI context offline_mode_setting
if (ctx) |context| {
if (context.debug.offline_mode_setting) |offline_mode| {
switch (offline_mode) {
.offline => this.enable.manifest_cache = .prefer_offline,
.online => this.enable.manifest_cache = .ttl,
.latest => this.enable.manifest_cache = .disabled,
}
}
}
if (maybe_cli) |cli| {
@@ -499,8 +510,7 @@ pub fn load(
this.json_output = cli.json_output;
if (cli.no_cache) {
this.enable.manifest_cache = false;
this.enable.manifest_cache_control = false;
this.enable.manifest_cache = .disabled;
}
if (cli.omit) |omit| {
@@ -584,7 +594,7 @@ pub fn load(
}
if (cli.force) {
this.enable.manifest_cache_control = false;
this.enable.manifest_cache = .disabled;
this.enable.force_install = true;
this.enable.force_save_lockfile = true;
}
@@ -641,6 +651,10 @@ pub fn load(
// `bun pm why` command options
this.top_only = cli.top_only;
this.depth = cli.depth;
if (cli.prefer_offline) {
this.enable.manifest_cache = .prefer_offline;
}
} else {
this.log_level = if (default_disable_progress_bar) LogLevel.default_no_progress else LogLevel.default;
PackageManager.verbose_install = false;
@@ -669,9 +683,42 @@ pub const Do = packed struct(u16) {
_: u4 = 0,
};
pub const ManifestCacheControl = enum(u2) {
disabled,
/// Populate the cache without reading from the cache.
write_only,
ttl,
/// No TTL on package manifest cache
///
/// This lets us resolve package versions without network requests if they
/// already exist in the cache.
prefer_offline,
pub const default: ManifestCacheControl = .ttl;
pub fn isExpired(this: ManifestCacheControl, public_max_age: u32, timestamp_for_manifest_cache_control: u32) bool {
return switch (this) {
.disabled => true,
.write_only => false,
.ttl => public_max_age > timestamp_for_manifest_cache_control,
.prefer_offline => false,
};
}
pub fn shouldReadFromCache(this: ManifestCacheControl) bool {
return switch (this) {
.disabled => false,
.write_only => false,
.ttl, .prefer_offline => true,
};
}
};
pub const Enable = packed struct(u16) {
manifest_cache: bool = true,
manifest_cache_control: bool = true,
manifest_cache: ManifestCacheControl = .default,
cache: bool = true,
fail_early: bool = false,
frozen_lockfile: bool = false,
@@ -714,3 +761,4 @@ const CommandLineArguments = @import("./CommandLineArguments.zig");
const Subcommand = bun.install.PackageManager.Subcommand;
const PackageManager = bun.install.PackageManager;
const PackageInstall = bun.install.PackageInstall;
const Command = bun.CLI.Command;

View File

@@ -41,7 +41,7 @@ pub fn scopeForPackageName(this: *const PackageManager, name: string) *const Npm
) orelse &this.options.scope;
}
pub fn getInstalledVersionsFromDiskCache(this: *PackageManager, tags_buf: *std.ArrayList(u8), package_name: []const u8, allocator: std.mem.Allocator) !std.ArrayList(Semver.Version) {
pub fn getInstalledVersionsFromDiskCache(this: *PackageManager, package_name: []const u8, tags_buf: *std.ArrayList(u8), allocator: std.mem.Allocator) !std.ArrayList(Semver.Version) {
var list = std.ArrayList(Semver.Version).init(allocator);
var dir = this.getCacheDirectory().openDir(package_name, .{
.iterate = true,
@@ -54,7 +54,7 @@ pub fn getInstalledVersionsFromDiskCache(this: *PackageManager, tags_buf: *std.A
while (try iter.next()) |entry| {
if (entry.kind != .directory and entry.kind != .sym_link) continue;
const name = entry.name;
const name = PackageManagerDirectories.CacheVersion.unversionedName(entry.name) orelse continue;
const sliced = SlicedString.init(name, name);
const parsed = Semver.Version.parse(sliced);
if (!parsed.valid or parsed.wildcard != .none) continue;
@@ -63,7 +63,7 @@ pub fn getInstalledVersionsFromDiskCache(this: *PackageManager, tags_buf: *std.A
var version = parsed.version.min();
const total = version.tag.build.len() + version.tag.pre.len();
if (total > 0) {
tags_buf.ensureUnusedCapacity(total) catch unreachable;
try tags_buf.ensureUnusedCapacity(total);
var available = tags_buf.items.ptr[tags_buf.items.len..tags_buf.capacity];
const new_version = version.cloneInto(name, &available);
tags_buf.items.len += total;
@@ -76,7 +76,10 @@ pub fn getInstalledVersionsFromDiskCache(this: *PackageManager, tags_buf: *std.A
return list;
}
pub fn resolveFromDiskCache(this: *PackageManager, package_name: []const u8, version: Dependency.Version) ?PackageID {
pub fn resolveFromDiskCache(this: *PackageManager, package_name: []const u8, version: Dependency.Version) ?struct {
package_id: PackageID,
is_first_time: bool,
} {
if (version.tag != .npm) {
// only npm supported right now
// tags are more ambiguous
@@ -89,7 +92,7 @@ pub fn resolveFromDiskCache(this: *PackageManager, package_name: []const u8, ver
var stack_fallback = std.heap.stackFallback(4096, arena_alloc);
const allocator = stack_fallback.get();
var tags_buf = std.ArrayList(u8).init(allocator);
const installed_versions = this.getInstalledVersionsFromDiskCache(&tags_buf, package_name, allocator) catch |err| {
const installed_versions = this.getInstalledVersionsFromDiskCache(package_name, &tags_buf, allocator) catch |err| {
Output.debug("error getting installed versions from disk cache: {s}", .{bun.span(@errorName(err))});
return null;
};
@@ -101,30 +104,32 @@ pub fn resolveFromDiskCache(this: *PackageManager, package_name: []const u8, ver
@as([]const u8, tags_buf.items),
Semver.Version.sortGt,
);
const npm_version = &version.value.npm.version;
for (installed_versions.items) |installed_version| {
if (version.value.npm.version.satisfies(installed_version, this.lockfile.buffers.string_bytes.items, tags_buf.items)) {
var buf: bun.PathBuffer = undefined;
const npm_package_path = this.pathForCachedNPMPath(&buf, package_name, installed_version) catch |err| {
const satisfies = npm_version.satisfies(installed_version, this.lockfile.buffers.string_bytes.items, tags_buf.items);
if (satisfies) {
const buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(buf);
const npm_package_path = this.pathForCachedNPMPath(buf, package_name, installed_version) catch |err| {
Output.debug("error getting path for cached npm path: {s}", .{bun.span(@errorName(err))});
return null;
};
var npm_info = version.npm().?;
npm_info.version = Semver.Query.Group.from(installed_version);
const dependency = Dependency.Version{
.tag = .npm,
.literal = version.literal,
.value = .{
.npm = .{
.name = String.init(package_name, package_name),
.version = Semver.Query.Group.from(installed_version),
},
.npm = npm_info,
},
};
switch (FolderResolution.getOrPut(.{ .cache_folder = npm_package_path }, dependency, ".", this)) {
.new_package_id => |id| {
this.enqueueDependencyList(this.lockfile.packages.items(.dependencies)[id]);
return id;
return .{ .package_id = id, .is_first_time = true };
},
.package_id => |id| {
this.enqueueDependencyList(this.lockfile.packages.items(.dependencies)[id]);
return id;
return .{ .package_id = id, .is_first_time = false };
},
.err => |err| {
Output.debug("error getting or putting folder resolution: {s}", .{bun.span(@errorName(err))});
@@ -218,6 +223,7 @@ pub fn verifyResolutions(this: *PackageManager, log_level: PackageManager.Option
// @sortImports
const PackageManagerDirectories = @import("./PackageManagerDirectories.zig");
const std = @import("std");
const bun = @import("bun");
@@ -229,7 +235,6 @@ const strings = bun.strings;
const Semver = bun.Semver;
const SlicedString = Semver.SlicedString;
const String = Semver.String;
const Dependency = bun.install.Dependency;
const DependencyID = bun.install.DependencyID;

View File

@@ -266,7 +266,7 @@ pub fn runTasks(
entry.value_ptr.manifest.pkg.public_max_age = timestamp_this_tick.?;
if (manager.options.enable.manifest_cache) {
if (manager.options.enable.manifest_cache != .disabled) {
Npm.PackageManifest.Serializer.saveAsync(
&entry.value_ptr.manifest,
manager.scopeForPackageName(name.slice()),

View File

@@ -66,14 +66,14 @@ pub fn byNameHashAllowExpired(
return null;
}
if (pm.options.enable.manifest_cache) {
if (pm.options.enable.manifest_cache != .disabled) {
if (Npm.PackageManifest.Serializer.loadByFileID(
pm.allocator,
scope,
pm.getCacheDirectory(),
name_hash,
) catch null) |manifest| {
if (pm.options.enable.manifest_cache_control and manifest.pkg.public_max_age > pm.timestamp_for_manifest_cache_control) {
if (manifest.pkg.public_max_age > pm.timestamp_for_manifest_cache_control or pm.options.enable.manifest_cache == .prefer_offline) {
entry.value_ptr.* = .{ .manifest = manifest };
return &entry.value_ptr.manifest;
} else {

View File

@@ -1047,6 +1047,7 @@ pub const Printer = struct {
null,
null,
.install,
null,
);
var printer = Printer{

View File

@@ -489,7 +489,7 @@ pub const Registry = struct {
new_etag,
@as(u32, @truncate(@as(u64, @intCast(@max(0, std.time.timestamp()))))) + 300,
)) |package| {
if (package_manager.options.enable.manifest_cache) {
if (package_manager.options.enable.manifest_cache != .disabled) {
PackageManifest.Serializer.saveAsync(
&package,
scope,

View File

@@ -94,6 +94,7 @@ pub const FolderResolution = union(Tag) {
.value = .{
.npm = .{
.version = this.version,
.url = String.from(""),
},
},

View File

@@ -2364,9 +2364,9 @@ pub const Resolver = struct {
}
if (r.opts.prefer_offline_install) {
if (pm.resolveFromDiskCache(esm.name, version)) |package_id| {
input_package_id_.* = package_id;
return .{ .resolution = pm.lockfile.packages.items(.resolution)[package_id] };
if (pm.resolveFromDiskCache(esm.name, version)) |result| {
input_package_id_.* = result.package_id;
return .{ .resolution = pm.lockfile.packages.items(.resolution)[result.package_id] };
}
}

View File

@@ -0,0 +1,289 @@
import { spawn } from "bun";
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "bun:test";
import { writeFile } from "fs/promises";
import { bunEnv as bunEnv_, bunExe } from "harness";
import { join } from "path";
import {
dummyAfterAll,
dummyAfterEach,
dummyBeforeAll,
dummyBeforeEach,
package_dir,
requested,
root_url,
setHandler,
} from "./dummy.registry";
beforeAll(dummyBeforeAll);
afterAll(dummyAfterAll);
beforeEach(dummyBeforeEach);
afterEach(dummyAfterEach);
let bunEnv: Record<string, string>;
beforeEach(() => {
bunEnv = {
...bunEnv_,
};
console.log(package_dir);
});
it("should use cache when --prefer-offline is passed even with expired data", async () => {
const urls: string[] = [];
// Create a registry handler that sets cache control headers with expiry in the past
setHandler(async request => {
urls.push(request.url);
expect(request.method).toBe("GET");
if (request.url.endsWith(".tgz")) {
// For .tgz files, return the test package from dummy registry
const { file } = await import("bun");
const { basename, join } = await import("path");
return new Response(file(join(import.meta.dir, basename(request.url).toLowerCase())), {
status: 200,
headers: {
"content-type": "application/octet-stream",
// Set cache control to expire in the past
"cache-control": "max-age=3600",
"last-modified": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago
},
});
}
// For package metadata requests
const name = request.url.slice(request.url.indexOf("/", root_url.length) + 1);
return new Response(
JSON.stringify({
name,
versions: {
"0.0.2": {
name,
version: "0.0.2",
dist: {
tarball: `${request.url}-0.0.2.tgz`,
},
},
"0.1.0": {
name,
version: "0.1.0",
dist: {
tarball: `${request.url}-0.1.0.tgz`,
},
},
},
"dist-tags": {
latest: name === "moo" ? "0.1.0" : "0.0.2",
},
}),
{
status: 200,
headers: {
"content-type": "application/json",
// Set cache control to expire in the past
"cache-control": "max-age=3600",
"last-modified": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago
},
},
);
});
// Create package.json with a dependency
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "test-prefer-offline",
version: "1.0.0",
dependencies: {
"bar": "^0.0.2",
},
}),
);
// First install - this should populate the cache
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install"],
cwd: package_dir,
env: {
...bunEnv,
},
stdio: ["ignore", "ignore", "pipe"],
});
const stderrText = await new Response(stderr).text();
expect(await exited).toBe(0);
expect(stderrText).not.toContain("error:");
}
// Save the URLs from the first install
const firstInstallUrls = [...urls];
expect(firstInstallUrls.length).toBeGreaterThan(0);
// Clear the URLs array and requested counter
urls.length = 0;
const firstRequestCount = requested;
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "test-prefer-offline",
version: "1.0.0",
dependencies: {
// Update from exact to inexact to force a registry lookup.
"bar": ">=0.0.2",
},
}),
);
// Second install with --prefer-offline - this should NOT make network requests
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--prefer-offline", "--verbose"],
cwd: package_dir,
env: {
...bunEnv,
BUN_CONFIG_MANIFEST_CACHE_CONTROL_TIMESTAMP: (Date.now() + 7200000).toString(),
BUN_DEBUG: "/tmp/all.log",
BUN_DEBUG_ALL: "1",
BUN_DEBUG_QUIET_LOGS: undefined,
},
stdio: ["ignore", "inherit", "pipe"],
});
const stderrText = await new Response(stderr).text();
const exitCode = await exited;
// With --prefer-offline and no cached manifest, the install should NOT fail
expect(exitCode).toBe(0);
expect(stderrText).not.toContain("failed to resolve");
console.log(stderrText);
}
// Verify no new network requests were made (the key behavior we want)
expect(urls).toEqual([]);
expect(requested).toBe(firstRequestCount);
// Since the install failed, the lockfile should remain from the first install
const lockfileExists = await Bun.file(join(package_dir, "bun.lock")).exists();
expect(lockfileExists).toBe(true);
});
it("should make network requests without --prefer-offline even with expired cache", async () => {
const urls: string[] = [];
// Create a registry handler that sets cache control headers with expiry in the past
setHandler(async request => {
urls.push(request.url);
expect(request.method).toBe("GET");
if (request.url.endsWith(".tgz")) {
const { file } = await import("bun");
const { basename, join } = await import("path");
return new Response(file(join(import.meta.dir, basename(request.url).toLowerCase())), {
status: 200,
headers: {
"content-type": "application/octet-stream",
"cache-control": "max-age=3600",
"last-modified": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago
},
});
}
const name = request.url.slice(request.url.indexOf("/", root_url.length) + 1);
return new Response(
JSON.stringify({
name,
versions: {
"0.0.2": {
name,
version: "0.0.2",
dist: {
tarball: `${request.url}-0.0.2.tgz`,
},
},
"0.1.0": {
name,
version: "0.1.0",
dist: {
tarball: `${request.url}-0.1.0.tgz`,
},
},
},
"dist-tags": {
latest: name === "moo" ? "0.1.0" : "0.0.2",
},
}),
{
status: 200,
headers: {
"content-type": "application/json",
"cache-control": "max-age=3600",
"last-modified": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago
},
},
);
});
// Create package.json with a dependency
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "test-normal-install",
version: "1.0.0",
dependencies: {
"bar": "0.0.2",
},
}),
);
// First install to populate cache
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install"],
cwd: package_dir,
env: bunEnv,
stdio: ["ignore", "ignore", "pipe"],
});
expect(await exited).toBe(0);
}
// Clear URLs and add a new dependency to force registry lookup
urls.length = 0;
// Add a new dependency to package.json to force a registry lookup
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "test-normal-install",
version: "1.0.0",
dependencies: {
"bar": "0.0.2",
"moo": "0.1.0", // This will force a registry lookup
},
}),
);
// Second install WITHOUT --prefer-offline - this SHOULD make network requests
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install"],
cwd: package_dir,
env: bunEnv,
stdio: ["ignore", "ignore", "pipe"],
});
const stderrText = await new Response(stderr).text();
if ((await exited) !== 0) {
console.log("Normal install STDERR:", stderrText);
}
expect(await exited).toBe(0);
expect(stderrText).not.toContain("error:");
}
// Verify network requests were made because cache was expired
expect(urls.length).toBeGreaterThan(0);
});