From ce03c998c4d390dcc57c2818d7238a7c3cf8a162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Ibarra=20Corretg=C3=A9?= Date: Tue, 3 Dec 2024 22:59:11 +0100 Subject: [PATCH] Add ability to create standalone binaries with qjs Ref: https://github.com/quickjs-ng/quickjs/issues/438 Closes: https://github.com/quickjs-ng/quickjs/pull/441 --- .github/workflows/ci.yml | 13 ++++ CMakeLists.txt | 1 + Makefile | 1 + docs/docs/cli.md | 36 ++++++++- gen/standalone.c | Bin 0 -> 14925 bytes qjs.c | 154 +++++++++++++++++++++++++++++++++++++-- standalone.js | 123 +++++++++++++++++++++++++++++++ 7 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 gen/standalone.c create mode 100644 standalone.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aff76ed..a99cf3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,11 @@ jobs: git submodule update --init --checkout --depth 1 time make test262 + - name: test standalone + run: | + ./build/qjs -c examples/hello.js -o hello + ./hello + windows-msvc: runs-on: windows-latest strategy: @@ -184,6 +189,10 @@ jobs: build\${{matrix.buildType}}\qjs.exe examples\test_point.js build\${{matrix.buildType}}\run-test262.exe -c tests.conf build\${{matrix.buildType}}\function_source.exe + - name: test standalone + run: | + build\${{matrix.buildType}}\qjs.exe -c examples\hello.js -o hello.exe + .\hello.exe - name: Set up Visual Studio shell uses: egor-tensin/vs-shell@v2 with: @@ -351,6 +360,10 @@ jobs: - name: test run: | make test + - name: test standalone + run: | + ./build/qjs -c examples/hello.js -o hello.exe + ./hello windows-mingw-shared: runs-on: windows-latest defaults: diff --git a/CMakeLists.txt b/CMakeLists.txt index 715e516..b635b4c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -259,6 +259,7 @@ target_link_libraries(qjsc qjs) add_executable(qjs_exe gen/repl.c + gen/standalone.c qjs.c ) add_qjs_libc_if_needed(qjs_exe) diff --git a/Makefile b/Makefile index 2ec7ee4..baeafd0 100644 --- a/Makefile +++ b/Makefile @@ -70,6 +70,7 @@ clean: codegen: $(QJSC) $(QJSC) -ss -o gen/repl.c -m repl.js + $(QJSC) -ss -o gen/standalone.c -m standalone.js $(QJSC) -e -o gen/function_source.c tests/function_source.js $(QJSC) -e -o gen/hello.c examples/hello.js $(QJSC) -e -o gen/hello_module.c -m examples/hello_module.js diff --git a/docs/docs/cli.md b/docs/docs/cli.md index fff81cb..7a38301 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -18,10 +18,13 @@ usage: qjs [options] [file [args]] -m --module load as ES6 module (default=autodetect) --script load as ES6 script (default=autodetect) -I --include file include an additional file - --std make 'std' and 'os' available to the loaded script + --std make 'std', 'os' and 'bjson' available to script -T --trace trace memory allocation -d --dump dump the memory usage stats -D --dump-flags flags for dumping debug data (see DUMP_* defines) +-c --compile FILE compile the given JS file as a standalone executable +-o --out FILE output file for standalone executables + --exe select the executable to use as the base, defaults to the current one --memory-limit n limit the memory usage to 'n' Kbytes --stack-size n limit the stack size to 'n' Kbytes --unhandled-rejection dump unhandled promise rejections @@ -52,6 +55,37 @@ DUMP_ATOMS 0x40000 /* dump atoms in JS_FreeRuntime */ DUMP_SHAPES 0x80000 /* dump shapes in JS_FreeRuntime */ ``` +### Creating standalone executables + +With the `qjs` CLI it's possible to create standalone executables that will bundle the given JavaScript file +alongside the binary. + +``` +$ qjs -c app.js -o app --exe qjs +``` + +The resulting `app` binary will have the same runtime dependencies as the `qjs` binary. This is acomplished +by compiling the target JavaScript file to bytecode and adding it a copy of the executable, with a little +trailer to help locate it. + +Rather than using the current executable, it's possible to use the `--exe` switch to create standalone +executables for other platforms. + +No JavaScript bundling is performed, the specified JS file cannot depend on other files. A bundler such +as `esbuild` can be used to generate an app bundle which can then be turned into the executable. + +``` +npx esbuild my-app/index.js \ + --bundle \ + --outfile=app.js \ + --external:qjs:* \ + --minify \ + --target=es2023 \ + --platform=neutral \ + --format=esm \ + --main-fields=main,module +``` + ## `qjsc` - The QuickJS JavaScript compiler The `qjsc` executable runs the JavaScript compiler, it can generate bytecode from diff --git a/gen/standalone.c b/gen/standalone.c new file mode 100644 index 0000000000000000000000000000000000000000..d9b48e4280c825d9f755496d58661c7914cf65fe GIT binary patch literal 14925 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 argc) { fprintf(stderr, "qjs: missing expression for -e\n"); - exit(2); + exit(1); } opt_arg = argv[optind++]; } @@ -482,6 +547,39 @@ int main(int argc, char **argv) stack_size = parse_limit(opt_arg); break; } + if (opt == 'c' || !strcmp(longopt, "compile")) { + if (!opt_arg) { + if (optind >= argc) { + fprintf(stderr, "qjs: missing file for -c\n"); + exit(1); + } + opt_arg = argv[optind++]; + } + compile_file = opt_arg; + break; + } + if (opt == 'o' || !strcmp(longopt, "out")) { + if (!opt_arg) { + if (optind >= argc) { + fprintf(stderr, "qjs: missing file for -o\n"); + exit(1); + } + opt_arg = argv[optind++]; + } + out = opt_arg; + break; + } + if (!strcmp(longopt, "exe")) { + if (!opt_arg) { + if (optind >= argc) { + fprintf(stderr, "qjs: missing file for --exe\n"); + exit(1); + } + opt_arg = argv[optind++]; + } + exe = opt_arg; + break; + } if (opt) { fprintf(stderr, "qjs: unknown option '-%c'\n", opt); } else { @@ -491,6 +589,11 @@ int main(int argc, char **argv) } } + if (compile_file && !out) + help(); + +start: + if (trace_memory) { js_trace_malloc_init(&trace_data); rt = JS_NewRuntime2(&trace_mf, &trace_data); @@ -547,11 +650,37 @@ int main(int argc, char **argv) goto fail; } - if (expr) { + if (standalone) { + JSValue ns = load_standalone_module(ctx); + if (JS_IsException(ns)) + goto fail; + JSValue func = JS_GetPropertyStr(ctx, ns, "runStandalone"); + JS_FreeValue(ctx, ns); + if (JS_IsException(func)) + goto fail; + ret = JS_Call(ctx, func, JS_UNDEFINED, 0, NULL); + JS_FreeValue(ctx, func); + } else if (compile_file) { + JSValue ns = load_standalone_module(ctx); + if (JS_IsException(ns)) + goto fail; + JSValue func = JS_GetPropertyStr(ctx, ns, "compileStandalone"); + JS_FreeValue(ctx, ns); + if (JS_IsException(func)) + goto fail; + JSValue args[3]; + args[0] = JS_NewString(ctx, compile_file); + args[1] = JS_NewString(ctx, out); + args[2] = JS_NewString(ctx, exe != NULL ? exe : argv[0]); + ret = JS_Call(ctx, func, JS_UNDEFINED, countof(args), args); + JS_FreeValue(ctx, func); + JS_FreeValue(ctx, args[0]); + JS_FreeValue(ctx, args[1]); + JS_FreeValue(ctx, args[2]); + } else if (expr) { if (eval_buf(ctx, expr, strlen(expr), "", 0)) goto fail; - } else - if (optind >= argc) { + } else if (optind >= argc) { /* interactive mode */ interactive = 1; } else { @@ -563,7 +692,16 @@ int main(int argc, char **argv) if (interactive) { js_std_eval_binary(ctx, qjsc_repl, qjsc_repl_size, 0); } - ret = js_std_loop(ctx); + if (standalone || compile_file) { + if (JS_IsException(ret)) { + ret = JS_GetException(ctx); + } else { + JS_FreeValue(ctx, ret); + ret = js_std_loop(ctx); + } + } else { + ret = js_std_loop(ctx); + } if (!JS_IsUndefined(ret)) { js_std_dump_error1(ctx, ret); JS_FreeValue(ctx, ret); diff --git a/standalone.js b/standalone.js new file mode 100644 index 0000000..75a9d6b --- /dev/null +++ b/standalone.js @@ -0,0 +1,123 @@ +import * as std from "qjs:std"; +import * as os from "qjs:os"; +import * as bjson from "qjs:bjson"; + +// See quickjs.h +const JS_READ_OBJ_BYTECODE = 1 << 0; +const JS_READ_OBJ_REFERENCE = 1 << 3; +const JS_WRITE_OBJ_BYTECODE = 1 << 0; +const JS_WRITE_OBJ_REFERENCE = 1 << 3; +const JS_WRITE_OBJ_STRIP_SOURCE = 1 << 4; + +/** + * Trailer for standalone binaries. When some code gets bundled with the qjs + * executable we add a 12 byte trailer. The first 8 bytes are the magic + * string that helps us understand this is a standalone binary, and the + * remaining 4 are the offset (from the beginning of the binary) where the + * bundled data is located. + * + * The offset is stored as a 32bit little-endian number. + */ +const Trailer = { + Magic: 'quickjs2', + MagicSize: 8, + DataSize: 4, + Size: 12 +}; + +function encodeAscii(txt) { + return new Uint8Array(txt.split('').map(c => c.charCodeAt(0))); +} + +function decodeAscii(buf) { + return Array.from(buf).map(c => String.fromCharCode(c)).join('') +} + +export function compileStandalone(inFile, outFile, targetExe) { + // Step 1: compile the source file to bytecode + const js = std.loadFile(inFile); + + if (!js) { + throw new Error(`failed to open ${inFile}`); + } + + const code = std.evalScript(js, { + compile_only: true, + compile_module: true + }); + const bytecode = new Uint8Array(bjson.write(code, JS_WRITE_OBJ_BYTECODE | JS_WRITE_OBJ_REFERENCE | JS_WRITE_OBJ_STRIP_SOURCE)); + + // Step 2: copy the bytecode to the end of the executable and add a marker. + const exe = std.loadFile(targetExe ?? globalThis.argv0, { binary: true }); + const exeSize = exe.length; + const newBuffer = exe.buffer.transfer(exeSize + bytecode.length + Trailer.Size); + const newExe = new Uint8Array(newBuffer); + + newExe.set(bytecode, exeSize); + newExe.set(encodeAscii(Trailer.Magic), exeSize + bytecode.length); + + const dw = new DataView(newBuffer, exeSize + bytecode.length + Trailer.MagicSize, Trailer.DataSize); + + dw.setUint32(0, exeSize, true /* little-endian */); + + // We use os.open() so we can set the permissions mask. + const newFd = os.open(outFile, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o755); + + if (newFd < 0) { + throw new Error(`failed to create ${outFile}`); + } + if (os.write(newFd, newBuffer, 0, newBuffer.byteLength) < 0) { + os.close(newFd); + throw new Error(`failed to write to output file`); + } + os.close(newFd); +} + +export function runStandalone() { + const file = globalThis.argv0; + const exe = std.open(file, 'rb'); + + if (!exe) { + throw new Error(`failed to open executable: ${file}`); + } + + let r = exe.seek(-Trailer.Size, std.SEEK_END); + if (r < 0) { + throw new Error(`seek error: ${-r}`); + } + + const trailer = new Uint8Array(Trailer.Size); + + exe.read(trailer.buffer, 0, Trailer.Size); + + const magic = new Uint8Array(trailer.buffer, 0, Trailer.MagicSize); + + // Shouldn't happen since qjs.c checks for it. + if (decodeAscii(magic) !== Trailer.Magic) { + exe.close(); + throw new Error('corrupted binary, magic mismatch'); + } + + const dw = new DataView(trailer.buffer, Trailer.MagicSize, Trailer.DataSize); + const offset = dw.getUint32(0, true /* little-endian */); + const bytecode = new Uint8Array(offset - Trailer.Size); + + r = exe.seek(offset, std.SEEK_SET); + if (r < 0) { + exe.close(); + throw new Error(`seek error: ${-r}`); + } + + exe.read(bytecode.buffer, 0, bytecode.length); + if (exe.error()) { + exe.close(); + throw new Error('read error'); + } + exe.close(); + + const code = bjson.read(bytecode.buffer, 0, bytecode.length, JS_READ_OBJ_BYTECODE | JS_READ_OBJ_REFERENCE); + + return std.evalScript(code, { + eval_module: true + }); +}