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
15 changes: 14 additions & 1 deletion Plugins/PackageToJS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ PackageToJS is a command plugin for Swift Package Manager that simplifies the pr
- Build WebAssembly file and generate JavaScript wrappers
- Test driver for Swift Testing and XCTest
- Generated JS files can be consumed by JS bundler tools like Vite
- Select `@bjorn3/browser_wasi_shim` or `uwasi` for generated WASI packages

## WASI Runtime

PackageToJS uses `@bjorn3/browser_wasi_shim` by default. Pass `--wasi-runtime uwasi`
to generate browser and Node platform wrappers backed by `uwasi` instead:

```bash
swift package --swift-sdk wasm32-unknown-wasi js --wasi-runtime uwasi
```

The generated browser main-thread setup rejects blocking WASI waits. Run guests that
require `poll_oneoff` clock waits in a worker. Node and browser workers use `uwasi`'s
blocking implementation.

## Requirements

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

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

18 changes: 16 additions & 2 deletions Plugins/PackageToJS/Sources/PackageToJS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ struct PackageToJS {
case node
}

enum WASIRuntime: String, CaseIterable {
case browserWASIShim = "browser-wasi-shim"
case uwasi
}

/// Path to the output directory
var outputPath: String?
/// The build configuration to use (default: debug)
Expand All @@ -18,6 +23,8 @@ struct PackageToJS {
var packageName: String?
/// Target platform for the generated JavaScript (default: browser)
var defaultPlatform: Platform = .browser
/// WASI runtime used by generated JavaScript (default: browser-wasi-shim)
var wasiRuntime: WASIRuntime = .browserWASIShim
/// Whether to explain the build plan (default: false)
var explain: Bool = false
/// Whether to print verbose output
Expand Down Expand Up @@ -625,7 +632,7 @@ struct PackagingPlanner {
}

// Copy the template files
for (file, output) in [
var templateFiles = [
("Plugins/PackageToJS/Templates/index.js", "index.js"),
("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"),
("Plugins/PackageToJS/Templates/instantiate.js", "instantiate.js"),
Expand All @@ -637,7 +644,13 @@ struct PackagingPlanner {
("Plugins/PackageToJS/Templates/platforms/node.d.ts", "platforms/node.d.ts"),
("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"),
("Sources/JavaScriptKit/Runtime/index.d.ts", "runtime.d.ts"),
] {
]
if options.wasiRuntime == .uwasi {
templateFiles.append(
("Plugins/PackageToJS/Templates/platforms/uwasi.js", "platforms/uwasi.js")
)
}
for (file, output) in templateFiles {
packageInputs.append(
planCopyTemplateFile(
make: &make,
Expand Down Expand Up @@ -943,6 +956,7 @@ struct PackagingPlanner {
// this task instead, so a change in them still re-runs the preprocessing.
let staticConditions: [String: Bool] = [
"USE_WASI_CDN": options.useCDN,
"USE_UWASI": options.wasiRuntime == .uwasi,
"HAS_BRIDGE": skeletons.count > 0,
"HAS_IMPORTS": skeletons.count > 0,
"TARGET_DEFAULT_PLATFORM_NODE": options.defaultPlatform == .node,
Expand Down
18 changes: 17 additions & 1 deletion Plugins/PackageToJS/Sources/PackageToJSPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,19 @@ extension ArgumentExtractor {
}
return platform
}

mutating func extractWASIRuntimeOption(named name: String) throws -> PackageToJS.PackageOptions.WASIRuntime {
guard let stringValue = self.extractOption(named: name).last else {
return .browserWASIShim
}

guard let runtime = PackageToJS.PackageOptions.WASIRuntime(rawValue: stringValue) else {
throw PackageToJSError(
"Invalid WASI runtime: \(stringValue), expected one of \(PackageToJS.PackageOptions.WASIRuntime.allCases.map(\.rawValue).joined(separator: ", "))"
)
}
return runtime
}
}

extension PackageToJS.PackageOptions {
Expand All @@ -608,6 +621,7 @@ extension PackageToJS.PackageOptions {
(extractor.extractOption(named: "configuration") + extractor.extractSingleDashOption(named: "c")).last
let packageName = extractor.extractOption(named: "package-name").last
let defaultPlatform = try extractor.extractPlatformOption(named: "default-platform")
let wasiRuntime = try extractor.extractWASIRuntimeOption(named: "wasi-runtime")
let explain = extractor.extractFlag(named: "explain")
let useCDN = extractor.extractFlag(named: "use-cdn")
let verbose = extractor.extractFlag(named: "verbose")
Expand All @@ -617,6 +631,7 @@ extension PackageToJS.PackageOptions {
configuration: configuration,
packageName: packageName,
defaultPlatform: defaultPlatform,
wasiRuntime: wasiRuntime,
explain: explain != 0,
verbose: verbose != 0,
useCDN: useCDN != 0,
Expand All @@ -629,7 +644,8 @@ extension PackageToJS.PackageOptions {
--output <path> Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package)
-c, --configuration <name> The build configuration to use (values: debug, release; default: debug)
--package-name <name> Name of the package (default: lowercased Package.swift name)
--platform <name> Target platform for generated JavaScript (values: \(PackageToJS.PackageOptions.Platform.allCases.map(\.rawValue).joined(separator: ", ")); default: \(PackageToJS.PackageOptions.Platform.browser))
--default-platform <name> Target platform for generated JavaScript (values: \(PackageToJS.PackageOptions.Platform.allCases.map(\.rawValue).joined(separator: ", ")); default: \(PackageToJS.PackageOptions.Platform.browser))
--wasi-runtime <name> WASI runtime for generated JavaScript (values: \(PackageToJS.PackageOptions.WASIRuntime.allCases.map(\.rawValue).joined(separator: ", ")); default: \(PackageToJS.PackageOptions.WASIRuntime.browserWASIShim.rawValue))
--use-cdn Whether to use CDN for dependency packages
--enable-code-coverage Whether to enable code coverage collection
--explain Whether to explain the build plan
Expand Down
4 changes: 4 additions & 0 deletions Plugins/PackageToJS/Templates/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"./wasm": "./@PACKAGE_TO_JS_MODULE_PATH@"
},
"dependencies": {
/* #if USE_UWASI */
"uwasi": "1.6.0"
/* #else */
"@bjorn3/browser_wasi_shim": "0.3.0"
/* #endif */
}
}
38 changes: 38 additions & 0 deletions Plugins/PackageToJS/Templates/platforms/browser.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
// @ts-check
import { MODULE_PATH /* #if USE_SHARED_MEMORY */, MEMORY_TYPE /* #endif */} from "../instantiate.js"
/* #if IS_WASI */
/* #if USE_UWASI */
/* #if USE_WASI_CDN */
// @ts-ignore
import { WASI, MemoryFileSystem, useAll, lineBuffered } from 'https://cdn.jsdelivr.net/npm/uwasi@1.6.0/+esm';
/* #else */
import { WASI, MemoryFileSystem, useAll, lineBuffered } from 'uwasi';
/* #endif */
import { browserMainThreadSleep, createUwasi } from './uwasi.js';
/* #else */
/* #if USE_WASI_CDN */
// @ts-ignore
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.4.1/+esm';
Expand All @@ -9,6 +18,7 @@ import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://c
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim';
/* #endif */
/* #endif */
/* #endif */

/* #if USE_SHARED_MEMORY */
export async function defaultBrowserThreadSetup() {
Expand All @@ -27,6 +37,12 @@ export async function defaultBrowserThreadSetup() {
}

/* #if IS_WASI */
/* #if USE_UWASI */
const { wasi } = createUwasi(
{ WASI, MemoryFileSystem, useAll, lineBuffered },
{ modulePath: MODULE_PATH }
)
/* #else */
const wasi = new WASI(/* args */[MODULE_PATH], /* env */[], /* fd */[
new OpenFile(new File([])), // stdin
ConsoleStdout.lineBuffered((stdout) => {
Expand All @@ -37,14 +53,19 @@ export async function defaultBrowserThreadSetup() {
}),
new PreopenDirectory("/", new Map()),
], { debug: false })
/* #endif */
/* #endif */
return {
/* #if IS_WASI */
/* #if USE_UWASI */
wasi,
/* #else */
wasi: Object.assign(wasi, {
setInstance(instance) {
wasi.inst = instance;
}
}),
/* #endif */
/* #endif */
threadChannel,
}
Expand Down Expand Up @@ -105,6 +126,18 @@ export async function defaultBrowserSetup(options) {
const args = options.args ?? []
const onStdoutLine = options.onStdoutLine ?? ((line) => console.log(line))
const onStderrLine = options.onStderrLine ?? ((line) => console.error(line))
/* #if USE_UWASI */
const { wasi } = createUwasi(
{ WASI, MemoryFileSystem, useAll, lineBuffered },
{
modulePath: MODULE_PATH,
args,
onStdoutLine,
onStderrLine,
sleep: browserMainThreadSleep(),
}
)
/* #else */
const wasi = new WASI(/* args */[MODULE_PATH, ...args], /* env */[], /* fd */[
new OpenFile(new File([])), // stdin
ConsoleStdout.lineBuffered((stdout) => {
Expand All @@ -116,6 +149,7 @@ export async function defaultBrowserSetup(options) {
new PreopenDirectory("/", new Map()),
], { debug: false })
/* #endif */
/* #endif */
/* #if USE_SHARED_MEMORY */
const memory = new WebAssembly.Memory(MEMORY_TYPE);
const threadChannel = new DefaultBrowserThreadRegistry(options.spawnWorker || createDefaultWorkerFactory())
Expand All @@ -127,12 +161,16 @@ export async function defaultBrowserSetup(options) {
getImports() { return options.getImports() },
/* #endif */
/* #if IS_WASI */
/* #if USE_UWASI */
wasi,
/* #else */
wasi: Object.assign(wasi, {
setInstance(instance) {
wasi.inst = instance;
}
}),
/* #endif */
/* #endif */
/* #if USE_SHARED_MEMORY */
memory, threadChannel,
/* #endif */
Expand Down
31 changes: 31 additions & 0 deletions Plugins/PackageToJS/Templates/platforms/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import { fileURLToPath } from "node:url";
import { Worker, parentPort } from "node:worker_threads";
import { MODULE_PATH /* #if USE_SHARED_MEMORY */, MEMORY_TYPE /* #endif */} from "../instantiate.js"
/* #if IS_WASI */
/* #if USE_UWASI */
import { WASI, MemoryFileSystem, useAll, lineBuffered } from 'uwasi';
import { createUwasi } from './uwasi.js';
/* #else */
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory, Directory, Inode } from '@bjorn3/browser_wasi_shim';
/* #endif */
/* #endif */

/* #if USE_SHARED_MEMORY */
export async function defaultNodeThreadSetup() {
Expand All @@ -22,6 +27,12 @@ export async function defaultNodeThreadSetup() {
}
}

/* #if USE_UWASI */
const { wasi } = createUwasi(
{ WASI, MemoryFileSystem, useAll, lineBuffered },
{ modulePath: MODULE_PATH }
)
/* #else */
const wasi = new WASI(/* args */[MODULE_PATH], /* env */[], /* fd */[
new OpenFile(new File([])), // stdin
ConsoleStdout.lineBuffered((stdout) => {
Expand All @@ -32,13 +43,18 @@ export async function defaultNodeThreadSetup() {
}),
new PreopenDirectory("/", new Map()),
], { debug: false })
/* #endif */

return {
/* #if USE_UWASI */
wasi,
/* #else */
wasi: Object.assign(wasi, {
setInstance(instance) {
wasi.inst = instance;
}
}),
/* #endif */
threadChannel,
}
}
Expand Down Expand Up @@ -119,6 +135,16 @@ export async function defaultNodeSetup(options = {}) {
const { readFile } = await import("node:fs/promises")

const args = options.args ?? process.argv.slice(2)
/* #if USE_UWASI */
const { wasi } = createUwasi(
{ WASI, MemoryFileSystem, useAll, lineBuffered },
{
modulePath: MODULE_PATH,
args,
withExtractFile: true,
}
)
/* #else */
const rootFs = new Map();
const wasi = new WASI(/* args */[MODULE_PATH, ...args], /* env */[], /* fd */[
new OpenFile(new File([])), // stdin
Expand All @@ -130,6 +156,7 @@ export async function defaultNodeSetup(options = {}) {
}),
new PreopenDirectory("/", rootFs),
], { debug: false })
/* #endif */
const pkgDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
const module = await WebAssembly.compile(new Uint8Array(await readFile(path.join(pkgDir, MODULE_PATH))))
/* #if USE_SHARED_MEMORY */
Expand All @@ -143,6 +170,9 @@ export async function defaultNodeSetup(options = {}) {
getImports() { return {} },
/* #endif */
/* #if IS_WASI */
/* #if USE_UWASI */
wasi,
/* #else */
wasi: Object.assign(wasi, {
setInstance(instance) {
wasi.inst = instance;
Expand Down Expand Up @@ -184,6 +214,7 @@ export async function defaultNodeSetup(options = {}) {
return undefined;
}
}),
/* #endif */
addToCoreImports(importObject) {
importObject["wasi_snapshot_preview1"]["proc_exit"] = (code) => {
if (options.onExit) {
Expand Down
65 changes: 65 additions & 0 deletions Plugins/PackageToJS/Templates/platforms/uwasi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @ts-check

/**
* Refuse blocking waits on the browser main thread, where UWASI would otherwise
* busy-wait. Workers and non-browser hosts can use UWASI's blocking default.
*
* @returns {((milliseconds: number) => void) | undefined}
*/
export function browserMainThreadSleep() {
if (typeof document === "undefined") return undefined;
return (milliseconds) => {
throw new Error(
`A WASI poll_oneoff wait of ${milliseconds}ms cannot block the browser main thread. ` +
"Run the guest in a worker to support blocking waits.",
);
};
}

/**
* @param {{ WASI: any, MemoryFileSystem: any, useAll: any, lineBuffered: any }} runtime
* @param {{
* modulePath: string,
* args?: string[],
* onStdoutLine?: (line: string) => void,
* onStderrLine?: (line: string) => void,
* sleep?: (milliseconds: number) => void,
* withExtractFile?: boolean,
* }} options
*/
export function createUwasi(runtime, options) {
const args = options.args ?? [];
const onStdoutLine = options.onStdoutLine ?? ((line) => console.log(line));
const onStderrLine =
options.onStderrLine ?? ((line) => console.error(line));
const fileSystem = new runtime.MemoryFileSystem({ "/": "/" });
const stdout = runtime.lineBuffered(onStdoutLine);
const stderr = runtime.lineBuffered(onStderrLine);
const wasi = new runtime.WASI({
args: [options.modulePath, ...args],
env: {},
features: [
runtime.useAll({
withFileSystem: fileSystem,
withStdio: {
stdin: () => "",
stdout,
stderr,
outputBuffers: true,
},
sleep: options.sleep,
}),
],
});

if (options.withExtractFile) {
Object.assign(wasi, {
extractFile(filePath) {
const node = fileSystem.lookup(filePath);
return node && node.type === "file" ? node.content : undefined;
},
});
}

return { wasi, fileSystem };
}
Loading
Loading