Skip to content

Commit 991755e

Browse files
authored
Merge pull request #811 from PassiveLogic/kr/package-to-js-uwasi
PackageToJS: Add selectable UWASI runtime
2 parents 62382f7 + a5cbe7a commit 991755e

20 files changed

Lines changed: 769 additions & 93 deletions

Plugins/PackageToJS/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ PackageToJS is a command plugin for Swift Package Manager that simplifies the pr
1111
- Build WebAssembly file and generate JavaScript wrappers
1212
- Test driver for Swift Testing and XCTest
1313
- Generated JS files can be consumed by JS bundler tools like Vite
14+
- Select `@bjorn3/browser_wasi_shim` or `uwasi` for generated WASI packages
15+
16+
## WASI Runtime
17+
18+
PackageToJS uses `@bjorn3/browser_wasi_shim` by default. Pass `--wasi-runtime uwasi`
19+
to generate browser and Node platform wrappers backed by `uwasi` instead:
20+
21+
```bash
22+
swift package --swift-sdk wasm32-unknown-wasi js --wasi-runtime uwasi
23+
```
24+
25+
The generated browser main-thread setup rejects blocking WASI waits. Run guests that
26+
require `poll_oneoff` clock waits in a worker. Node and browser workers use `uwasi`'s
27+
blocking implementation.
1428

1529
## Requirements
1630

@@ -42,4 +56,3 @@ Please define the following environment variables when you want to run E2E tests
4256

4357
- `SWIFT_SDK_ID`: Specifies the Swift SDK identifier to use
4458
- `SWIFT_BIN_PATH`: Specifies the `bin` path to the Swift toolchain to use
45-

Plugins/PackageToJS/Sources/PackageToJS.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ struct PackageToJS {
1010
case node
1111
}
1212

13+
enum WASIRuntime: String, CaseIterable {
14+
case browserWASIShim = "browser-wasi-shim"
15+
case uwasi
16+
}
17+
1318
/// Path to the output directory
1419
var outputPath: String?
1520
/// The build configuration to use (default: debug)
@@ -18,6 +23,8 @@ struct PackageToJS {
1823
var packageName: String?
1924
/// Target platform for the generated JavaScript (default: browser)
2025
var defaultPlatform: Platform = .browser
26+
/// WASI runtime used by generated JavaScript (default: browser-wasi-shim)
27+
var wasiRuntime: WASIRuntime = .browserWASIShim
2128
/// Whether to explain the build plan (default: false)
2229
var explain: Bool = false
2330
/// Whether to print verbose output
@@ -625,7 +632,7 @@ struct PackagingPlanner {
625632
}
626633

627634
// Copy the template files
628-
for (file, output) in [
635+
var templateFiles = [
629636
("Plugins/PackageToJS/Templates/index.js", "index.js"),
630637
("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"),
631638
("Plugins/PackageToJS/Templates/instantiate.js", "instantiate.js"),
@@ -637,7 +644,13 @@ struct PackagingPlanner {
637644
("Plugins/PackageToJS/Templates/platforms/node.d.ts", "platforms/node.d.ts"),
638645
("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"),
639646
("Sources/JavaScriptKit/Runtime/index.d.ts", "runtime.d.ts"),
640-
] {
647+
]
648+
if options.wasiRuntime == .uwasi {
649+
templateFiles.append(
650+
("Plugins/PackageToJS/Templates/platforms/uwasi.js", "platforms/uwasi.js")
651+
)
652+
}
653+
for (file, output) in templateFiles {
641654
packageInputs.append(
642655
planCopyTemplateFile(
643656
make: &make,
@@ -943,6 +956,7 @@ struct PackagingPlanner {
943956
// this task instead, so a change in them still re-runs the preprocessing.
944957
let staticConditions: [String: Bool] = [
945958
"USE_WASI_CDN": options.useCDN,
959+
"USE_UWASI": options.wasiRuntime == .uwasi,
946960
"HAS_BRIDGE": skeletons.count > 0,
947961
"HAS_IMPORTS": skeletons.count > 0,
948962
"TARGET_DEFAULT_PLATFORM_NODE": options.defaultPlatform == .node,

Plugins/PackageToJS/Sources/PackageToJSPlugin.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,19 @@ extension ArgumentExtractor {
599599
}
600600
return platform
601601
}
602+
603+
mutating func extractWASIRuntimeOption(named name: String) throws -> PackageToJS.PackageOptions.WASIRuntime {
604+
guard let stringValue = self.extractOption(named: name).last else {
605+
return .browserWASIShim
606+
}
607+
608+
guard let runtime = PackageToJS.PackageOptions.WASIRuntime(rawValue: stringValue) else {
609+
throw PackageToJSError(
610+
"Invalid WASI runtime: \(stringValue), expected one of \(PackageToJS.PackageOptions.WASIRuntime.allCases.map(\.rawValue).joined(separator: ", "))"
611+
)
612+
}
613+
return runtime
614+
}
602615
}
603616

604617
extension PackageToJS.PackageOptions {
@@ -608,6 +621,7 @@ extension PackageToJS.PackageOptions {
608621
(extractor.extractOption(named: "configuration") + extractor.extractSingleDashOption(named: "c")).last
609622
let packageName = extractor.extractOption(named: "package-name").last
610623
let defaultPlatform = try extractor.extractPlatformOption(named: "default-platform")
624+
let wasiRuntime = try extractor.extractWASIRuntimeOption(named: "wasi-runtime")
611625
let explain = extractor.extractFlag(named: "explain")
612626
let useCDN = extractor.extractFlag(named: "use-cdn")
613627
let verbose = extractor.extractFlag(named: "verbose")
@@ -617,6 +631,7 @@ extension PackageToJS.PackageOptions {
617631
configuration: configuration,
618632
packageName: packageName,
619633
defaultPlatform: defaultPlatform,
634+
wasiRuntime: wasiRuntime,
620635
explain: explain != 0,
621636
verbose: verbose != 0,
622637
useCDN: useCDN != 0,
@@ -629,7 +644,8 @@ extension PackageToJS.PackageOptions {
629644
--output <path> Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package)
630645
-c, --configuration <name> The build configuration to use (values: debug, release; default: debug)
631646
--package-name <name> Name of the package (default: lowercased Package.swift name)
632-
--platform <name> Target platform for generated JavaScript (values: \(PackageToJS.PackageOptions.Platform.allCases.map(\.rawValue).joined(separator: ", ")); default: \(PackageToJS.PackageOptions.Platform.browser))
647+
--default-platform <name> Target platform for generated JavaScript (values: \(PackageToJS.PackageOptions.Platform.allCases.map(\.rawValue).joined(separator: ", ")); default: \(PackageToJS.PackageOptions.Platform.browser))
648+
--wasi-runtime <name> WASI runtime for generated JavaScript (values: \(PackageToJS.PackageOptions.WASIRuntime.allCases.map(\.rawValue).joined(separator: ", ")); default: \(PackageToJS.PackageOptions.WASIRuntime.browserWASIShim.rawValue))
633649
--use-cdn Whether to use CDN for dependency packages
634650
--enable-code-coverage Whether to enable code coverage collection
635651
--explain Whether to explain the build plan

Plugins/PackageToJS/Templates/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"./wasm": "./@PACKAGE_TO_JS_MODULE_PATH@"
99
},
1010
"dependencies": {
11+
/* #if USE_UWASI */
12+
"uwasi": "1.5.2"
13+
/* #else */
1114
"@bjorn3/browser_wasi_shim": "0.3.0"
15+
/* #endif */
1216
}
1317
}

Plugins/PackageToJS/Templates/platforms/browser.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
// @ts-check
22
import { MODULE_PATH /* #if USE_SHARED_MEMORY */, MEMORY_TYPE /* #endif */} from "../instantiate.js"
33
/* #if IS_WASI */
4+
/* #if USE_UWASI */
5+
/* #if USE_WASI_CDN */
6+
// @ts-ignore
7+
import { WASI, MemoryFileSystem, useAll, lineBuffered } from 'https://cdn.jsdelivr.net/npm/uwasi@1.5.2/+esm';
8+
/* #else */
9+
import { WASI, MemoryFileSystem, useAll, lineBuffered } from 'uwasi';
10+
/* #endif */
11+
import { browserMainThreadSleep, createUwasi } from './uwasi.js';
12+
/* #else */
413
/* #if USE_WASI_CDN */
514
// @ts-ignore
615
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.4.1/+esm';
@@ -9,6 +18,7 @@ import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://c
918
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim';
1019
/* #endif */
1120
/* #endif */
21+
/* #endif */
1222

1323
/* #if USE_SHARED_MEMORY */
1424
export async function defaultBrowserThreadSetup() {
@@ -27,6 +37,12 @@ export async function defaultBrowserThreadSetup() {
2737
}
2838

2939
/* #if IS_WASI */
40+
/* #if USE_UWASI */
41+
const { wasi } = createUwasi(
42+
{ WASI, MemoryFileSystem, useAll, lineBuffered },
43+
{ modulePath: MODULE_PATH }
44+
)
45+
/* #else */
3046
const wasi = new WASI(/* args */[MODULE_PATH], /* env */[], /* fd */[
3147
new OpenFile(new File([])), // stdin
3248
ConsoleStdout.lineBuffered((stdout) => {
@@ -37,14 +53,19 @@ export async function defaultBrowserThreadSetup() {
3753
}),
3854
new PreopenDirectory("/", new Map()),
3955
], { debug: false })
56+
/* #endif */
4057
/* #endif */
4158
return {
4259
/* #if IS_WASI */
60+
/* #if USE_UWASI */
61+
wasi,
62+
/* #else */
4363
wasi: Object.assign(wasi, {
4464
setInstance(instance) {
4565
wasi.inst = instance;
4666
}
4767
}),
68+
/* #endif */
4869
/* #endif */
4970
threadChannel,
5071
}
@@ -105,6 +126,18 @@ export async function defaultBrowserSetup(options) {
105126
const args = options.args ?? []
106127
const onStdoutLine = options.onStdoutLine ?? ((line) => console.log(line))
107128
const onStderrLine = options.onStderrLine ?? ((line) => console.error(line))
129+
/* #if USE_UWASI */
130+
const { wasi } = createUwasi(
131+
{ WASI, MemoryFileSystem, useAll, lineBuffered },
132+
{
133+
modulePath: MODULE_PATH,
134+
args,
135+
onStdoutLine,
136+
onStderrLine,
137+
sleep: browserMainThreadSleep(),
138+
}
139+
)
140+
/* #else */
108141
const wasi = new WASI(/* args */[MODULE_PATH, ...args], /* env */[], /* fd */[
109142
new OpenFile(new File([])), // stdin
110143
ConsoleStdout.lineBuffered((stdout) => {
@@ -116,6 +149,7 @@ export async function defaultBrowserSetup(options) {
116149
new PreopenDirectory("/", new Map()),
117150
], { debug: false })
118151
/* #endif */
152+
/* #endif */
119153
/* #if USE_SHARED_MEMORY */
120154
const memory = new WebAssembly.Memory(MEMORY_TYPE);
121155
const threadChannel = new DefaultBrowserThreadRegistry(options.spawnWorker || createDefaultWorkerFactory())
@@ -127,12 +161,16 @@ export async function defaultBrowserSetup(options) {
127161
getImports() { return options.getImports() },
128162
/* #endif */
129163
/* #if IS_WASI */
164+
/* #if USE_UWASI */
165+
wasi,
166+
/* #else */
130167
wasi: Object.assign(wasi, {
131168
setInstance(instance) {
132169
wasi.inst = instance;
133170
}
134171
}),
135172
/* #endif */
173+
/* #endif */
136174
/* #if USE_SHARED_MEMORY */
137175
memory, threadChannel,
138176
/* #endif */

Plugins/PackageToJS/Templates/platforms/node.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import { fileURLToPath } from "node:url";
33
import { Worker, parentPort } from "node:worker_threads";
44
import { MODULE_PATH /* #if USE_SHARED_MEMORY */, MEMORY_TYPE /* #endif */} from "../instantiate.js"
55
/* #if IS_WASI */
6+
/* #if USE_UWASI */
7+
import { WASI, MemoryFileSystem, useAll, lineBuffered } from 'uwasi';
8+
import { createUwasi } from './uwasi.js';
9+
/* #else */
610
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory, Directory, Inode } from '@bjorn3/browser_wasi_shim';
711
/* #endif */
12+
/* #endif */
813

914
/* #if USE_SHARED_MEMORY */
1015
export async function defaultNodeThreadSetup() {
@@ -22,6 +27,12 @@ export async function defaultNodeThreadSetup() {
2227
}
2328
}
2429

30+
/* #if USE_UWASI */
31+
const { wasi } = createUwasi(
32+
{ WASI, MemoryFileSystem, useAll, lineBuffered },
33+
{ modulePath: MODULE_PATH }
34+
)
35+
/* #else */
2536
const wasi = new WASI(/* args */[MODULE_PATH], /* env */[], /* fd */[
2637
new OpenFile(new File([])), // stdin
2738
ConsoleStdout.lineBuffered((stdout) => {
@@ -32,13 +43,18 @@ export async function defaultNodeThreadSetup() {
3243
}),
3344
new PreopenDirectory("/", new Map()),
3445
], { debug: false })
46+
/* #endif */
3547

3648
return {
49+
/* #if USE_UWASI */
50+
wasi,
51+
/* #else */
3752
wasi: Object.assign(wasi, {
3853
setInstance(instance) {
3954
wasi.inst = instance;
4055
}
4156
}),
57+
/* #endif */
4258
threadChannel,
4359
}
4460
}
@@ -119,6 +135,16 @@ export async function defaultNodeSetup(options = {}) {
119135
const { readFile } = await import("node:fs/promises")
120136

121137
const args = options.args ?? process.argv.slice(2)
138+
/* #if USE_UWASI */
139+
const { wasi } = createUwasi(
140+
{ WASI, MemoryFileSystem, useAll, lineBuffered },
141+
{
142+
modulePath: MODULE_PATH,
143+
args,
144+
withExtractFile: true,
145+
}
146+
)
147+
/* #else */
122148
const rootFs = new Map();
123149
const wasi = new WASI(/* args */[MODULE_PATH, ...args], /* env */[], /* fd */[
124150
new OpenFile(new File([])), // stdin
@@ -130,6 +156,7 @@ export async function defaultNodeSetup(options = {}) {
130156
}),
131157
new PreopenDirectory("/", rootFs),
132158
], { debug: false })
159+
/* #endif */
133160
const pkgDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
134161
const module = await WebAssembly.compile(new Uint8Array(await readFile(path.join(pkgDir, MODULE_PATH))))
135162
/* #if USE_SHARED_MEMORY */
@@ -143,6 +170,9 @@ export async function defaultNodeSetup(options = {}) {
143170
getImports() { return {} },
144171
/* #endif */
145172
/* #if IS_WASI */
173+
/* #if USE_UWASI */
174+
wasi,
175+
/* #else */
146176
wasi: Object.assign(wasi, {
147177
setInstance(instance) {
148178
wasi.inst = instance;
@@ -184,6 +214,7 @@ export async function defaultNodeSetup(options = {}) {
184214
return undefined;
185215
}
186216
}),
217+
/* #endif */
187218
addToCoreImports(importObject) {
188219
importObject["wasi_snapshot_preview1"]["proc_exit"] = (code) => {
189220
if (options.onExit) {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// @ts-check
2+
3+
/**
4+
* Refuse blocking waits on the browser main thread, where UWASI would otherwise
5+
* busy-wait. Workers and non-browser hosts can use UWASI's blocking default.
6+
*
7+
* @returns {((milliseconds: number) => void) | undefined}
8+
*/
9+
export function browserMainThreadSleep() {
10+
if (typeof document === "undefined") return undefined;
11+
return (milliseconds) => {
12+
throw new Error(
13+
`A WASI poll_oneoff wait of ${milliseconds}ms cannot block the browser main thread. ` +
14+
"Run the guest in a worker to support blocking waits.",
15+
);
16+
};
17+
}
18+
19+
/**
20+
* @param {{ WASI: any, MemoryFileSystem: any, useAll: any, lineBuffered: any }} runtime
21+
* @param {{
22+
* modulePath: string,
23+
* args?: string[],
24+
* onStdoutLine?: (line: string) => void,
25+
* onStderrLine?: (line: string) => void,
26+
* sleep?: (milliseconds: number) => void,
27+
* withExtractFile?: boolean,
28+
* }} options
29+
*/
30+
export function createUwasi(runtime, options) {
31+
const args = options.args ?? [];
32+
const onStdoutLine = options.onStdoutLine ?? ((line) => console.log(line));
33+
const onStderrLine =
34+
options.onStderrLine ?? ((line) => console.error(line));
35+
const fileSystem = new runtime.MemoryFileSystem({ "/": "/" });
36+
const stdout = runtime.lineBuffered(onStdoutLine);
37+
const stderr = runtime.lineBuffered(onStderrLine);
38+
const wasi = new runtime.WASI({
39+
args: [options.modulePath, ...args],
40+
env: {},
41+
features: [
42+
runtime.useAll({
43+
withFileSystem: fileSystem,
44+
withStdio: {
45+
stdin: () => "",
46+
stdout,
47+
stderr,
48+
outputBuffers: true,
49+
},
50+
sleep: options.sleep,
51+
}),
52+
],
53+
});
54+
55+
if (options.withExtractFile) {
56+
Object.assign(wasi, {
57+
extractFile(filePath) {
58+
const node = fileSystem.lookup(filePath);
59+
return node && node.type === "file" ? node.content : undefined;
60+
},
61+
});
62+
}
63+
64+
return { wasi, fileSystem };
65+
}

0 commit comments

Comments
 (0)