Skip to content
Merged
9 changes: 9 additions & 0 deletions Plugins/PackageToJS/Sources/PackageToJS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import Foundation

struct PackageToJS {
struct PackageOptions {
enum Platform: String, CaseIterable {
case browser
case node
}

/// Path to the output directory
var outputPath: String?
/// The build configuration to use (default: debug)
var configuration: String?
/// Name of the package (default: lowercased Package.swift name)
var packageName: String?
/// Target platform for the generated JavaScript (default: browser)
var defaultPlatform: Platform = .browser
/// Whether to explain the build plan (default: false)
var explain: Bool = false
/// Whether to print verbose output
Expand Down Expand Up @@ -717,6 +724,8 @@ struct PackagingPlanner {
"USE_WASI_CDN": options.useCDN,
"HAS_BRIDGE": exportedSkeletons.count > 0 || importedSkeletons.count > 0,
"HAS_IMPORTS": importedSkeletons.count > 0,
"TARGET_DEFAULT_PLATFORM_NODE": options.defaultPlatform == .node,
"TARGET_DEFAULT_PLATFORM_BROWSER": options.defaultPlatform == .browser,
]
let constantSubstitutions: [String: String] = [
"PACKAGE_TO_JS_MODULE_PATH": wasmFilename,
Expand Down
32 changes: 25 additions & 7 deletions Plugins/PackageToJS/Sources/PackageToJSPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ struct PackageToJSPlugin: CommandPlugin {
}

var extractor = ArgumentExtractor(arguments)
let buildOptions = PackageToJS.BuildOptions.parse(from: &extractor)
let buildOptions = try PackageToJS.BuildOptions.parse(from: &extractor)

if extractor.remainingArguments.count > 0 {
printStderr(
Expand Down Expand Up @@ -239,7 +239,7 @@ struct PackageToJSPlugin: CommandPlugin {
}

var extractor = ArgumentExtractor(arguments)
let testOptions = PackageToJS.TestOptions.parse(from: &extractor)
let testOptions = try PackageToJS.TestOptions.parse(from: &extractor)

if extractor.remainingArguments.count > 0 {
printStderr(
Expand Down Expand Up @@ -440,12 +440,28 @@ private func printStderr(_ message: String) {

// MARK: - Options parsing

extension ArgumentExtractor {
mutating func extractPlatformOption(named name: String) throws -> PackageToJS.PackageOptions.Platform {
guard let stringValue = self.extractOption(named: name).last else {
return .browser
}

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

extension PackageToJS.PackageOptions {
static func parse(from extractor: inout ArgumentExtractor) -> PackageToJS.PackageOptions {
static func parse(from extractor: inout ArgumentExtractor) throws -> PackageToJS.PackageOptions {
let outputPath = extractor.extractOption(named: "output").last
let configuration: String? =
(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 explain = extractor.extractFlag(named: "explain")
let useCDN = extractor.extractFlag(named: "use-cdn")
let verbose = extractor.extractFlag(named: "verbose")
Expand All @@ -454,6 +470,7 @@ extension PackageToJS.PackageOptions {
outputPath: outputPath,
configuration: configuration,
packageName: packageName,
defaultPlatform: defaultPlatform,
explain: explain != 0,
verbose: verbose != 0,
useCDN: useCDN != 0,
Expand All @@ -466,6 +483,7 @@ 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))
--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 All @@ -475,7 +493,7 @@ extension PackageToJS.PackageOptions {
}

extension PackageToJS.BuildOptions {
static func parse(from extractor: inout ArgumentExtractor) -> PackageToJS.BuildOptions {
static func parse(from extractor: inout ArgumentExtractor) throws -> PackageToJS.BuildOptions {
let product = extractor.extractOption(named: "product").last
let noOptimize = extractor.extractFlag(named: "no-optimize")
let rawDebugInfoFormat = extractor.extractOption(named: "debug-info-format").last
Expand All @@ -488,7 +506,7 @@ extension PackageToJS.BuildOptions {
}
debugInfoFormat = format
}
let packageOptions = PackageToJS.PackageOptions.parse(from: &extractor)
let packageOptions = try PackageToJS.PackageOptions.parse(from: &extractor)
return PackageToJS.BuildOptions(
product: product,
noOptimize: noOptimize != 0,
Expand Down Expand Up @@ -526,15 +544,15 @@ extension PackageToJS.BuildOptions {
}

extension PackageToJS.TestOptions {
static func parse(from extractor: inout ArgumentExtractor) -> PackageToJS.TestOptions {
static func parse(from extractor: inout ArgumentExtractor) throws -> PackageToJS.TestOptions {
let buildOnly = extractor.extractFlag(named: "build-only")
let listTests = extractor.extractFlag(named: "list-tests")
let filter = extractor.extractOption(named: "filter")
let prelude = extractor.extractOption(named: "prelude").last
let environment = extractor.extractOption(named: "environment").last
let inspect = extractor.extractFlag(named: "inspect")
let extraNodeArguments = extractor.extractSingleDashOption(named: "Xnode")
let packageOptions = PackageToJS.PackageOptions.parse(from: &extractor)
let packageOptions = try PackageToJS.PackageOptions.parse(from: &extractor)
var options = PackageToJS.TestOptions(
buildOnly: buildOnly != 0,
listTests: listTests != 0,
Expand Down
2 changes: 2 additions & 0 deletions Plugins/PackageToJS/Templates/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Exports, Imports, ModuleSource } from './instantiate.js'

export type Options = {
/* #if TARGET_DEFAULT_PLATFORM_BROWSER */
/**
* The WebAssembly module to instantiate
*
* If not provided, the module will be fetched from the default path.
*/
module?: ModuleSource
/* #endif */
/* #if HAS_IMPORTS */
/**
* The imports to use for the module
Expand Down
37 changes: 34 additions & 3 deletions Plugins/PackageToJS/Templates/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
// @ts-check
import { instantiate } from './instantiate.js';
import { defaultBrowserSetup /* #if USE_SHARED_MEMORY */, createDefaultWorkerFactory /* #endif */} from './platforms/browser.js';
/* #if TARGET_DEFAULT_PLATFORM_NODE */
import { defaultNodeSetup /* #if USE_SHARED_MEMORY */, createDefaultWorkerFactory as createDefaultWorkerFactoryForNode /* #endif */} from './platforms/node.js';
/* #else */
import { defaultBrowserSetup /* #if USE_SHARED_MEMORY */, createDefaultWorkerFactory as createDefaultWorkerFactoryForBrowser /* #endif */} from './platforms/browser.js';
/* #endif */

/* #if TARGET_DEFAULT_PLATFORM_NODE */
/** @type {import('./index.d').init} */
export async function init(_options) {
async function initNode(_options) {
/** @type {import('./platforms/node.d.ts').DefaultNodeSetupOptions} */
const options = {
...(_options || {}),
/* #if USE_SHARED_MEMORY */
spawnWorker: createDefaultWorkerFactoryForNode(),
/* #endif */
};
const instantiateOptions = await defaultNodeSetup(options);
return await instantiate(instantiateOptions);
}

/* #else */

/** @type {import('./index.d').init} */
async function initBrowser(_options) {
/** @type {import('./index.d').Options} */
const options = _options || {
/* #if HAS_IMPORTS */
Expand All @@ -21,8 +41,19 @@ export async function init(_options) {
getImports: () => options.getImports(),
/* #endif */
/* #if USE_SHARED_MEMORY */
spawnWorker: createDefaultWorkerFactory()
spawnWorker: createDefaultWorkerFactoryForBrowser()
/* #endif */
})
return await instantiate(instantiateOptions);
}

/* #endif */

/** @type {import('./index.d').init} */
export async function init(options) {
/* #if TARGET_DEFAULT_PLATFORM_NODE */
return initNode(options);
/* #else */
return initBrowser(options);
/* #endif */
}
8 changes: 5 additions & 3 deletions Plugins/PackageToJS/Templates/platforms/node.d.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { InstantiateOptions } from "../instantiate.js"
import type { Worker } from "node:worker_threads"

export function defaultNodeSetup(options: {
export type DefaultNodeSetupOptions = {
/* #if IS_WASI */
args?: string[],
/* #endif */
onExit?: (code: number) => void,
/* #if USE_SHARED_MEMORY */
spawnWorker: (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => Worker,
/* #endif */
}): Promise<InstantiateOptions>
}

export function createDefaultWorkerFactory(preludeScript: string): (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => Worker
export function defaultNodeSetup(options: DefaultNodeSetupOptions): Promise<InstantiateOptions>

export function createDefaultWorkerFactory(preludeScript?: string): (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => Worker
4 changes: 3 additions & 1 deletion Plugins/PackageToJS/Templates/platforms/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,17 @@ export async function defaultNodeSetup(options) {
new PreopenDirectory("/", rootFs),
], { debug: false })
const pkgDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
const module = await WebAssembly.compile(await readFile(path.join(pkgDir, MODULE_PATH)))
const module = await WebAssembly.compile(new Uint8Array(await readFile(path.join(pkgDir, MODULE_PATH))))
/* #if USE_SHARED_MEMORY */
const memory = new WebAssembly.Memory(MEMORY_TYPE);
const threadChannel = new DefaultNodeThreadRegistry(options.spawnWorker)
/* #endif */

return {
module,
/* #if HAS_IMPORTS */
getImports() { return {} },
/* #endif */
/* #if IS_WASI */
wasi: Object.assign(wasi, {
setInstance(instance) {
Expand Down
1 change: 1 addition & 0 deletions Plugins/PackageToJS/Templates/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"module": "esnext",
"lib": ["es2017", "dom"],
"noEmit": true,
"allowJs": true,
"skipLibCheck": true,
Expand Down
64 changes: 56 additions & 8 deletions Plugins/PackageToJS/Tests/TemplatesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,66 @@ import Foundation
@testable import PackageToJS

@Suite struct TemplatesTests {
static let templatesPath = URL(fileURLWithPath: #filePath)
static let pluginPackagePath = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
static let templatesPath =
pluginPackagePath
.appendingPathComponent("Templates")
static let localTemporaryDirectory =
pluginPackagePath
.appendingPathComponent("Tests")
.appendingPathComponent("TemporaryDirectory")

/// `npx tsc -p Templates/tsconfig.json`
@Test func tscCheck() throws {
let tsc = Process()
tsc.executableURL = try which("npx")
tsc.arguments = ["tsc", "-p", Self.templatesPath.appending(path: "tsconfig.json").path]
try tsc.run()
tsc.waitUntilExit()
#expect(tsc.terminationStatus == 0)
/// Test both node and browser platform variants
@Test(arguments: ["node", "browser"])
func tscCheck(platform: String) throws {
// Use a local temporary directory to place instantiated templates so that
// they can reference repo-root node_modules packages like @types/node.
try FileManager.default.createDirectory(
at: Self.localTemporaryDirectory,
withIntermediateDirectories: true,
attributes: nil
)
try withTemporaryDirectory(prefixDirectory: Self.localTemporaryDirectory) { tempDir, _ in
let destination = tempDir.appending(path: Self.templatesPath.lastPathComponent)
// Copy entire templates folder to temp location
try FileManager.default.copyItem(at: Self.templatesPath, to: destination)

// Setup preprocessing conditions
let conditions: [String: Bool] = [
"USE_SHARED_MEMORY": false,
"IS_WASI": true,
"USE_WASI_CDN": false,
"HAS_BRIDGE": false,
"HAS_IMPORTS": false,
"TARGET_DEFAULT_PLATFORM_NODE": platform == "node",
"TARGET_DEFAULT_PLATFORM_BROWSER": platform == "browser",
]
let preprocessOptions = PreprocessOptions(conditions: conditions, substitutions: [:])

// Preprocess all JS and TS files in-place
let enumerator = FileManager.default.enumerator(at: destination, includingPropertiesForKeys: nil)
while let fileURL = enumerator?.nextObject() as? URL {
guard !fileURL.hasDirectoryPath,
fileURL.pathExtension == "js" || fileURL.pathExtension == "ts"
else {
continue
}

let content = try String(contentsOf: fileURL, encoding: .utf8)
let preprocessed = try preprocess(source: content, file: fileURL.path, options: preprocessOptions)
try preprocessed.write(to: fileURL, atomically: true, encoding: .utf8)
}

// Run TypeScript on the preprocessed files
let tsc = Process()
tsc.executableURL = try which("npx")
tsc.arguments = ["tsc", "-p", destination.appending(path: "tsconfig.json").path]
try tsc.run()
tsc.waitUntilExit()
#expect(tsc.terminationStatus == 0)
}
}
}
7 changes: 5 additions & 2 deletions Plugins/PackageToJS/Tests/TemporaryDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ struct MakeTemporaryDirectoryError: Error {
let error: CInt
}

internal func withTemporaryDirectory<T>(body: (URL, _ retain: inout Bool) throws -> T) throws -> T {
internal func withTemporaryDirectory<T>(
prefixDirectory: URL = FileManager.default.temporaryDirectory,
body: (URL, _ retain: inout Bool) throws -> T
) throws -> T {
// Create a temporary directory using mkdtemp
var template = FileManager.default.temporaryDirectory.appendingPathComponent("PackageToJSTests.XXXXXX").path
var template = prefixDirectory.appendingPathComponent("PackageToJSTests.XXXXXX").path
return try template.withUTF8 { template in
let copy = UnsafeMutableBufferPointer<CChar>.allocate(capacity: template.count + 1)
template.copyBytes(to: copy)
Expand Down
Loading