From 605bb1d4159630fa86034f6342d22face93192a9 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 5 Sep 2026 11:31:35 +0200 Subject: [PATCH 1/2] sea: return the SEA resource by reference FindSingleExecutableResource() returned the deserialized SeaResource by value, copying the whole assets map on every call. getAsset() calls it once per asset read, which made every fs operation served by the SEA virtual file system pay a cost linear in the number of bundled assets: with 8192 assets, reading each of them took seconds instead of milliseconds. Return a reference to the cached resource instead. Signed-off-by: Matteo Collina --- src/module_wrap.cc | 2 +- src/node.cc | 6 +++--- src/node_contextify.cc | 2 +- src/node_sea.cc | 18 +++++++++--------- src/node_sea.h | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/module_wrap.cc b/src/module_wrap.cc index 38ac15b337f3..aa6b3bc303f7 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -367,7 +367,7 @@ void ModuleWrap::New(const FunctionCallbackInfo& args) { // For embedder ESM in a SEA, use the bundled code cache if available. if (id_symbol == realm->isolate_data()->embedder_module_hdo() && sea::IsSingleExecutable()) { - sea::SeaResource sea = sea::FindSingleExecutableResource(); + const sea::SeaResource& sea = sea::FindSingleExecutableResource(); if (sea.use_code_cache()) { std::string_view data = sea.code_cache.value(); user_cached_data = new ScriptCompiler::CachedData( diff --git a/src/node.cc b/src/node.cc index a43eb28b779d..75d01fdd23e9 100644 --- a/src/node.cc +++ b/src/node.cc @@ -327,7 +327,7 @@ MaybeLocal StartExecution(Environment* env, #ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION // Snapshot in SEA is only loaded for the main thread. if (sea::IsSingleExecutable() && env->is_main_thread()) { - sea::SeaResource sea = sea::FindSingleExecutableResource(); + const sea::SeaResource& sea = sea::FindSingleExecutableResource(); // The SEA preparation blob building process should already enforce this, // this check is just here to guard against the unlikely case where // the SEA preparation blob has been manually modified by someone. @@ -957,7 +957,7 @@ static ExitCode InitializeNodeWithArgsInternal( !(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv); #ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION if (sea::IsSingleExecutable()) { - sea::SeaResource sea_resource = sea::FindSingleExecutableResource(); + const sea::SeaResource& sea_resource = sea::FindSingleExecutableResource(); if (sea_resource.exec_argv_extension != sea::SeaExecArgvExtension::kEnv) { should_parse_node_options = false; } @@ -1517,7 +1517,7 @@ bool LoadSnapshotData(const SnapshotData** snapshot_data_ptr) { #ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION if (sea::IsSingleExecutable()) { is_sea = true; - sea::SeaResource sea = sea::FindSingleExecutableResource(); + const sea::SeaResource& sea = sea::FindSingleExecutableResource(); if (sea.use_snapshot()) { std::unique_ptr read_data = std::make_unique(); diff --git a/src/node_contextify.cc b/src/node_contextify.cc index 8f9dddf53cae..838a65c1ff35 100644 --- a/src/node_contextify.cc +++ b/src/node_contextify.cc @@ -1768,7 +1768,7 @@ static void CompileFunctionForCJSLoader( ScriptCompiler::CachedData* cached_data = nullptr; #ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION if (is_sea_main) { - sea::SeaResource sea = sea::FindSingleExecutableResource(); + const sea::SeaResource& sea = sea::FindSingleExecutableResource(); // Use the "main" field in SEA config for the filename. Local filename_from_sea; if (!ToV8Value(context, sea.code_path).ToLocal(&filename_from_sea)) { diff --git a/src/node_sea.cc b/src/node_sea.cc index d17ed779399c..82f9328c88e5 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -246,7 +246,7 @@ bool SeaResource::use_code_cache() const { return static_cast(flags & SeaFlags::kUseCodeCache); } -SeaResource FindSingleExecutableResource() { +const SeaResource& FindSingleExecutableResource() { static const SeaResource sea_resource = []() -> SeaResource { std::string_view blob = FindSingleExecutableBlob(); per_process::Debug(DebugCategory::SEA, @@ -266,7 +266,7 @@ void IsSea(const FunctionCallbackInfo& args) { void IsVfsEnabled(const FunctionCallbackInfo& args) { bool enabled = false; if (IsSingleExecutable()) { - SeaResource sea_resource = FindSingleExecutableResource(); + const SeaResource& sea_resource = FindSingleExecutableResource(); enabled = static_cast(sea_resource.flags & SeaFlags::kEnableVfs); } args.GetReturnValue().Set(enabled); @@ -285,7 +285,7 @@ void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo& args) { return; } - SeaResource sea_resource = FindSingleExecutableResource(); + const SeaResource& sea_resource = FindSingleExecutableResource(); args.GetReturnValue().Set(!static_cast( sea_resource.flags & SeaFlags::kDisableExperimentalSeaWarning)); } @@ -300,7 +300,7 @@ std::tuple FixupArgsForSEA(int argc, static std::vector exec_argv_storage; static std::vector cli_extension_args; - SeaResource sea_resource = FindSingleExecutableResource(); + const SeaResource& sea_resource = FindSingleExecutableResource(); new_argv.clear(); exec_argv_storage.clear(); @@ -868,7 +868,7 @@ void GetAsset(const FunctionCallbackInfo& args) { CHECK_EQ(args.Length(), 1); CHECK(args[0]->IsString()); Utf8Value key(args.GetIsolate(), args[0]); - SeaResource sea_resource = FindSingleExecutableResource(); + const SeaResource& sea_resource = FindSingleExecutableResource(); if (sea_resource.assets.empty()) { return; } @@ -890,7 +890,7 @@ void GetAsset(const FunctionCallbackInfo& args) { void GetAssetKeys(const FunctionCallbackInfo& args) { CHECK_EQ(args.Length(), 0); Isolate* isolate = args.GetIsolate(); - SeaResource sea_resource = FindSingleExecutableResource(); + const SeaResource& sea_resource = FindSingleExecutableResource(); Local context = isolate->GetCurrentContext(); LocalVector keys(isolate); @@ -912,7 +912,7 @@ MaybeLocal LoadSingleExecutableApplication( // env->context() is entered. Environment* env = info.env(); Local context = env->context(); - SeaResource sea = FindSingleExecutableResource(); + const SeaResource& sea = FindSingleExecutableResource(); CHECK(!sea.use_snapshot()); // TODO(joyeecheung): this should be an external string. Refactor UnionBytes @@ -934,7 +934,7 @@ bool MaybeLoadSingleExecutableApplication(Environment* env) { return false; } - SeaResource sea = FindSingleExecutableResource(); + const SeaResource& sea = FindSingleExecutableResource(); if (sea.use_snapshot()) { // The SEA preparation blob building process should already enforce this, @@ -960,7 +960,7 @@ void Initialize(Local target, Isolate* isolate = env->isolate(); if (IsSingleExecutable()) { - SeaResource sea_resource = FindSingleExecutableResource(); + const SeaResource& sea_resource = FindSingleExecutableResource(); // Expose the main script path recorded in the SEA config so the VFS // integration can place the main script at the mount point root. if (static_cast(sea_resource.flags & SeaFlags::kEnableVfs)) { diff --git a/src/node_sea.h b/src/node_sea.h index cd633c2f43b7..5640316d1592 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -70,7 +70,7 @@ struct SeaResource { bool IsSingleExecutable(); std::string_view FindSingleExecutableBlob(); -SeaResource FindSingleExecutableResource(); +const SeaResource& FindSingleExecutableResource(); std::tuple FixupArgsForSEA(int argc, char** argv, std::vector* errors); From 0a39ff78cb2e3532f913bff04fcc8902abb965d6 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 5 Sep 2026 11:31:44 +0200 Subject: [PATCH 2/2] sea: add vfsArchive to serve the assets from a ZIP archive When "vfsArchive" names a ZIP archive in the SEA configuration (with "useVfs": true), --build-sea embeds the archive verbatim as one reserved asset, and at runtime the SEA virtual file system mounts the existing ZipProvider over a zero-copy view of the embedded archive instead of the SEAProvider. The main script is injected into the in-memory archive index as a stored entry, so the mounted tree looks the same as with "assets". The archive can be produced with any ZIP tool or with the ZIP support in node:zlib; no ZIP serialization logic is added to the build side. This trades asset read speed (entries are inflated when opened) for a substantially smaller executable when the assets are compressible. Signed-off-by: Matteo Collina --- doc/api/single-executable-applications.md | 37 +++++++++++ doc/api/vfs.md | 6 ++ lib/internal/vfs/sea.js | 57 +++++++++++++++- src/node_sea.cc | 66 +++++++++++++++++++ src/node_sea.h | 2 + test/fixtures/sea/vfs-zip/config.json | 1 + test/fixtures/sea/vfs-zip/greeting.txt | 1 + test/fixtures/sea/vfs-zip/math.js | 4 ++ test/fixtures/sea/vfs-zip/sea-config.json | 6 ++ test/fixtures/sea/vfs-zip/sea.js | 61 +++++++++++++++++ test/fixtures/sea/vfs-zip/test-pkg-index.js | 5 ++ .../sea/vfs-zip/test-pkg-package.json | 5 ++ ...test-build-sea-vfs-incompatible-options.js | 66 +++++++++++++++++++ ...t-single-executable-application-vfs-zip.js | 60 +++++++++++++++++ 14 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/sea/vfs-zip/config.json create mode 100644 test/fixtures/sea/vfs-zip/greeting.txt create mode 100644 test/fixtures/sea/vfs-zip/math.js create mode 100644 test/fixtures/sea/vfs-zip/sea-config.json create mode 100644 test/fixtures/sea/vfs-zip/sea.js create mode 100644 test/fixtures/sea/vfs-zip/test-pkg-index.js create mode 100644 test/fixtures/sea/vfs-zip/test-pkg-package.json create mode 100644 test/sea/test-single-executable-application-vfs-zip.js diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 5083d16894d6..791d324ee01c 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -117,6 +117,7 @@ The configuration currently reads the following top-level fields: "useSnapshot": false, // Default: false "useCodeCache": true, // Default: false "useVfs": true, // Default: false + "vfsArchive": "/path/to/assets.zip", // Optional "execArgv": ["--no-warnings", "--max-old-space-size=4096"], // Optional "execArgvExtension": "env", // Default: "env", options: "none", "env", "cli" "assets": { // Optional @@ -260,6 +261,41 @@ Module format detection works the same way as on the real file system: name bundled ES modules with the `.mjs` extension (or provide the relevant `package.json` files as assets) so they are interpreted as ESM. +#### Serving the assets from a ZIP archive with `"vfsArchive"` + +Instead of listing individual `"assets"`, the configuration can point +`"vfsArchive"` at a prebuilt ZIP archive. The archive is embedded into the +executable as-is, and the virtual file system serves the files inside it, +inflating each one when it is read. When the assets are compressible (such +as JavaScript, JSON, or other text), a deflate-compressed archive can +substantially reduce the size of the generated executable. + +The archive can be built with any ZIP tool, or with the ZIP support in +[`node:zlib`][]: + +```mjs +import { zipFiles } from 'node:zlib'; +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; + +await pipeline( + zipFiles([ + ['./dist/config.json', 'config.json'], + ['./dist/data.txt', 'data/data.txt'], + ]), + createWriteStream('assets.zip'), +); +``` + +The mounted file tree looks the same as with `"assets"`: the entries appear +under the mount point using their archive names, the main script is placed +at the mount point root, and access through `__dirname`-relative paths, +`require()`, and `import` is unchanged. However, `sea.getAsset()` and +`sea.getAssetAsBlob()` do not serve the individual files, because the +executable only embeds the archive; read the files through the file system +APIs instead. `"vfsArchive"` requires `"useVfs": true` and cannot be +combined with `"assets"`. + #### Snapshot and code caching limitations `"useVfs": true` cannot be used together with `"useSnapshot": true` or @@ -751,6 +787,7 @@ to help us document them. [Using native addons in the injected main script]: #using-native-addons-in-the-injected-main-script [VFS documentation]: vfs.md [Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/ +[`node:zlib`]: zlib.md [`process.execPath`]: process.md#processexecpath [`require()`]: modules.md#requireid [`require.main`]: modules.md#accessing-the-main-module diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 6f67a85ad8a8..65d365b28028 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -453,6 +453,11 @@ is loaded from inside the mount through the ESM loader, and `"useVfs"` cannot be used together with `"useSnapshot"` or `"useCodeCache"`. The SEA configuration parser will error if either combination is detected. +Instead of listing individual `"assets"`, the SEA configuration can point +`"vfsArchive"` at a prebuilt ZIP archive; the mount is then backed by a +[`ZipProvider`][] over the embedded archive, and each file is inflated when +it is read. See [Serving the assets from a ZIP archive][] for details. + See the [Single Executable Application][] documentation for more information on creating SEA builds with assets. @@ -628,6 +633,7 @@ fields use synthetic but stable values: [CommonJS resolution algorithm]: modules.md#all-together [ES modules resolution algorithm]: esm.md#resolution-algorithm [Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management +[Serving the assets from a ZIP archive]: single-executable-applications.md#serving-the-assets-from-a-zip-archive-with-vfsarchive [Single Executable Application]: single-executable-applications.md [`MemoryProvider`]: #class-memoryprovider [`RealFSProvider`]: #class-realfsprovider diff --git a/lib/internal/vfs/sea.js b/lib/internal/vfs/sea.js index f08bc71bd91b..0aafa7705f75 100644 --- a/lib/internal/vfs/sea.js +++ b/lib/internal/vfs/sea.js @@ -1,6 +1,15 @@ 'use strict'; -const { isSea, isVfsEnabled } = internalBinding('sea'); +const { + ObjectKeys, +} = primordials; + +const { + isSea, + isVfsEnabled, + isVfsArchiveEnabled, + getAsset, +} = internalBinding('sea'); const { kEmptyObject } = require('internal/util'); const { codes: { @@ -38,9 +47,14 @@ function initSeaVfs(options = kEmptyObject) { } const { VirtualFileSystem } = require('internal/vfs/file_system'); - const { SEAProvider } = require('internal/vfs/providers/sea'); - const provider = new SEAProvider({ extraFiles: options.extraFiles }); + let provider; + if (isVfsArchiveEnabled()) { + provider = createZipProvider(options.extraFiles); + } else { + const { SEAProvider } = require('internal/vfs/providers/sea'); + provider = new SEAProvider({ extraFiles: options.extraFiles }); + } // The SEA warning already covers the feature; don't emit the // VirtualFileSystem experimental warning for the implicit SEA mount. const vfs = new VirtualFileSystem(provider, { @@ -51,6 +65,43 @@ function initSeaVfs(options = kEmptyObject) { return vfs; } +// The reserved asset key under which --build-sea stores the ZIP archive +// named by "vfsArchive". Must match kVfsArchiveAssetName in src/node_sea.cc. +const kVfsArchiveAssetName = 'node:sea:vfs.zip'; + +/** + * Creates a ZipProvider over the ZIP archive embedded by `"vfsArchive"`. + * The archive bytes are used in place (a zero-copy view over the SEA + * blob); entries are inflated on demand when they are opened. + * The extra files (the SEA main script) are added to the in-memory archive + * index as stored entries, leaving the embedded bytes untouched. + * @param {Record} [extraFiles] Additional files to + * serve alongside the assets (used for the SEA main script) + * @returns {ZipProvider} + */ +function createZipProvider(extraFiles) { + const { Buffer } = require('buffer'); + const { ZipBuffer } = require('internal/zip'); + const { ZipProvider } = require('internal/vfs/providers/ziparchive'); + + // getAsset returns a zero-copy ArrayBuffer over the (possibly read-only) + // SEA blob; ZipBuffer only reads from it, and decompressed contents are + // fresh buffers, so the view can be used without copying the archive. + const archive = getAsset(kVfsArchiveAssetName); + const zip = new ZipBuffer(Buffer.from(archive)); + + if (extraFiles !== undefined) { + const names = ObjectKeys(extraFiles); + for (let i = 0; i < names.length; i++) { + const content = extraFiles[names[i]]; + const data = typeof content === 'string' ? Buffer.from(content) : content; + zip.addSync(names[i], data, { __proto__: null, method: 'store' }); + } + } + + return new ZipProvider(zip); +} + /* c8 ignore stop */ module.exports = { diff --git a/src/node_sea.cc b/src/node_sea.cc index 82f9328c88e5..ad636f6d248b 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -44,6 +44,10 @@ using v8::Value; namespace node { namespace sea { +// The reserved asset key under which the ZIP archive named by "vfsArchive" +// is stored. Must match the name used by lib/internal/vfs/sea.js. +constexpr std::string_view kVfsArchiveAssetName = "node:sea:vfs.zip"; + namespace { SeaFlags operator|(SeaFlags x, SeaFlags y) { @@ -272,6 +276,15 @@ void IsVfsEnabled(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(enabled); } +void IsVfsArchiveEnabled(const FunctionCallbackInfo& args) { + bool enabled = false; + if (IsSingleExecutable()) { + const SeaResource& sea_resource = FindSingleExecutableResource(); + enabled = static_cast(sea_resource.flags & SeaFlags::kVfsArchive); + } + args.GetReturnValue().Set(enabled); +} + void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo& args) { bool is_building_sea = !per_process::cli_options->experimental_sea_config.empty(); @@ -466,6 +479,16 @@ std::optional ParseSingleExecutableConfig( if (use_vfs) { result.flags |= SeaFlags::kEnableVfs; } + } else if (key == "vfsArchive") { + std::string_view archive_path; + if (field.value().get_string().get(archive_path)) { + FPrintF(stderr, + "\"vfsArchive\" field of %s is not a string\n", + config_path); + return std::nullopt; + } + result.vfs_archive_path = archive_path; + result.flags |= SeaFlags::kVfsArchive; } else if (key == "assets") { simdjson::ondemand::object assets_object; if (field.value().get_object().get(assets_object)) { @@ -597,6 +620,26 @@ std::optional ParseSingleExecutableConfig( } } + if (static_cast(result.flags & SeaFlags::kVfsArchive)) { + if (!static_cast(result.flags & SeaFlags::kEnableVfs)) { + FPrintF(stderr, "\"vfsArchive\" requires \"useVfs\" to be true\n"); + return std::nullopt; + } + if (!result.assets.empty()) { + FPrintF(stderr, + "\"vfsArchive\" cannot be used together with \"assets\"\n"); + return std::nullopt; + } + if (result.vfs_archive_path.empty()) { + FPrintF(stderr, + "\"vfsArchive\" field of %s is not a non-empty string\n", + config_path); + return std::nullopt; + } + // The archive is embedded as a single reserved asset. + result.flags |= SeaFlags::kIncludeAssets; + } + if (result.main_path.empty()) { FPrintF(stderr, "\"main\" field of %s is not a non-empty string\n", @@ -808,6 +851,27 @@ ExitCode GenerateSingleExecutableBlob( if (!config.assets.empty() && BuildAssets(config.assets, &assets) != 0) { return ExitCode::kGenericUserError; } + if (static_cast(config.flags & SeaFlags::kVfsArchive)) { + std::string archive; + int r = ReadFileSync(&archive, config.vfs_archive_path.c_str()); + if (r != 0) { + const char* err = uv_strerror(r); + FPrintF(stderr, + "Cannot read vfsArchive %s: %s\n", + config.vfs_archive_path, + err); + return ExitCode::kGenericUserError; + } + // Only a signature sanity check; the archive is parsed by the ZIP + // support in JS when the executable starts. + if (archive.size() < 4 || archive[0] != 'P' || archive[1] != 'K') { + FPrintF(stderr, + "vfsArchive %s is not a ZIP archive\n", + config.vfs_archive_path); + return ExitCode::kGenericUserError; + } + assets.emplace(std::string(kVfsArchiveAssetName), std::move(archive)); + } std::unordered_map assets_view; for (auto const& [key, content] : assets) { assets_view.emplace(key, content); @@ -981,6 +1045,7 @@ void Initialize(Local target, SetMethod(context, target, "isSea", IsSea); SetMethod(context, target, "isVfsEnabled", IsVfsEnabled); + SetMethod(context, target, "isVfsArchiveEnabled", IsVfsArchiveEnabled); SetMethod(context, target, "isExperimentalSeaWarningNeeded", @@ -992,6 +1057,7 @@ void Initialize(Local target, void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(IsSea); registry->Register(IsVfsEnabled); + registry->Register(IsVfsArchiveEnabled); registry->Register(IsExperimentalSeaWarningNeeded); registry->Register(GetAsset); registry->Register(GetAssetKeys); diff --git a/src/node_sea.h b/src/node_sea.h index 5640316d1592..32879e902273 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -31,6 +31,7 @@ enum class SeaFlags : uint32_t { kIncludeAssets = 1 << 3, kIncludeExecArgv = 1 << 4, kEnableVfs = 1 << 5, + kVfsArchive = 1 << 6, }; enum class SeaExecArgvExtension : uint8_t { @@ -48,6 +49,7 @@ struct SeaConfig { ModuleFormat main_format = ModuleFormat::kCommonJS; std::unordered_map assets; std::vector exec_argv; + std::string vfs_archive_path; }; struct SeaResource { diff --git a/test/fixtures/sea/vfs-zip/config.json b/test/fixtures/sea/vfs-zip/config.json new file mode 100644 index 000000000000..8dd57a1d1fdf --- /dev/null +++ b/test/fixtures/sea/vfs-zip/config.json @@ -0,0 +1 @@ +{"name":"test-app","version":"1.0.0"} diff --git a/test/fixtures/sea/vfs-zip/greeting.txt b/test/fixtures/sea/vfs-zip/greeting.txt new file mode 100644 index 000000000000..cbfbb578572c --- /dev/null +++ b/test/fixtures/sea/vfs-zip/greeting.txt @@ -0,0 +1 @@ +Hello from SEA VFS! \ No newline at end of file diff --git a/test/fixtures/sea/vfs-zip/math.js b/test/fixtures/sea/vfs-zip/math.js new file mode 100644 index 000000000000..26be73d850ac --- /dev/null +++ b/test/fixtures/sea/vfs-zip/math.js @@ -0,0 +1,4 @@ +module.exports = { + add: (a, b) => a + b, + multiply: (a, b) => a * b, +}; diff --git a/test/fixtures/sea/vfs-zip/sea-config.json b/test/fixtures/sea/vfs-zip/sea-config.json new file mode 100644 index 000000000000..23ae1f1d23fd --- /dev/null +++ b/test/fixtures/sea/vfs-zip/sea-config.json @@ -0,0 +1,6 @@ +{ + "main": "sea.js", + "output": "sea-prep.blob", + "useVfs": true, + "vfsArchive": "assets.zip" +} diff --git a/test/fixtures/sea/vfs-zip/sea.js b/test/fixtures/sea/vfs-zip/sea.js new file mode 100644 index 000000000000..eb26e0f59651 --- /dev/null +++ b/test/fixtures/sea/vfs-zip/sea.js @@ -0,0 +1,61 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +// With "useVfsZip", the bundled assets are stored as a single ZIP archive +// and mounted through the ZipProvider. Everything is reachable through +// __dirname-relative paths and relative requires, like the plain SEA VFS. + +// The main script runs from inside the VFS, not from the executable. +assert.notStrictEqual(__filename, process.execPath); +assert.strictEqual(path.basename(__filename), 'sea.js'); +assert.strictEqual(require.main, module); +console.log('main script runs from', __filename); + +// The main script itself is readable through fs. +assert.strictEqual(fs.existsSync(__filename), true); + +// Read the config file through standard fs (decompressed from the archive). +const config = JSON.parse( + fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8')); +assert.strictEqual(config.name, 'test-app'); +assert.strictEqual(config.version, '1.0.0'); + +// Read a nested text file. +const greeting = fs.readFileSync( + path.join(__dirname, 'data', 'greeting.txt'), 'utf8'); +assert.strictEqual(greeting, 'Hello from SEA VFS!'); + +// existsSync and statSync work, including for implicit directories. +assert.strictEqual(fs.existsSync(path.join(__dirname, 'nonexistent.txt')), false); +assert.strictEqual(fs.statSync(path.join(__dirname, 'config.json')).isFile(), true); +assert.strictEqual(fs.statSync(path.join(__dirname, 'data')).isDirectory(), true); + +// readdirSync lists archive entries and the injected main script. +const entries = fs.readdirSync(__dirname); +assert.ok(entries.includes('config.json')); +assert.ok(entries.includes('data')); +assert.ok(entries.includes('sea.js')); + +// Relative require of a module stored (deflated) in the archive. +const math = require('./modules/math.js'); +assert.strictEqual(math.add(2, 3), 5); + +// Bare specifier lookup inside the mount. +const pkg = require('test-pkg'); +assert.strictEqual(pkg.greet('SEA'), 'Hello, SEA!'); + +// node:sea assets are replaced by the archive in zip mode; the VFS is the +// way to read individual assets. +const sea = require('node:sea'); +assert.strictEqual(sea.isSea(), true); + +// Repeated reads return independent buffers. +const a = fs.readFileSync(path.join(__dirname, 'data', 'greeting.txt')); +const b = fs.readFileSync(path.join(__dirname, 'data', 'greeting.txt')); +assert.notStrictEqual(a, b); +a[0] = 0; +assert.strictEqual(b.toString('utf8'), 'Hello from SEA VFS!'); + +console.log('All SEA VFS zip tests passed!'); diff --git a/test/fixtures/sea/vfs-zip/test-pkg-index.js b/test/fixtures/sea/vfs-zip/test-pkg-index.js new file mode 100644 index 000000000000..e6c2ee94611c --- /dev/null +++ b/test/fixtures/sea/vfs-zip/test-pkg-index.js @@ -0,0 +1,5 @@ +'use strict'; +module.exports = { + name: 'test-pkg', + greet: (name) => `Hello, ${name}!`, +}; diff --git a/test/fixtures/sea/vfs-zip/test-pkg-package.json b/test/fixtures/sea/vfs-zip/test-pkg-package.json new file mode 100644 index 000000000000..bc4109d609ae --- /dev/null +++ b/test/fixtures/sea/vfs-zip/test-pkg-package.json @@ -0,0 +1,5 @@ +{ + "name": "test-pkg", + "version": "1.0.0", + "exports": { ".": "./index.js" } +} diff --git a/test/sea/test-build-sea-vfs-incompatible-options.js b/test/sea/test-build-sea-vfs-incompatible-options.js index 3f76beff087d..68bd520cb505 100644 --- a/test/sea/test-build-sea-vfs-incompatible-options.js +++ b/test/sea/test-build-sea-vfs-incompatible-options.js @@ -75,3 +75,69 @@ skipIfBuildSEAIsNotSupported(); stderr: /"useVfs" is not supported when "useCodeCache" is true/, }); } + +// Test: "vfsArchive" without "useVfs" +{ + tmpdir.refresh(); + const config = tmpdir.resolve('vfs-archive-no-vfs.json'); + writeFileSync(config, ` +{ + "main": "bundle.js", + "output": "sea", + "vfsArchive": "assets.zip" +} + `, 'utf8'); + spawnSyncAndAssert( + process.execPath, + ['--build-sea', config], { + cwd: tmpdir.path, + }, { + status: 1, + stderr: /"vfsArchive" requires "useVfs" to be true/, + }); +} + +// Test: "vfsArchive" combined with "assets" +{ + tmpdir.refresh(); + const config = tmpdir.resolve('vfs-archive-and-assets.json'); + writeFileSync(config, ` +{ + "main": "bundle.js", + "output": "sea", + "useVfs": true, + "vfsArchive": "assets.zip", + "assets": { "a.txt": "a.txt" } +} + `, 'utf8'); + spawnSyncAndAssert( + process.execPath, + ['--build-sea', config], { + cwd: tmpdir.path, + }, { + status: 1, + stderr: /"vfsArchive" cannot be used together with "assets"/, + }); +} + +// Test: "vfsArchive" is not a string +{ + tmpdir.refresh(); + const config = tmpdir.resolve('vfs-archive-not-string.json'); + writeFileSync(config, ` +{ + "main": "bundle.js", + "output": "sea", + "useVfs": true, + "vfsArchive": true +} + `, 'utf8'); + spawnSyncAndAssert( + process.execPath, + ['--build-sea', config], { + cwd: tmpdir.path, + }, { + status: 1, + stderr: /"vfsArchive" field of .*vfs-archive-not-string\.json is not a string/, + }); +} diff --git a/test/sea/test-single-executable-application-vfs-zip.js b/test/sea/test-single-executable-application-vfs-zip.js new file mode 100644 index 000000000000..5bd79dbd08ed --- /dev/null +++ b/test/sea/test-single-executable-application-vfs-zip.js @@ -0,0 +1,60 @@ +'use strict'; + +// This tests the SEA VFS archive integration - a prebuilt ZIP archive of the +// assets is embedded into the executable and mounted through the ZipProvider. + +const common = require('../common'); + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); +const { createWriteStream } = require('fs'); +const { pipeline } = require('stream/promises'); +const { zipFiles } = require('zlib'); + +async function main() { + tmpdir.refresh(); + + // Build the assets archive with the ZIP support of node:zlib. This is what + // "vfsArchive" users do themselves before running --build-sea. + const fixture = (...args) => fixtures.path('sea', 'vfs-zip', ...args); + await pipeline( + zipFiles([ + [fixture('config.json'), 'config.json'], + [fixture('greeting.txt'), 'data/greeting.txt'], + [fixture('math.js'), 'modules/math.js'], + [fixture('test-pkg-package.json'), 'node_modules/test-pkg/package.json'], + [fixture('test-pkg-index.js'), 'node_modules/test-pkg/index.js'], + ]), + createWriteStream(tmpdir.resolve('assets.zip')), + ); + + const outputFile = buildSEA(fixtures.path('sea', 'vfs-zip')); + + spawnSyncAndAssert( + outputFile, + { + env: { + ...process.env, + NODE_DEBUG_NATIVE: undefined, + }, + }, + { + stdout: /All SEA VFS zip tests passed!/, + stderr(stderr) { + if (/ExperimentalWarning: VirtualFileSystem/.test(stderr)) { + throw new Error('SEA VFS should not emit the public VirtualFileSystem warning'); + } + }, + }, + ); +} + +main().then(common.mustCall());