Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions doc/api/single-executable-applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
57 changes: 54 additions & 3 deletions lib/internal/vfs/sea.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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, {
Expand All @@ -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<string, string|Buffer>} [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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ void ModuleWrap::New(const FunctionCallbackInfo<Value>& 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(
Expand Down
6 changes: 3 additions & 3 deletions src/node.cc
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ MaybeLocal<Value> 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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<SnapshotData> read_data =
std::make_unique<SnapshotData>();
Expand Down
2 changes: 1 addition & 1 deletion src/node_contextify.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> filename_from_sea;
if (!ToV8Value(context, sea.code_path).ToLocal(&filename_from_sea)) {
Expand Down
84 changes: 75 additions & 9 deletions src/node_sea.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -246,7 +250,7 @@ bool SeaResource::use_code_cache() const {
return static_cast<bool>(flags & SeaFlags::kUseCodeCache);
}

SeaResource FindSingleExecutableResource() {
const SeaResource& FindSingleExecutableResource() {
static const SeaResource sea_resource = []() -> SeaResource {
std::string_view blob = FindSingleExecutableBlob();
per_process::Debug(DebugCategory::SEA,
Expand All @@ -266,12 +270,21 @@ void IsSea(const FunctionCallbackInfo<Value>& args) {
void IsVfsEnabled(const FunctionCallbackInfo<Value>& args) {
bool enabled = false;
if (IsSingleExecutable()) {
SeaResource sea_resource = FindSingleExecutableResource();
const SeaResource& sea_resource = FindSingleExecutableResource();
enabled = static_cast<bool>(sea_resource.flags & SeaFlags::kEnableVfs);
}
args.GetReturnValue().Set(enabled);
}

void IsVfsArchiveEnabled(const FunctionCallbackInfo<Value>& args) {
bool enabled = false;
if (IsSingleExecutable()) {
const SeaResource& sea_resource = FindSingleExecutableResource();
enabled = static_cast<bool>(sea_resource.flags & SeaFlags::kVfsArchive);
}
args.GetReturnValue().Set(enabled);
}

void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo<Value>& args) {
bool is_building_sea =
!per_process::cli_options->experimental_sea_config.empty();
Expand All @@ -285,7 +298,7 @@ void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo<Value>& args) {
return;
}

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

SeaResource sea_resource = FindSingleExecutableResource();
const SeaResource& sea_resource = FindSingleExecutableResource();

new_argv.clear();
exec_argv_storage.clear();
Expand Down Expand Up @@ -466,6 +479,16 @@ std::optional<SeaConfig> 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)) {
Expand Down Expand Up @@ -597,6 +620,26 @@ std::optional<SeaConfig> ParseSingleExecutableConfig(
}
}

if (static_cast<bool>(result.flags & SeaFlags::kVfsArchive)) {
if (!static_cast<bool>(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",
Expand Down Expand Up @@ -808,6 +851,27 @@ ExitCode GenerateSingleExecutableBlob(
if (!config.assets.empty() && BuildAssets(config.assets, &assets) != 0) {
return ExitCode::kGenericUserError;
}
if (static_cast<bool>(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<std::string_view, std::string_view> assets_view;
for (auto const& [key, content] : assets) {
assets_view.emplace(key, content);
Expand Down Expand Up @@ -868,7 +932,7 @@ void GetAsset(const FunctionCallbackInfo<Value>& 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;
}
Expand All @@ -890,7 +954,7 @@ void GetAsset(const FunctionCallbackInfo<Value>& args) {
void GetAssetKeys(const FunctionCallbackInfo<Value>& args) {
CHECK_EQ(args.Length(), 0);
Isolate* isolate = args.GetIsolate();
SeaResource sea_resource = FindSingleExecutableResource();
const SeaResource& sea_resource = FindSingleExecutableResource();

Local<Context> context = isolate->GetCurrentContext();
LocalVector<Value> keys(isolate);
Expand All @@ -912,7 +976,7 @@ MaybeLocal<Value> LoadSingleExecutableApplication(
// env->context() is entered.
Environment* env = info.env();
Local<Context> context = env->context();
SeaResource sea = FindSingleExecutableResource();
const SeaResource& sea = FindSingleExecutableResource();

CHECK(!sea.use_snapshot());
// TODO(joyeecheung): this should be an external string. Refactor UnionBytes
Expand All @@ -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,
Expand All @@ -960,7 +1024,7 @@ void Initialize(Local<Object> 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<bool>(sea_resource.flags & SeaFlags::kEnableVfs)) {
Expand All @@ -981,6 +1045,7 @@ void Initialize(Local<Object> target,

SetMethod(context, target, "isSea", IsSea);
SetMethod(context, target, "isVfsEnabled", IsVfsEnabled);
SetMethod(context, target, "isVfsArchiveEnabled", IsVfsArchiveEnabled);
SetMethod(context,
target,
"isExperimentalSeaWarningNeeded",
Expand All @@ -992,6 +1057,7 @@ void Initialize(Local<Object> target,
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(IsSea);
registry->Register(IsVfsEnabled);
registry->Register(IsVfsArchiveEnabled);
registry->Register(IsExperimentalSeaWarningNeeded);
registry->Register(GetAsset);
registry->Register(GetAssetKeys);
Expand Down
Loading
Loading