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/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..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) { @@ -246,7 +250,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,12 +270,21 @@ 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); } +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(); @@ -285,7 +298,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 +313,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(); @@ -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); @@ -868,7 +932,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 +954,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 +976,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 +998,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 +1024,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)) { @@ -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 cd633c2f43b7..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 { @@ -70,7 +72,7 @@ struct SeaResource { bool IsSingleExecutable(); std::string_view FindSingleExecutableBlob(); -SeaResource FindSingleExecutableResource(); +const SeaResource& FindSingleExecutableResource(); std::tuple FixupArgsForSEA(int argc, char** argv, std::vector* errors); 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());