Skip to content

Commit a1bfb6f

Browse files
mcollinaaduh95
authored andcommitted
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. Also make FindSingleExecutableResource() return the deserialized SeaResource by reference instead of by value: the copy included the whole assets map, and getAsset() calls it once per asset read, so every fs operation served by the SEA virtual file system paid a cost linear in the number of bundled assets - with 8192 assets, reading each of them took seconds instead of milliseconds. Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: #65810 Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
1 parent 50bc27f commit a1bfb6f

17 files changed

Lines changed: 389 additions & 18 deletions

‎doc/api/single-executable-applications.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ The configuration currently reads the following top-level fields:
117117
"useSnapshot": false, // Default: false
118118
"useCodeCache": true, // Default: false
119119
"useVfs": true, // Default: false
120+
"vfsArchive": "/path/to/assets.zip", // Optional
120121
"execArgv": ["--no-warnings", "--max-old-space-size=4096"], // Optional
121122
"execArgvExtension": "env", // Default: "env", options: "none", "env", "cli"
122123
"assets": { // Optional
@@ -260,6 +261,41 @@ Module format detection works the same way as on the real file
260261
system: name bundled ES modules with the `.mjs` extension (or provide the
261262
relevant `package.json` files as assets) so they are interpreted as ESM.
262263
264+
#### Serving the assets from a ZIP archive with `"vfsArchive"`
265+
266+
Instead of listing individual `"assets"`, the configuration can point
267+
`"vfsArchive"` at a prebuilt ZIP archive. The archive is embedded into the
268+
executable as-is, and the virtual file system serves the files inside it,
269+
inflating each one when it is read. When the assets are compressible (such
270+
as JavaScript, JSON, or other text), a deflate-compressed archive can
271+
substantially reduce the size of the generated executable.
272+
273+
The archive can be built with any ZIP tool, or with the ZIP support in
274+
[`node:zlib`][]:
275+
276+
```mjs
277+
import { zipFiles } from 'node:zlib';
278+
import { createWriteStream } from 'node:fs';
279+
import { pipeline } from 'node:stream/promises';
280+
281+
await pipeline(
282+
zipFiles([
283+
['./dist/config.json', 'config.json'],
284+
['./dist/data.txt', 'data/data.txt'],
285+
]),
286+
createWriteStream('assets.zip'),
287+
);
288+
```
289+
290+
The mounted file tree looks the same as with `"assets"`: the entries appear
291+
under the mount point using their archive names, the main script is placed
292+
at the mount point root, and access through `__dirname`-relative paths,
293+
`require()`, and `import` is unchanged. However, `sea.getAsset()` and
294+
`sea.getAssetAsBlob()` do not serve the individual files, because the
295+
executable only embeds the archive; read the files through the file system
296+
APIs instead. `"vfsArchive"` requires `"useVfs": true` and cannot be
297+
combined with `"assets"`.
298+
263299
#### Snapshot and code caching limitations
264300
265301
`"useVfs": true` cannot be used together with `"useSnapshot": true` or
@@ -751,6 +787,7 @@ to help us document them.
751787
[Using native addons in the injected main script]: #using-native-addons-in-the-injected-main-script
752788
[VFS documentation]: vfs.md
753789
[Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/
790+
[`node:zlib`]: zlib.md
754791
[`process.execPath`]: process.md#processexecpath
755792
[`require()`]: modules.md#requireid
756793
[`require.main`]: modules.md#accessing-the-main-module

‎doc/api/vfs.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,11 @@ is loaded from inside the mount through the ESM loader, and
518518
`"useVfs"` cannot be used together with `"useSnapshot"` or `"useCodeCache"`.
519519
The SEA configuration parser will error if either combination is detected.
520520

521+
Instead of listing individual `"assets"`, the SEA configuration can point
522+
`"vfsArchive"` at a prebuilt ZIP archive; the mount is then backed by a
523+
[`ZipProvider`][] over the embedded archive, and each file is inflated when
524+
it is read. See [Serving the assets from a ZIP archive][] for details.
525+
521526
See the [Single Executable Application][] documentation for more information
522527
on creating SEA builds with assets.
523528

@@ -693,6 +698,7 @@ fields use synthetic but stable values:
693698
[CommonJS resolution algorithm]: modules.md#all-together
694699
[ES modules resolution algorithm]: esm.md#resolution-algorithm
695700
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
701+
[Serving the assets from a ZIP archive]: single-executable-applications.md#serving-the-assets-from-a-zip-archive-with-vfsarchive
696702
[Single Executable Application]: single-executable-applications.md
697703
[`--import`]: cli.md#--importmodule
698704
[`--require`]: cli.md#-r---require-module

‎lib/internal/vfs/sea.js‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
'use strict';
22

3-
const { isSea, isVfsEnabled } = internalBinding('sea');
3+
const {
4+
ObjectKeys,
5+
} = primordials;
6+
7+
const {
8+
isSea,
9+
isVfsEnabled,
10+
isVfsArchiveEnabled,
11+
getAsset,
12+
} = internalBinding('sea');
413
const { kEmptyObject } = require('internal/util');
514
const {
615
codes: {
@@ -38,9 +47,14 @@ function initSeaVfs(options = kEmptyObject) {
3847
}
3948

4049
const { VirtualFileSystem } = require('internal/vfs/file_system');
41-
const { SEAProvider } = require('internal/vfs/providers/sea');
4250

43-
const provider = new SEAProvider({ extraFiles: options.extraFiles });
51+
let provider;
52+
if (isVfsArchiveEnabled()) {
53+
provider = createZipProvider(options.extraFiles);
54+
} else {
55+
const { SEAProvider } = require('internal/vfs/providers/sea');
56+
provider = new SEAProvider({ extraFiles: options.extraFiles });
57+
}
4458
// The SEA warning already covers the feature; don't emit the
4559
// VirtualFileSystem experimental warning for the implicit SEA mount.
4660
const vfs = new VirtualFileSystem(provider, {
@@ -51,6 +65,43 @@ function initSeaVfs(options = kEmptyObject) {
5165
return vfs;
5266
}
5367

68+
// The reserved asset key under which --build-sea stores the ZIP archive
69+
// named by "vfsArchive". Must match kVfsArchiveAssetName in src/node_sea.cc.
70+
const kVfsArchiveAssetName = 'node:sea:vfs.zip';
71+
72+
/**
73+
* Creates a ZipProvider over the ZIP archive embedded by `"vfsArchive"`.
74+
* The archive bytes are used in place (a zero-copy view over the SEA
75+
* blob); entries are inflated on demand when they are opened.
76+
* The extra files (the SEA main script) are added to the in-memory archive
77+
* index as stored entries, leaving the embedded bytes untouched.
78+
* @param {Record<string, string|Buffer>} [extraFiles] Additional files to
79+
* serve alongside the assets (used for the SEA main script)
80+
* @returns {ZipProvider}
81+
*/
82+
function createZipProvider(extraFiles) {
83+
const { Buffer } = require('buffer');
84+
const { ZipBuffer } = require('internal/zip');
85+
const { ZipProvider } = require('internal/vfs/providers/ziparchive');
86+
87+
// getAsset returns a zero-copy ArrayBuffer over the (possibly read-only)
88+
// SEA blob; ZipBuffer only reads from it, and decompressed contents are
89+
// fresh buffers, so the view can be used without copying the archive.
90+
const archive = getAsset(kVfsArchiveAssetName);
91+
const zip = new ZipBuffer(Buffer.from(archive));
92+
93+
if (extraFiles !== undefined) {
94+
const names = ObjectKeys(extraFiles);
95+
for (let i = 0; i < names.length; i++) {
96+
const content = extraFiles[names[i]];
97+
const data = typeof content === 'string' ? Buffer.from(content) : content;
98+
zip.addSync(names[i], data, { __proto__: null, method: 'store' });
99+
}
100+
}
101+
102+
return new ZipProvider(zip);
103+
}
104+
54105
/* c8 ignore stop */
55106

56107
module.exports = {

‎src/module_wrap.cc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ void ModuleWrap::New(const FunctionCallbackInfo<Value>& args) {
367367
// For embedder ESM in a SEA, use the bundled code cache if available.
368368
if (id_symbol == realm->isolate_data()->embedder_module_hdo() &&
369369
sea::IsSingleExecutable()) {
370-
sea::SeaResource sea = sea::FindSingleExecutableResource();
370+
const sea::SeaResource& sea = sea::FindSingleExecutableResource();
371371
if (sea.use_code_cache()) {
372372
std::string_view data = sea.code_cache.value();
373373
user_cached_data = new ScriptCompiler::CachedData(

‎src/node.cc‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ MaybeLocal<Value> StartExecution(Environment* env,
330330
#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION
331331
// Snapshot in SEA is only loaded for the main thread.
332332
if (sea::IsSingleExecutable() && env->is_main_thread()) {
333-
sea::SeaResource sea = sea::FindSingleExecutableResource();
333+
const sea::SeaResource& sea = sea::FindSingleExecutableResource();
334334
// The SEA preparation blob building process should already enforce this,
335335
// this check is just here to guard against the unlikely case where
336336
// the SEA preparation blob has been manually modified by someone.
@@ -986,7 +986,7 @@ static ExitCode InitializeNodeWithArgsInternal(
986986
!(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv);
987987
#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION
988988
if (sea::IsSingleExecutable()) {
989-
sea::SeaResource sea_resource = sea::FindSingleExecutableResource();
989+
const sea::SeaResource& sea_resource = sea::FindSingleExecutableResource();
990990
if (sea_resource.exec_argv_extension != sea::SeaExecArgvExtension::kEnv) {
991991
should_parse_node_options = false;
992992
}
@@ -1603,7 +1603,7 @@ bool LoadSnapshotData(const SnapshotData** snapshot_data_ptr) {
16031603
#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION
16041604
if (sea::IsSingleExecutable()) {
16051605
is_sea = true;
1606-
sea::SeaResource sea = sea::FindSingleExecutableResource();
1606+
const sea::SeaResource& sea = sea::FindSingleExecutableResource();
16071607
if (sea.use_snapshot()) {
16081608
std::unique_ptr<SnapshotData> read_data =
16091609
std::make_unique<SnapshotData>();

‎src/node_contextify.cc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1768,7 +1768,7 @@ static void CompileFunctionForCJSLoader(
17681768
ScriptCompiler::CachedData* cached_data = nullptr;
17691769
#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION
17701770
if (is_sea_main) {
1771-
sea::SeaResource sea = sea::FindSingleExecutableResource();
1771+
const sea::SeaResource& sea = sea::FindSingleExecutableResource();
17721772
// Use the "main" field in SEA config for the filename.
17731773
Local<Value> filename_from_sea;
17741774
if (!ToV8Value(context, sea.code_path).ToLocal(&filename_from_sea)) {

‎src/node_sea.cc‎

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ using v8::Value;
4444
namespace node {
4545
namespace sea {
4646

47+
// The reserved asset key under which the ZIP archive named by "vfsArchive"
48+
// is stored. Must match the name used by lib/internal/vfs/sea.js.
49+
constexpr std::string_view kVfsArchiveAssetName = "node:sea:vfs.zip";
50+
4751
namespace {
4852

4953
SeaFlags operator|(SeaFlags x, SeaFlags y) {
@@ -246,7 +250,7 @@ bool SeaResource::use_code_cache() const {
246250
return static_cast<bool>(flags & SeaFlags::kUseCodeCache);
247251
}
248252

249-
SeaResource FindSingleExecutableResource() {
253+
const SeaResource& FindSingleExecutableResource() {
250254
static const SeaResource sea_resource = []() -> SeaResource {
251255
std::string_view blob = FindSingleExecutableBlob();
252256
per_process::Debug(DebugCategory::SEA,
@@ -266,12 +270,21 @@ void IsSea(const FunctionCallbackInfo<Value>& args) {
266270
void IsVfsEnabled(const FunctionCallbackInfo<Value>& args) {
267271
bool enabled = false;
268272
if (IsSingleExecutable()) {
269-
SeaResource sea_resource = FindSingleExecutableResource();
273+
const SeaResource& sea_resource = FindSingleExecutableResource();
270274
enabled = static_cast<bool>(sea_resource.flags & SeaFlags::kEnableVfs);
271275
}
272276
args.GetReturnValue().Set(enabled);
273277
}
274278

279+
void IsVfsArchiveEnabled(const FunctionCallbackInfo<Value>& args) {
280+
bool enabled = false;
281+
if (IsSingleExecutable()) {
282+
const SeaResource& sea_resource = FindSingleExecutableResource();
283+
enabled = static_cast<bool>(sea_resource.flags & SeaFlags::kVfsArchive);
284+
}
285+
args.GetReturnValue().Set(enabled);
286+
}
287+
275288
void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo<Value>& args) {
276289
bool is_building_sea =
277290
!per_process::cli_options->experimental_sea_config.empty();
@@ -285,7 +298,7 @@ void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo<Value>& args) {
285298
return;
286299
}
287300

288-
SeaResource sea_resource = FindSingleExecutableResource();
301+
const SeaResource& sea_resource = FindSingleExecutableResource();
289302
args.GetReturnValue().Set(!static_cast<bool>(
290303
sea_resource.flags & SeaFlags::kDisableExperimentalSeaWarning));
291304
}
@@ -300,7 +313,7 @@ std::tuple<int, char**> FixupArgsForSEA(int argc,
300313
static std::vector<std::string> exec_argv_storage;
301314
static std::vector<std::string> cli_extension_args;
302315

303-
SeaResource sea_resource = FindSingleExecutableResource();
316+
const SeaResource& sea_resource = FindSingleExecutableResource();
304317

305318
new_argv.clear();
306319
exec_argv_storage.clear();
@@ -466,6 +479,16 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
466479
if (use_vfs) {
467480
result.flags |= SeaFlags::kEnableVfs;
468481
}
482+
} else if (key == "vfsArchive") {
483+
std::string_view archive_path;
484+
if (field.value().get_string().get(archive_path)) {
485+
FPrintF(stderr,
486+
"\"vfsArchive\" field of %s is not a string\n",
487+
config_path);
488+
return std::nullopt;
489+
}
490+
result.vfs_archive_path = archive_path;
491+
result.flags |= SeaFlags::kVfsArchive;
469492
} else if (key == "assets") {
470493
simdjson::ondemand::object assets_object;
471494
if (field.value().get_object().get(assets_object)) {
@@ -597,6 +620,26 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
597620
}
598621
}
599622

623+
if (static_cast<bool>(result.flags & SeaFlags::kVfsArchive)) {
624+
if (!static_cast<bool>(result.flags & SeaFlags::kEnableVfs)) {
625+
FPrintF(stderr, "\"vfsArchive\" requires \"useVfs\" to be true\n");
626+
return std::nullopt;
627+
}
628+
if (!result.assets.empty()) {
629+
FPrintF(stderr,
630+
"\"vfsArchive\" cannot be used together with \"assets\"\n");
631+
return std::nullopt;
632+
}
633+
if (result.vfs_archive_path.empty()) {
634+
FPrintF(stderr,
635+
"\"vfsArchive\" field of %s is not a non-empty string\n",
636+
config_path);
637+
return std::nullopt;
638+
}
639+
// The archive is embedded as a single reserved asset.
640+
result.flags |= SeaFlags::kIncludeAssets;
641+
}
642+
600643
if (result.main_path.empty()) {
601644
FPrintF(stderr,
602645
"\"main\" field of %s is not a non-empty string\n",
@@ -808,6 +851,27 @@ ExitCode GenerateSingleExecutableBlob(
808851
if (!config.assets.empty() && BuildAssets(config.assets, &assets) != 0) {
809852
return ExitCode::kGenericUserError;
810853
}
854+
if (static_cast<bool>(config.flags & SeaFlags::kVfsArchive)) {
855+
std::string archive;
856+
int r = ReadFileSync(&archive, config.vfs_archive_path.c_str());
857+
if (r != 0) {
858+
const char* err = uv_strerror(r);
859+
FPrintF(stderr,
860+
"Cannot read vfsArchive %s: %s\n",
861+
config.vfs_archive_path,
862+
err);
863+
return ExitCode::kGenericUserError;
864+
}
865+
// Only a signature sanity check; the archive is parsed by the ZIP
866+
// support in JS when the executable starts.
867+
if (archive.size() < 4 || archive[0] != 'P' || archive[1] != 'K') {
868+
FPrintF(stderr,
869+
"vfsArchive %s is not a ZIP archive\n",
870+
config.vfs_archive_path);
871+
return ExitCode::kGenericUserError;
872+
}
873+
assets.emplace(std::string(kVfsArchiveAssetName), std::move(archive));
874+
}
811875
std::unordered_map<std::string_view, std::string_view> assets_view;
812876
for (auto const& [key, content] : assets) {
813877
assets_view.emplace(key, content);
@@ -868,7 +932,7 @@ void GetAsset(const FunctionCallbackInfo<Value>& args) {
868932
CHECK_EQ(args.Length(), 1);
869933
CHECK(args[0]->IsString());
870934
Utf8Value key(args.GetIsolate(), args[0]);
871-
SeaResource sea_resource = FindSingleExecutableResource();
935+
const SeaResource& sea_resource = FindSingleExecutableResource();
872936
if (sea_resource.assets.empty()) {
873937
return;
874938
}
@@ -892,7 +956,7 @@ void GetAsset(const FunctionCallbackInfo<Value>& args) {
892956
void GetAssetKeys(const FunctionCallbackInfo<Value>& args) {
893957
CHECK_EQ(args.Length(), 0);
894958
Isolate* isolate = args.GetIsolate();
895-
SeaResource sea_resource = FindSingleExecutableResource();
959+
const SeaResource& sea_resource = FindSingleExecutableResource();
896960

897961
Local<Context> context = isolate->GetCurrentContext();
898962
LocalVector<Value> keys(isolate);
@@ -914,7 +978,7 @@ MaybeLocal<Value> LoadSingleExecutableApplication(
914978
// env->context() is entered.
915979
Environment* env = info.env();
916980
Local<Context> context = env->context();
917-
SeaResource sea = FindSingleExecutableResource();
981+
const SeaResource& sea = FindSingleExecutableResource();
918982

919983
CHECK(!sea.use_snapshot());
920984
// TODO(joyeecheung): this should be an external string. Refactor UnionBytes
@@ -936,7 +1000,7 @@ bool MaybeLoadSingleExecutableApplication(Environment* env) {
9361000
return false;
9371001
}
9381002

939-
SeaResource sea = FindSingleExecutableResource();
1003+
const SeaResource& sea = FindSingleExecutableResource();
9401004

9411005
if (sea.use_snapshot()) {
9421006
// The SEA preparation blob building process should already enforce this,
@@ -962,7 +1026,7 @@ void Initialize(Local<Object> target,
9621026
Isolate* isolate = env->isolate();
9631027

9641028
if (IsSingleExecutable()) {
965-
SeaResource sea_resource = FindSingleExecutableResource();
1029+
const SeaResource& sea_resource = FindSingleExecutableResource();
9661030
// Expose the main script path recorded in the SEA config so the VFS
9671031
// integration can place the main script at the mount point root.
9681032
if (static_cast<bool>(sea_resource.flags & SeaFlags::kEnableVfs)) {
@@ -983,6 +1047,7 @@ void Initialize(Local<Object> target,
9831047

9841048
SetMethod(context, target, "isSea", IsSea);
9851049
SetMethod(context, target, "isVfsEnabled", IsVfsEnabled);
1050+
SetMethod(context, target, "isVfsArchiveEnabled", IsVfsArchiveEnabled);
9861051
SetMethod(context,
9871052
target,
9881053
"isExperimentalSeaWarningNeeded",
@@ -994,6 +1059,7 @@ void Initialize(Local<Object> target,
9941059
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
9951060
registry->Register(IsSea);
9961061
registry->Register(IsVfsEnabled);
1062+
registry->Register(IsVfsArchiveEnabled);
9971063
registry->Register(IsExperimentalSeaWarningNeeded);
9981064
registry->Register(GetAsset);
9991065
registry->Register(GetAssetKeys);

0 commit comments

Comments
 (0)