Skip to content

Commit db0efa3

Browse files
joyeecheungrichardlau
authored andcommitted
sea: support embedding assets
With this patch: Users can now include assets by adding a key-path dictionary to the configuration as the `assets` field. At build time, Node.js would read the assets from the specified paths and bundle them into the preparation blob. In the generated executable, users can retrieve the assets using the `sea.getAsset()` and `sea.getAssetAsBlob()` API. ```json { "main": "/path/to/bundled/script.js", "output": "/path/to/write/the/generated/blob.blob", "assets": { "a.jpg": "/path/to/a.jpg", "b.txt": "/path/to/b.txt" } } ``` The single-executable application can access the assets as follows: ```cjs const { getAsset } = require('node:sea'); // Returns a copy of the data in an ArrayBuffer const image = getAsset('a.jpg'); // Returns a string decoded from the asset as UTF8. const text = getAsset('b.txt', 'utf8'); // Returns a Blob containing the asset. const blob = getAssetAsBlob('a.jpg'); ``` Drive-by: update the documentation to include a section dedicated to the injected main script and refer to it as "injected main script" instead of "injected module" because it's a script, not a module. PR-URL: #50960 Refs: nodejs/single-executable#68 Reviewed-By: Antoine du Hamel <[email protected]> Reviewed-By: Stephen Belanger <[email protected]>
1 parent a58c98e commit db0efa3

12 files changed

+578
-12
lines changed

doc/api/errors.md

+22
Original file line numberDiff line numberDiff line change
@@ -2401,6 +2401,17 @@ error indicates that the idle loop has failed to stop.
24012401
An attempt was made to use operations that can only be used when building
24022402
V8 startup snapshot even though Node.js isn't building one.
24032403

2404+
<a id="ERR_NOT_IN_SINGLE_EXECUTABLE_APPLICATION"></a>
2405+
2406+
### `ERR_NOT_IN_SINGLE_EXECUTABLE_APPLICATION`
2407+
2408+
<!-- YAML
2409+
added: REPLACEME
2410+
-->
2411+
2412+
The operation cannot be performed when it's not in a single-executable
2413+
application.
2414+
24042415
<a id="ERR_NOT_SUPPORTED_IN_SNAPSHOT"></a>
24052416

24062417
### `ERR_NOT_SUPPORTED_IN_SNAPSHOT`
@@ -2547,6 +2558,17 @@ The [`server.close()`][] method was called when a `net.Server` was not
25472558
running. This applies to all instances of `net.Server`, including HTTP, HTTPS,
25482559
and HTTP/2 `Server` instances.
25492560

2561+
<a id="ERR_SINGLE_EXECUTABLE_APPLICATION_ASSET_NOT_FOUND"></a>
2562+
2563+
### `ERR_SINGLE_EXECUTABLE_APPLICATION_ASSET_NOT_FOUND`
2564+
2565+
<!-- YAML
2566+
added: REPLACEME
2567+
-->
2568+
2569+
A key was passed to single executable application APIs to identify an asset,
2570+
but no match could be found.
2571+
25502572
<a id="ERR_SOCKET_ALREADY_BOUND"></a>
25512573

25522574
### `ERR_SOCKET_ALREADY_BOUND`

doc/api/single-executable-applications.md

+100-10
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,52 @@ The configuration currently reads the following top-level fields:
178178
"output": "/path/to/write/the/generated/blob.blob",
179179
"disableExperimentalSEAWarning": true, // Default: false
180180
"useSnapshot": false, // Default: false
181-
"useCodeCache": true // Default: false
181+
"useCodeCache": true, // Default: false
182+
"assets": { // Optional
183+
"a.dat": "/path/to/a.dat",
184+
"b.txt": "/path/to/b.txt"
185+
}
182186
}
183187
```
184188
185189
If the paths are not absolute, Node.js will use the path relative to the
186190
current working directory. The version of the Node.js binary used to produce
187191
the blob must be the same as the one to which the blob will be injected.
188192
193+
### Assets
194+
195+
Users can include assets by adding a key-path dictionary to the configuration
196+
as the `assets` field. At build time, Node.js would read the assets from the
197+
specified paths and bundle them into the preparation blob. In the generated
198+
executable, users can retrieve the assets using the [`sea.getAsset()`][] and
199+
[`sea.getAssetAsBlob()`][] APIs.
200+
201+
```json
202+
{
203+
"main": "/path/to/bundled/script.js",
204+
"output": "/path/to/write/the/generated/blob.blob",
205+
"assets": {
206+
"a.jpg": "/path/to/a.jpg",
207+
"b.txt": "/path/to/b.txt"
208+
}
209+
}
210+
```
211+
212+
The single-executable application can access the assets as follows:
213+
214+
```cjs
215+
const { getAsset } = require('node:sea');
216+
// Returns a copy of the data in an ArrayBuffer.
217+
const image = getAsset('a.jpg');
218+
// Returns a string decoded from the asset as UTF8.
219+
const text = getAsset('b.txt', 'utf8');
220+
// Returns a Blob containing the asset.
221+
const blob = getAssetAsBlob('a.jpg');
222+
```
223+
224+
See documentation of the [`sea.getAsset()`][] and [`sea.getAssetAsBlob()`][]
225+
APIs for more information.
226+
189227
### Startup snapshot support
190228
191229
The `useSnapshot` field can be used to enable startup snapshot support. In this
@@ -229,11 +267,58 @@ execute the script, which would improve the startup performance.
229267
230268
**Note:** `import()` does not work when `useCodeCache` is `true`.
231269
232-
## Notes
270+
## In the injected main script
233271
234-
### `require(id)` in the injected module is not file based
272+
### Single-executable application API
235273
236-
`require()` in the injected module is not the same as the [`require()`][]
274+
The `node:sea` builtin allows interaction with the single-executable application
275+
from the JavaScript main script embedded into the executable.
276+
277+
#### `sea.isSea()`
278+
279+
<!-- YAML
280+
added: REPLACEME
281+
-->
282+
283+
* Returns: {boolean} Whether this script is running inside a single-executable
284+
application.
285+
286+
### `sea.getAsset(key[, encoding])`
287+
288+
<!-- YAML
289+
added: REPLACEME
290+
-->
291+
292+
This method can be used to retrieve the assets configured to be bundled into the
293+
single-executable application at build time.
294+
An error is thrown when no matching asset can be found.
295+
296+
* `key` {string} the key for the asset in the dictionary specified by the
297+
`assets` field in the single-executable application configuration.
298+
* `encoding` {string} If specified, the asset will be decoded as
299+
a string. Any encoding supported by the `TextDecoder` is accepted.
300+
If unspecified, an `ArrayBuffer` containing a copy of the asset would be
301+
returned instead.
302+
* Returns: {string|ArrayBuffer}
303+
304+
### `sea.getAssetAsBlob(key[, options])`
305+
306+
<!-- YAML
307+
added: REPLACEME
308+
-->
309+
310+
Similar to [`sea.getAsset()`][], but returns the result in a [`Blob`][].
311+
An error is thrown when no matching asset can be found.
312+
313+
* `key` {string} the key for the asset in the dictionary specified by the
314+
`assets` field in the single-executable application configuration.
315+
* `options` {Object}
316+
* `type` {string} An optional mime type for the blob.
317+
* Returns: {Blob}
318+
319+
### `require(id)` in the injected main script is not file based
320+
321+
`require()` in the injected main script is not the same as the [`require()`][]
237322
available to modules that are not injected. It also does not have any of the
238323
properties that non-injected [`require()`][] has except [`require.main`][]. It
239324
can only be used to load built-in modules. Attempting to load a module that can
@@ -250,15 +335,17 @@ const { createRequire } = require('node:module');
250335
require = createRequire(__filename);
251336
```
252337
253-
### `__filename` and `module.filename` in the injected module
338+
### `__filename` and `module.filename` in the injected main script
254339
255-
The values of `__filename` and `module.filename` in the injected module are
256-
equal to [`process.execPath`][].
340+
The values of `__filename` and `module.filename` in the injected main script
341+
are equal to [`process.execPath`][].
257342
258-
### `__dirname` in the injected module
343+
### `__dirname` in the injected main script
259344
260-
The value of `__dirname` in the injected module is equal to the directory name
261-
of [`process.execPath`][].
345+
The value of `__dirname` in the injected main script is equal to the directory
346+
name of [`process.execPath`][].
347+
348+
## Notes
262349
263350
### Single executable application creation process
264351
@@ -298,9 +385,12 @@ to help us document them.
298385
[Mach-O]: https://en.wikipedia.org/wiki/Mach-O
299386
[PE]: https://en.wikipedia.org/wiki/Portable_Executable
300387
[Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/
388+
[`Blob`]: https://developer.mozilla.org/en-US/docs/Web/API/Blob
301389
[`process.execPath`]: process.md#processexecpath
302390
[`require()`]: modules.md#requireid
303391
[`require.main`]: modules.md#accessing-the-main-module
392+
[`sea.getAsset()`]: #seagetassetkey-encoding
393+
[`sea.getAssetAsBlob()`]: #seagetassetasblobkey-options
304394
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
305395
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
306396
[documentation about startup snapshot support in Node.js]: cli.md#--build-snapshot

lib/internal/bootstrap/realm.js

+1
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ const legacyWrapperList = new SafeSet([
128128
// beginning with "internal/".
129129
// Modules that can only be imported via the node: scheme.
130130
const schemelessBlockList = new SafeSet([
131+
'sea',
131132
'test',
132133
'test/reporters',
133134
]);

lib/internal/errors.js

+4
Original file line numberDiff line numberDiff line change
@@ -1637,6 +1637,8 @@ E('ERR_NETWORK_IMPORT_DISALLOWED',
16371637
"import of '%s' by %s is not supported: %s", Error);
16381638
E('ERR_NOT_BUILDING_SNAPSHOT',
16391639
'Operation cannot be invoked when not building startup snapshot', Error);
1640+
E('ERR_NOT_IN_SINGLE_EXECUTABLE_APPLICATION',
1641+
'Operation cannot be invoked when not in a single-executable application', Error);
16401642
E('ERR_NOT_SUPPORTED_IN_SNAPSHOT', '%s is not supported in startup snapshot', Error);
16411643
E('ERR_NO_CRYPTO',
16421644
'Node.js is not compiled with OpenSSL crypto support', Error);
@@ -1720,6 +1722,8 @@ E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
17201722
E('ERR_SERVER_ALREADY_LISTEN',
17211723
'Listen method has been called more than once without closing.', Error);
17221724
E('ERR_SERVER_NOT_RUNNING', 'Server is not running.', Error);
1725+
E('ERR_SINGLE_EXECUTABLE_APPLICATION_ASSET_NOT_FOUND',
1726+
'Cannot find asset %s for the single executable application', Error);
17231727
E('ERR_SOCKET_ALREADY_BOUND', 'Socket is already bound', Error);
17241728
E('ERR_SOCKET_BAD_BUFFER_SIZE',
17251729
'Buffer size must be a positive integer', TypeError);

lib/sea.js

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use strict';
2+
const {
3+
ArrayBufferPrototypeSlice,
4+
} = primordials;
5+
6+
const { isSea, getAsset: getAssetInternal } = internalBinding('sea');
7+
const { TextDecoder } = require('internal/encoding');
8+
const { validateString } = require('internal/validators');
9+
const {
10+
ERR_NOT_IN_SINGLE_EXECUTABLE_APPLICATION,
11+
ERR_SINGLE_EXECUTABLE_APPLICATION_ASSET_NOT_FOUND,
12+
} = require('internal/errors').codes;
13+
const { Blob } = require('internal/blob');
14+
15+
/**
16+
* Look for the asset in the injected SEA blob using the key. If
17+
* no matching asset is found an error is thrown. The returned
18+
* ArrayBuffer should not be mutated or otherwise the process
19+
* can crash due to access violation.
20+
* @param {string} key
21+
* @returns {ArrayBuffer}
22+
*/
23+
function getRawAsset(key) {
24+
validateString(key, 'key');
25+
26+
if (!isSea()) {
27+
throw new ERR_NOT_IN_SINGLE_EXECUTABLE_APPLICATION();
28+
}
29+
30+
const asset = getAssetInternal(key);
31+
if (asset === undefined) {
32+
throw new ERR_SINGLE_EXECUTABLE_APPLICATION_ASSET_NOT_FOUND(key);
33+
}
34+
return asset;
35+
}
36+
37+
/**
38+
* Look for the asset in the injected SEA blob using the key. If the
39+
* encoding is specified, return a string decoded from it by TextDecoder,
40+
* otherwise return *a copy* of the original data in an ArrayBuffer. If
41+
* no matching asset is found an error is thrown.
42+
* @param {string} key
43+
* @param {string|undefined} encoding
44+
* @returns {string|ArrayBuffer}
45+
*/
46+
function getAsset(key, encoding) {
47+
if (encoding !== undefined) {
48+
validateString(encoding, 'encoding');
49+
}
50+
const asset = getRawAsset(key);
51+
if (encoding === undefined) {
52+
return ArrayBufferPrototypeSlice(asset);
53+
}
54+
const decoder = new TextDecoder(encoding);
55+
return decoder.decode(asset);
56+
}
57+
58+
/**
59+
* Look for the asset in the injected SEA blob using the key. If
60+
* no matching asset is found an error is thrown. The data is returned
61+
* in a Blob. If no matching asset is found an error is thrown.
62+
* @param {string} key
63+
* @param {ConstructorParameters<Blob>[1]} [options]
64+
* @returns {Blob}
65+
*/
66+
function getAssetAsBlob(key, options) {
67+
const asset = getRawAsset(key);
68+
return new Blob([asset], options);
69+
}
70+
71+
module.exports = {
72+
isSea,
73+
getAsset,
74+
getAssetAsBlob,
75+
};

src/json_parser.cc

+48
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include "util-inl.h"
55

66
namespace node {
7+
using v8::Array;
78
using v8::Context;
89
using v8::Isolate;
910
using v8::Local;
@@ -101,4 +102,51 @@ std::optional<bool> JSONParser::GetTopLevelBoolField(std::string_view field) {
101102
return value->BooleanValue(isolate);
102103
}
103104

105+
std::optional<JSONParser::StringDict> JSONParser::GetTopLevelStringDict(
106+
std::string_view field) {
107+
Isolate* isolate = isolate_.get();
108+
v8::HandleScope handle_scope(isolate);
109+
Local<Context> context = context_.Get(isolate);
110+
Local<Object> content_object = content_.Get(isolate);
111+
Local<Value> value;
112+
bool has_field;
113+
// It's not a real script, so don't print the source line.
114+
errors::PrinterTryCatch bootstrapCatch(
115+
isolate, errors::PrinterTryCatch::kDontPrintSourceLine);
116+
Local<Value> field_local;
117+
if (!ToV8Value(context, field, isolate).ToLocal(&field_local)) {
118+
return std::nullopt;
119+
}
120+
if (!content_object->Has(context, field_local).To(&has_field)) {
121+
return std::nullopt;
122+
}
123+
if (!has_field) {
124+
return StringDict();
125+
}
126+
if (!content_object->Get(context, field_local).ToLocal(&value) ||
127+
!value->IsObject()) {
128+
return std::nullopt;
129+
}
130+
Local<Object> dict = value.As<Object>();
131+
Local<Array> keys;
132+
if (!dict->GetOwnPropertyNames(context).ToLocal(&keys)) {
133+
return std::nullopt;
134+
}
135+
std::unordered_map<std::string, std::string> result;
136+
uint32_t length = keys->Length();
137+
for (uint32_t i = 0; i < length; ++i) {
138+
Local<Value> key;
139+
Local<Value> value;
140+
if (!keys->Get(context, i).ToLocal(&key) || !key->IsString())
141+
return StringDict();
142+
if (!dict->Get(context, key).ToLocal(&value) || !value->IsString())
143+
return StringDict();
144+
145+
Utf8Value key_utf8(isolate, key);
146+
Utf8Value value_utf8(isolate, value);
147+
result.emplace(*key_utf8, *value_utf8);
148+
}
149+
return result;
150+
}
151+
104152
} // namespace node

src/json_parser.h

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <memory>
77
#include <optional>
88
#include <string>
9+
#include <unordered_map>
910
#include "util.h"
1011
#include "v8.h"
1112

@@ -15,11 +16,13 @@ namespace node {
1516
// complicates things.
1617
class JSONParser {
1718
public:
19+
using StringDict = std::unordered_map<std::string, std::string>;
1820
JSONParser();
1921
~JSONParser() = default;
2022
bool Parse(const std::string& content);
2123
std::optional<std::string> GetTopLevelStringField(std::string_view field);
2224
std::optional<bool> GetTopLevelBoolField(std::string_view field);
25+
std::optional<StringDict> GetTopLevelStringDict(std::string_view field);
2326

2427
private:
2528
// We might want a lighter-weight JSON parser for this use case. But for now

0 commit comments

Comments
 (0)