From 04b01789b12ba1e18c65eb9dcbcdc1c2c1d47b29 Mon Sep 17 00:00:00 2001
From: Domantas Petrauskas <5850190+ptrdom@users.noreply.github.com>
Date: Sat, 3 Feb 2024 14:14:32 +0200
Subject: [PATCH] Electron plugin (#72)
---
.github/workflows/test.yml | 4 +-
build.sbt | 64 ++--
.../EsbuildElectronProcessConfiguration.scala | 16 +
.../ScalaJSEsbuildElectronPlugin.scala | 278 ++++++++++++++++++
.../scalajsesbuild/electron/Scripts.scala | 90 ++++++
.../scalajsesbuild/electron/package.scala | 61 ++++
.../app/esbuild/electron-builder.json5 | 37 +++
.../electron-project/app/esbuild/index.html | 19 ++
.../electron-project/app/esbuild/package.json | 17 ++
.../electron-project/app/esbuild/styles.css | 7 +
.../app/src/main/scala/example/Main.scala | 64 ++++
.../app/src/main/scala/example/Preload.scala | 42 +++
.../app/src/main/scala/example/Renderer.scala | 30 ++
.../example/facade/electron/Electron.scala | 47 +++
.../example/facade/node/EventEmitter.scala | 8 +
.../main/scala/example/facade/node/Fs.scala | 10 +
.../main/scala/example/facade/node/Node.scala | 18 ++
.../main/scala/example/facade/node/OS.scala | 10 +
.../main/scala/example/facade/node/Path.scala | 10 +
.../electron-project/build.sbt | 139 +++++++++
.../test/scala/example/PlaywrightSpec.scala | 57 ++++
.../example/facade/playwright/Electron.scala | 39 +++
.../src/test/scala/example/SeleniumSpec.scala | 100 +++++++
.../electron-project/project/plugins.sbt | 32 ++
.../electron-project/test | 29 ++
.../scalajsesbuild/EsbuildWebScripts.scala | 52 ++--
.../ScalaJSEsbuildWebPlugin.scala | 155 +++++-----
.../scala/scalajsesbuild/EsbuildScripts.scala | 12 +-
.../scalajsesbuild/ScalaJSEsbuildPlugin.scala | 18 +-
.../main/scala/scalajsesbuild/package.scala | 36 +++
30 files changed, 1362 insertions(+), 139 deletions(-)
create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt
create mode 100644 sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8b172e01..8d42544f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,4 +30,6 @@ jobs:
- name: Setup pnpm
run: corepack prepare pnpm@8.8.0 --activate
- name: Run tests
- run: sbt scalafmtSbtCheck scalafmtCheckAll test scriptedSequentialPerModule
+ uses: coactions/setup-xvfb@v1
+ with:
+ run: sbt scalafmtSbtCheck scalafmtCheckAll test scriptedSequentialPerModule
diff --git a/build.sbt b/build.sbt
index 567af9e2..094bce59 100644
--- a/build.sbt
+++ b/build.sbt
@@ -22,6 +22,7 @@ lazy val `scalajs-esbuild` = (project in file("."))
.settings(publish / skip := true)
.aggregate(
`sbt-scalajs-esbuild`,
+ `sbt-scalajs-esbuild-electron`,
`sbt-scalajs-esbuild-web`,
`sbt-web-scalajs-esbuild`
)
@@ -33,6 +34,31 @@ lazy val commonSettings = Seq(
scriptedBufferLog := false
)
+def shadingSettings = Seq(
+ // workaround for https://github.com/coursier/sbt-shading/issues/39
+ packagedArtifacts := {
+ val sbtV = (pluginCrossBuild / sbtBinaryVersion).value
+ val scalaV = scalaBinaryVersion.value
+ val packagedArtifactsV = packagedArtifacts.value
+ val nameV = name.value
+
+ val (legacyArtifact, legacyFile) = packagedArtifactsV
+ .find { case (a, _) =>
+ a.`type` == "jar" && a.name == nameV
+ }
+ .getOrElse(sys.error("Legacy jar not found"))
+ val mavenArtifact =
+ legacyArtifact.withName(nameV + s"_${scalaV}_$sbtV")
+ val mavenFile = new File(
+ legacyFile.getParentFile,
+ legacyFile.name.replace(legacyArtifact.name, mavenArtifact.name)
+ )
+ IO.copyFile(legacyFile, mavenFile)
+
+ packagedArtifactsV + (mavenArtifact -> mavenFile)
+ }
+)
+
lazy val `sbt-scalajs-esbuild` =
project
.in(file("sbt-scalajs-esbuild"))
@@ -65,28 +91,7 @@ lazy val `sbt-scalajs-esbuild-web` = project
val () = scriptedDependencies.value
val () = (`sbt-scalajs-esbuild` / publishLocal).value
},
- // workaround for https://github.com/coursier/sbt-shading/issues/39
- packagedArtifacts := {
- val sbtV = (pluginCrossBuild / sbtBinaryVersion).value
- val scalaV = scalaBinaryVersion.value
- val packagedArtifactsV = packagedArtifacts.value
- val nameV = name.value
-
- val (legacyArtifact, legacyFile) = packagedArtifactsV
- .find { case (a, _) =>
- a.`type` == "jar" && a.name == nameV
- }
- .getOrElse(sys.error("Legacy jar not found"))
- val mavenArtifact =
- legacyArtifact.withName(nameV + s"_${scalaV}_$sbtV")
- val mavenFile = new File(
- legacyFile.getParentFile,
- legacyFile.name.replace(legacyArtifact.name, mavenArtifact.name)
- )
- IO.copyFile(legacyFile, mavenFile)
-
- packagedArtifactsV + (mavenArtifact -> mavenFile)
- }
+ shadingSettings
)
.dependsOn(`sbt-scalajs-esbuild`)
@@ -104,6 +109,21 @@ lazy val `sbt-web-scalajs-esbuild` =
)
.dependsOn(`sbt-scalajs-esbuild-web`)
+lazy val `sbt-scalajs-esbuild-electron` =
+ project
+ .in(file("sbt-scalajs-esbuild-electron"))
+ .enablePlugins(SbtPlugin)
+ .settings(commonSettings)
+ .settings(
+ scriptedDependencies := {
+ val () = scriptedDependencies.value
+ val () = (`sbt-scalajs-esbuild` / publishLocal).value
+ },
+ shadingSettings
+ )
+ .dependsOn(`sbt-scalajs-esbuild-web`)
+
+// workaround for https://github.com/sbt/sbt/issues/7431
TaskKey[Unit]("scriptedSequentialPerModule") := {
Def.taskDyn {
val projects: Seq[ProjectReference] = `scalajs-esbuild`.aggregate
diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala
new file mode 100644
index 00000000..51118aaa
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/EsbuildElectronProcessConfiguration.scala
@@ -0,0 +1,16 @@
+package scalajsesbuild
+
+class EsbuildElectronProcessConfiguration(
+ val mainModuleID: String,
+ val preloadModuleIDs: Set[String],
+ val rendererModuleIDs: Set[String]
+) {
+ override def toString: String = {
+ s"EsbuildElectronProcessConfiguration($mainModuleID,$preloadModuleIDs,$rendererModuleIDs)"
+ }
+}
+
+object EsbuildElectronProcessConfiguration {
+ def main(moduleID: String): EsbuildElectronProcessConfiguration =
+ new EsbuildElectronProcessConfiguration(moduleID, Set.empty, Set.empty)
+}
diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala
new file mode 100644
index 00000000..eb8a68f9
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/ScalaJSEsbuildElectronPlugin.scala
@@ -0,0 +1,278 @@
+package scalajsesbuild
+
+import org.scalajs.linker.interface.ModuleInitializer
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.ModuleKind
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.jsEnvInput
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSLinkerConfig
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSModuleInitializers
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSStage
+import org.scalajs.sbtplugin.Stage
+import sbt.*
+import sbt.AutoPlugin
+import sbt.Keys.*
+import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildBundle
+import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildBundleScript
+import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildInstall
+import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildScalaJSModuleConfigurations
+import scalajsesbuild.ScalaJSEsbuildWebPlugin.autoImport.esbuildBundleHtmlEntryPoints
+import scalajsesbuild.ScalaJSEsbuildWebPlugin.autoImport.esbuildServeScript
+import scalajsesbuild.ScalaJSEsbuildWebPlugin.autoImport.esbuildServeStart
+import scalajsesbuild.electron.*
+
+import scala.sys.process.Process
+
+object ScalaJSEsbuildElectronPlugin extends AutoPlugin {
+
+ override def requires = ScalaJSEsbuildWebPlugin
+
+ object autoImport {
+ // TODO need to replace this with electron process config - not needed for bundling, but needed for serving
+ val esbuildElectronProcessConfiguration
+ : TaskKey[EsbuildElectronProcessConfiguration] = taskKey(
+ "Configuration linking Scala.js modules to Electron process module components"
+ )
+ }
+
+ import autoImport.*
+
+ override lazy val projectSettings: Seq[Setting[?]] =
+ Seq(
+ scalaJSLinkerConfig ~= {
+ _.withModuleKind(
+ ModuleKind.ESModule
+ ) // TODO try using CommonJSModule - need to figure out how to disable Closure compiler
+ },
+ esbuildElectronProcessConfiguration := {
+ val modules: Seq[ModuleInitializer] = scalaJSModuleInitializers.value
+ (modules.headOption, modules.tail.isEmpty) match {
+ case (Some(module), true) =>
+ EsbuildElectronProcessConfiguration.main(module.moduleID)
+ case _ =>
+ sys.error(
+ "Unable to automatically derive `esbuildElectronProcessConfiguration`, the settings needs to be provided manually"
+ )
+ }
+ }
+ ) ++ inConfig(Compile)(perConfigSettings) ++
+ inConfig(Test)(perConfigSettings)
+
+ private lazy val perConfigSettings: Seq[Setting[?]] = Seq(
+ esbuildScalaJSModuleConfigurations := Map.empty,
+ jsEnvInput := jsEnvInputTask.value,
+ run := Def.taskDyn {
+ val stageTask = scalaJSStage.value.stageTask
+ Def.task {
+ (stageTask / esbuildBundle).value
+
+ val stageTaskReport = stageTask.value.data
+ val mainModule = resolveMainModule(stageTaskReport)
+
+ val targetDirectory = (esbuildInstall / crossTarget).value
+ val outputDirectory =
+ (stageTask / esbuildBundle / crossTarget).value
+ val path =
+ targetDirectory
+ .relativize(new File(outputDirectory, mainModule.jsFileName))
+ .getOrElse(
+ sys.error(
+ s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]"
+ )
+ )
+
+ val exitValue = Process(
+ "node" :: "./node_modules/electron/cli" :: path.toString :: Nil,
+ targetDirectory
+ ).run(streams.value.log)
+ .exitValue()
+ if (exitValue != 0) {
+ sys.error(s"Nonzero exit value: $exitValue")
+ }
+ }
+ }.value
+ ) ++
+ perScalaJSStageSettings(Stage.FastOpt) ++
+ perScalaJSStageSettings(Stage.FullOpt)
+
+ private def perScalaJSStageSettings(stage: Stage): Seq[Setting[?]] = {
+ val stageTask = stage.stageTask
+
+ Seq(
+ stageTask / esbuildBundleScript := {
+ val configurationV = configuration.value
+ val stageTaskReport = stageTask.value.data
+ val electronProcessConfiguration =
+ esbuildElectronProcessConfiguration.value
+ val (
+ mainModuleEntryPoint,
+ preloadModuleEntryPoints,
+ rendererModuleEntryPoints
+ ) = extractEntryPointsByProcess(
+ stageTaskReport,
+ electronProcessConfiguration
+ )
+ val nodeEntryPointsJsArray =
+ (preloadModuleEntryPoints + mainModuleEntryPoint)
+ .map("'" + _ + "'")
+ .mkString("[", ",", "]")
+ val rendererModuleEntryPointsJsArray = rendererModuleEntryPoints
+ .map("'" + _ + "'")
+ .mkString("[", ",", "]")
+ val targetDirectory = (esbuildInstall / crossTarget).value
+ val outputDirectory =
+ (stageTask / esbuildBundle / crossTarget).value
+ val relativeOutputDirectory =
+ targetDirectory
+ .relativize(outputDirectory)
+ .getOrElse(
+ sys.error(
+ s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]"
+ )
+ )
+ val relativeOutputDirectoryJs = s"'$relativeOutputDirectory'"
+ val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value
+ require(
+ !htmlEntryPoints.forall(_.isAbsolute),
+ "HTML entry point paths must be relative"
+ )
+ val htmlEntryPointsJsArray =
+ htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]")
+
+ val minify = if (configurationV == Test) {
+ false
+ } else {
+ true
+ }
+
+ // language=JS
+ s"""
+ |${EsbuildScripts.esbuildOptions}
+ |
+ |${EsbuildScripts.bundle}
+ |
+ |${EsbuildWebScripts.htmlTransform}
+ |
+ |${EsbuildWebScripts.transformHtmlEntryPoints}
+ |
+ |bundle(
+ | ${EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Node.jsValue},
+ | $nodeEntryPointsJsArray,
+ | $relativeOutputDirectoryJs,
+ | null,
+ | false,
+ | $minify,
+ | 'sbt-scalajs-esbuild-node-bundle-meta.json',
+ | {external: ['electron']}
+ |);
+ |
+ |const metaFilePromise = bundle(
+ | ${EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Browser.jsValue},
+ | $rendererModuleEntryPointsJsArray,
+ | $relativeOutputDirectoryJs,
+ | 'assets',
+ | false,
+ | $minify,
+ | 'sbt-scalajs-esbuild-renderer-bundle-meta.json'
+ |);
+ |
+ |metaFilePromise
+ | .then((metaFile) => {
+ | transformHtmlEntryPoints(
+ | $htmlEntryPointsJsArray,
+ | $relativeOutputDirectoryJs,
+ | metaFile
+ | );
+ | });
+ |""".stripMargin
+ },
+ stageTask / esbuildServeScript := {
+ val configurationV = configuration.value
+ val stageTaskReport = stageTask.value.data
+ val electronProcessConfiguration =
+ esbuildElectronProcessConfiguration.value
+ val (
+ mainModuleEntryPoint,
+ preloadModuleEntryPoints,
+ rendererModuleEntryPoints
+ ) = extractEntryPointsByProcess(
+ stageTaskReport,
+ electronProcessConfiguration
+ )
+ val mainModuleEntryPointJs = s"'$mainModuleEntryPoint'"
+ val preloadModuleEntryPointsJs =
+ preloadModuleEntryPoints
+ .map("'" + _ + "'")
+ .mkString("[", ",", "]")
+ val rendererModuleEntryPointsJsArray = rendererModuleEntryPoints
+ .map("'" + _ + "'")
+ .mkString("[", ",", "]")
+ val targetDirectory = (esbuildInstall / crossTarget).value
+ val nodeRelativeOutputDirectoryJs = {
+ val outputDirectory =
+ (stageTask / esbuildBundle / crossTarget).value
+ val nodeRelativeOutputDirectory = targetDirectory
+ .relativize(outputDirectory)
+ .getOrElse(
+ sys.error(
+ s"Target directory [$targetDirectory] must be parent directory of node output directory [$outputDirectory]"
+ )
+ )
+ s"'$nodeRelativeOutputDirectory'"
+ }
+ val rendererRelativeOutputDirectoryJs = {
+ val outputDirectory =
+ (stageTask / esbuildServeStart / crossTarget).value
+ val relativeOutputDirectory =
+ targetDirectory
+ .relativize(outputDirectory)
+ .getOrElse(
+ sys.error(
+ s"Target directory [$targetDirectory] must be parent directory of renderer output directory [$outputDirectory]"
+ )
+ )
+ s"'$relativeOutputDirectory'"
+ }
+ val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value
+ require(
+ !htmlEntryPoints.forall(_.isAbsolute),
+ "HTML entry point paths must be relative"
+ )
+ val htmlEntryPointsJsArray =
+ htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]")
+
+ // language=JS
+ s"""
+ |${EsbuildScripts.esbuildOptions}
+ |
+ |${EsbuildScripts.bundle}
+ |
+ |${EsbuildWebScripts.htmlTransform}
+ |
+ |${EsbuildWebScripts.esbuildLiveReload}
+ |
+ |${EsbuildWebScripts.serve}
+ |
+ |${electron.Scripts.electronServe}
+ |
+ |serve(
+ | $rendererModuleEntryPointsJsArray,
+ | $rendererRelativeOutputDirectoryJs,
+ | 'assets',
+ | 'sbt-scalajs-esbuild-renderer-bundle-meta.json',
+ | 8001,
+ | 8000,
+ | $htmlEntryPointsJsArray
+ |)
+ | .then((reloadEventEmitter) => {
+ | electronServe(
+ | reloadEventEmitter,
+ | 8000,
+ | $mainModuleEntryPointJs,
+ | $preloadModuleEntryPointsJs,
+ | $nodeRelativeOutputDirectoryJs
+ | );
+ | });
+ |""".stripMargin
+ }
+ )
+ }
+}
diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala
new file mode 100644
index 00000000..506d4573
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/Scripts.scala
@@ -0,0 +1,90 @@
+package scalajsesbuild.electron
+
+object Scripts {
+
+ private[scalajsesbuild] def electronServe = {
+ // language=JS
+ """const electronServe = async (
+ | reloadEventEmitter,
+ | rendererBuildServerProxyPort,
+ | mainEntryPoint,
+ | preloadEntryPoints,
+ | electronBuildOutputDirectory
+ |) => {
+ | const path = require('path');
+ | const esbuild = require('esbuild');
+ | const { spawn } = require('node:child_process');
+ | const electron = require('electron');
+ |
+ | await (async function () {
+ | const plugins = [{
+ | name: 'renderer-reload-plugin',
+ | setup(build) {
+ | build.onEnd(() => {
+ | reloadEventEmitter.emit('reload');
+ | });
+ | },
+ | }];
+ |
+ | const ctx = await esbuild.context({
+ | entryPoints: preloadEntryPoints,
+ | bundle: true,
+ | outdir: electronBuildOutputDirectory,
+ | logOverride: {
+ | 'equals-negative-zero': 'silent',
+ | },
+ | logLevel: "info",
+ | entryNames: '[name]',
+ | assetNames: '[name]',
+ | plugins: plugins,
+ | platform: 'node',
+ | external: ['electron'],
+ | });
+ |
+ | ctx.watch();
+ | })();
+ |
+ | await (async function () {
+ | const plugins = [{
+ | name: 'main-reload-plugin',
+ | setup(build) {
+ | let electronProcess = null;
+ | build.onEnd(() => {
+ | if (electronProcess != null) {
+ | electronProcess.handle.removeListener('exit', electronProcess.closeListener);
+ | electronProcess.handle.kill();
+ | electronProcess = null;
+ | }
+ | electronProcess = {
+ | handle: spawn(electron, [path.join(electronBuildOutputDirectory, mainEntryPoint), '.'], { stdio: 'inherit' }),
+ | closeListener: () => process.exit()
+ | };
+ | electronProcess.handle.on('exit', electronProcess.closeListener);
+ | });
+ | },
+ | }];
+ |
+ | const ctx = await esbuild.context({
+ | entryPoints: [mainEntryPoint],
+ | bundle: true,
+ | outdir: electronBuildOutputDirectory,
+ | logOverride: {
+ | 'equals-negative-zero': 'silent',
+ | },
+ | logLevel: "info",
+ | entryNames: '[name]',
+ | assetNames: '[name]',
+ | plugins: plugins,
+ | platform: 'node',
+ | external: ['electron'],
+ | });
+ |
+ | ctx.watch();
+ | })();
+ |
+ | Object.assign(process.env, {
+ | DEV_SERVER_URL: `http://localhost:${rendererBuildServerProxyPort}`,
+ | })
+ |};""".stripMargin
+ }
+}
diff --git a/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala
new file mode 100644
index 00000000..68b2793f
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/main/scala/scalajsesbuild/electron/package.scala
@@ -0,0 +1,61 @@
+package scalajsesbuild
+
+import org.scalajs.linker.interface.Report
+import org.scalajs.linker.interface.unstable
+
+package object electron {
+
+ private[scalajsesbuild] def extractEntryPointsByProcess(
+ report: Report,
+ electronProcessConfiguration: EsbuildElectronProcessConfiguration
+ ) = {
+ report match {
+ case report: unstable.ReportImpl =>
+ val mainModuleEntryPoint = report.publicModules
+ .find(
+ _.moduleID == electronProcessConfiguration.mainModuleID
+ )
+ .getOrElse(
+ sys.error(
+ s"Main module [$electronProcessConfiguration.mainModuleID] not found in Scala.js modules"
+ )
+ )
+ .jsFileName
+ val preloadModuleEntryPoints =
+ electronProcessConfiguration.preloadModuleIDs
+ .map(moduleID =>
+ report.publicModules
+ .find(
+ _.moduleID == moduleID
+ )
+ .getOrElse(
+ sys.error(
+ s"Preload module [$electronProcessConfiguration.mainModuleID] not found in Scala.js modules"
+ )
+ )
+ .jsFileName
+ )
+ val rendererModuleEntryPoints =
+ electronProcessConfiguration.rendererModuleIDs
+ .map(moduleID =>
+ report.publicModules
+ .find(
+ _.moduleID == moduleID
+ )
+ .getOrElse(
+ sys.error(
+ s"Renderer module [$electronProcessConfiguration.mainModuleID] not found in Scala.js modules"
+ )
+ )
+ .jsFileName
+ )
+ (
+ mainModuleEntryPoint,
+ preloadModuleEntryPoints,
+ rendererModuleEntryPoints
+ )
+ case unhandled =>
+ sys.error(s"Unhandled report type [$unhandled]")
+ }
+ }
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5 b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5
new file mode 100644
index 00000000..ad955755
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/electron-builder.json5
@@ -0,0 +1,37 @@
+/**
+ * @see https://www.electron.build/configuration/configuration
+ */
+{
+ "appId": "YourAppID",
+ "asar": true,
+ "directories": {
+ "output": "release/${version}"
+ },
+ "files": [
+ "out"
+ ],
+ "mac": {
+ "artifactName": "${productName}_${version}.${ext}",
+ "target": [
+ "dmg"
+ ]
+ },
+ "win": {
+ "target": [
+ {
+ "target": "nsis",
+ "arch": [
+ "x64"
+ ]
+ }
+ ],
+ "artifactName": "${productName}_${version}.${ext}"
+ },
+ "nsis": {
+ "oneClick": false,
+ "perMachine": false,
+ "allowToChangeInstallationDirectory": true,
+ "deleteAppDataOnUninstall": false
+ },
+ publish: null
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html
new file mode 100644
index 00000000..65a3ea28
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Hello World!
+
+
+ Hello World!
+ We are using Node.js ,
+ Chromium ,
+ and Electron .
+
+
+
+
+
+
+
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json
new file mode 100644
index 00000000..e0495048
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "electron-project",
+ "private": true,
+ "version": "0.0.0",
+ "main": "out/main.js",
+ "repository": {
+ "url": "git+https://github.com/ptrdom/scalajs-esbuild.git"
+ },
+ "devDependencies": {
+ "esbuild": "0.19.11",
+ "electron-builder": "24.9.1",
+ "electron-chromedriver": "28.1.0",
+ "jsdom": "22.1.0",
+ "electron": "28.1.0",
+ "playwright": "1.41.1"
+ }
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css
new file mode 100644
index 00000000..fa8b48ef
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/esbuild/styles.css
@@ -0,0 +1,7 @@
+/* styles.css */
+
+/* Add styles here to customize the appearance of your app */
+
+#css-hook::after {
+ content: 'CSS WORKS!'
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala
new file mode 100644
index 00000000..9965a89a
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Main.scala
@@ -0,0 +1,64 @@
+package example
+
+import example.facade.electron.BrowserWindow
+import example.facade.electron.BrowserWindowConfig
+import example.facade.electron.ElectronGlobals.app
+import example.facade.electron.WebPreferences
+import example.facade.node.NodeGlobals.__dirname
+import example.facade.node.NodeGlobals.process
+import example.facade.node.Path.join
+
+import scala.scalajs.js
+import scala.scalajs.js.|
+
+object Main extends App {
+ // Create the browser window.
+ def createWindow(): Unit = {
+ val mainWindow = new BrowserWindow(new BrowserWindowConfig {
+ override val height = 600
+ override val width = 800
+ override val webPreferences = new WebPreferences {
+ override val preload = join(__dirname, "preload.js")
+ }
+ })
+
+ // and load the index.html of the app.
+ process.env.DEV_SERVER_URL
+ .asInstanceOf[js.UndefOr[String]]
+ .toOption
+ .fold(
+ mainWindow.loadFile(join(__dirname, "../out", "index.html"))
+ )(url => mainWindow.loadURL(url))
+
+ // Open the DevTools.
+ // mainWindow.webContents.openDevTools()
+ }
+
+ // This method will be called when Electron has finished
+ // initialization and is ready to create browser windows.
+ // Some APIs can only be used after this event occurs.
+ app
+ .whenReady()
+ .`then`((_ => {
+ createWindow()
+
+ // On macOS it's common to re-create a window in the app when the
+ // dock icon is clicked and there are no other windows open.
+ app.on(
+ "activate",
+ () => {
+ if (BrowserWindow.getAllWindows().length == 0) createWindow()
+ }
+ )
+ }): js.Function1[Unit, Unit | js.Thenable[Unit]])
+
+ // Quit when all windows are closed, except on macOS. There, it's common
+ // for applications and their menu bar to stay active until the user quits
+ // explicitly with Cmd + Q.
+ app.on(
+ "window-all-close",
+ () => {
+ if (process.platform != "darwin") app.quit()
+ }
+ )
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala
new file mode 100644
index 00000000..464ad7b1
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Preload.scala
@@ -0,0 +1,42 @@
+package example
+
+import example.facade.node.NodeGlobals.process
+import org.scalajs.dom
+import org.scalajs.dom.document
+import org.scalajs.dom.window
+
+import scala.scalajs.js
+
+/** The preload script runs before. It has access to web APIs as well as
+ * Electron's renderer process modules and some polyfilled Node.js functions.
+ *
+ * https://www.electronjs.org/docs/latest/tutorial/sandbox
+ */
+object Preload extends App {
+ window.addEventListener(
+ "DOMContentLoaded",
+ { (_: dom.Event) =>
+ val replaceText = (selector: String, text: String) => {
+ val element = document.getElementById(selector)
+ if (element != null) element.innerText = text
+ }
+
+ js.Array("chrome", "node", "electron")
+ .foreach(`type` =>
+ replaceText(
+ s"${`type`}-version",
+ process.versions.get(`type`).orNull
+ )
+ )
+ }
+ )
+
+ document.addEventListener(
+ "DOMContentLoaded",
+ { (_: dom.Event) =>
+ val h1 = document.createElement("h1")
+ h1.textContent = "PRELOAD WORKS!"
+ document.body.append(h1)
+ }
+ )
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala
new file mode 100644
index 00000000..4af6e836
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/Renderer.scala
@@ -0,0 +1,30 @@
+package example
+
+import org.scalajs.dom
+import org.scalajs.dom.document
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSImport
+
+@js.native
+@JSImport("./styles.css", JSImport.Namespace)
+object Style extends js.Object
+
+/** This file is loaded via the <script> tag in the index.html file and
+ * will be executed in the renderer process for that window. No Node.js APIs
+ * are available in this process because `nodeIntegration` is turned off and
+ * `contextIsolation` is turned on. Use the contextBridge API in `preload.js`
+ * to expose Node.js functionality from the main process.
+ */
+object Renderer extends App {
+ val style = Style
+
+ document.addEventListener(
+ "DOMContentLoaded",
+ { (_: dom.Event) =>
+ val h1 = document.createElement("h1")
+ h1.textContent = "RENDERER WORKS!"
+ document.body.append(h1)
+ }
+ )
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala
new file mode 100644
index 00000000..c021ef5c
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/electron/Electron.scala
@@ -0,0 +1,47 @@
+package example.facade.electron
+
+import example.facade.node.EventEmitter
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSImport
+
+object ElectronGlobals {
+ @js.native
+ @JSImport("electron", "app")
+ def app: Application = js.native
+}
+
+@js.native
+trait Application extends EventEmitter {
+ def whenReady(): js.Promise[Unit]
+ def quit(): Unit
+}
+
+@js.native
+@JSImport("electron", "BrowserWindow")
+class BrowserWindow(config: BrowserWindowConfig) extends js.Object {
+ def loadURL(url: String): js.Promise[Unit] = js.native
+ def loadFile(filePath: String): js.Promise[Unit] = js.native
+ def webContents: WebContents = js.native
+}
+
+trait BrowserWindowConfig extends js.Object {
+ val height: js.UndefOr[Int] = js.undefined
+ val width: js.UndefOr[Int] = js.undefined
+ val webPreferences: js.UndefOr[WebPreferences] = js.undefined
+}
+
+trait WebPreferences extends js.Object {
+ val preload: js.UndefOr[String] = js.undefined
+ val nodeIntegration: js.UndefOr[Boolean] = js.undefined
+}
+
+trait WebContents extends js.Object {
+ def openDevTools(): Unit
+}
+
+@js.native
+@JSImport("electron", "BrowserWindow")
+object BrowserWindow extends js.Object {
+ def getAllWindows(): js.Array[BrowserWindow] = js.native
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala
new file mode 100644
index 00000000..64ecefca
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/EventEmitter.scala
@@ -0,0 +1,8 @@
+package example.facade.node
+
+import scala.scalajs.js
+
+@js.native
+trait EventEmitter extends js.Object {
+ def on(event: String, listener: js.Function): Unit = js.native
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala
new file mode 100644
index 00000000..5b8ff012
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Fs.scala
@@ -0,0 +1,10 @@
+package example.facade.node
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSImport
+
+object Fs {
+ @js.native
+ @JSImport("fs", "mkdtempSync")
+ def mkdtempSync(prefix: String): String = js.native
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala
new file mode 100644
index 00000000..fea91706
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Node.scala
@@ -0,0 +1,18 @@
+package example.facade.node
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSGlobalScope
+
+@js.native
+@JSGlobalScope
+object NodeGlobals extends js.Object {
+ val process: Process = js.native
+ val __dirname: String = js.native
+}
+
+@js.native
+trait Process extends js.Object {
+ val platform: String = js.native
+ val versions: js.Dictionary[String] = js.native
+ val env: js.Dynamic = js.native
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala
new file mode 100644
index 00000000..6932fa6e
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/OS.scala
@@ -0,0 +1,10 @@
+package example.facade.node
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSImport
+
+object OS {
+ @js.native
+ @JSImport("os", "tmpdir")
+ def tmpdir(): String = js.native
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala
new file mode 100644
index 00000000..43a61f44
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/app/src/main/scala/example/facade/node/Path.scala
@@ -0,0 +1,10 @@
+package example.facade.node
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSImport
+
+object Path {
+ @js.native
+ @JSImport("path", "join")
+ def join(paths: String*): String = js.native
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt
new file mode 100644
index 00000000..d7c34b2b
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/build.sbt
@@ -0,0 +1,139 @@
+import org.scalajs.jsenv.nodejs.NodeJSEnv
+import org.scalajs.linker.interface.ModuleInitializer
+import org.scalajs.sbtplugin.Stage
+import scalajsesbuild.EsbuildElectronProcessConfiguration
+import scala.sys.process._
+
+lazy val root = (project in file("."))
+ .aggregate(
+ app,
+ `integration-test-playwright-node`,
+ `integration-test-selenium-jvm`
+ )
+
+ThisBuild / scalaVersion := "2.13.8"
+
+val viteElectronBuildPackage =
+ taskKey[Unit]("Generate package directory with electron-builder")
+val viteElectronBuildDistributable =
+ taskKey[Unit]("Package in distributable format with electron-builder")
+
+lazy val app = (project in file("app"))
+ .enablePlugins(ScalaJSEsbuildElectronPlugin)
+ .settings(
+ Test / test := {},
+ // Suppress meaningless 'multiple main classes detected' warning
+ Compile / mainClass := None,
+ scalaJSModuleInitializers := Seq(
+ ModuleInitializer
+ .mainMethodWithArgs("example.Main", "main")
+ .withModuleID("main"),
+ ModuleInitializer
+ .mainMethodWithArgs("example.Preload", "main")
+ .withModuleID("preload"),
+ ModuleInitializer
+ .mainMethodWithArgs("example.Renderer", "main")
+ .withModuleID("renderer")
+ ),
+ Compile / esbuildElectronProcessConfiguration := new EsbuildElectronProcessConfiguration(
+ "main",
+ Set("preload"),
+ Set("renderer")
+ ),
+ libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.2.0",
+ Seq(Compile, Test)
+ .flatMap { config =>
+ inConfig(config)(
+ Seq(Stage.FastOpt, Stage.FullOpt).flatMap { stage =>
+ val stageTask = stage match {
+ case Stage.FastOpt => fastLinkJS
+ case Stage.FullOpt => fullLinkJS
+ }
+
+ def fn(args: List[String] = Nil) = Def.task {
+ val log = streams.value.log
+
+ (stageTask / esbuildBundle).value
+
+ val targetDir = (esbuildInstall / crossTarget).value
+
+ val exitValue = Process(
+ "node" :: "node_modules/electron-builder/cli" :: Nil ::: args,
+ targetDir
+ ).run(log).exitValue()
+ if (exitValue != 0) {
+ sys.error(s"Nonzero exit value: $exitValue")
+ } else ()
+ }
+
+ Seq(
+ stageTask / viteElectronBuildPackage := fn("--dir" :: Nil).value,
+ stageTask / viteElectronBuildDistributable := fn().value
+ )
+ }
+ )
+ }
+ )
+
+lazy val `integration-test-selenium-jvm` =
+ (project in file("integration-test-selenium-jvm"))
+ .settings(
+ Test / test := (Test / test).dependsOn {
+ Def.taskDyn {
+ val stageTask = (app / Compile / scalaJSStage).value match {
+ case Stage.FastOpt => fastLinkJS
+ case Stage.FullOpt => fullLinkJS
+ }
+ Def.task {
+ (((app / Compile) / stageTask) / esbuildBundle).value
+ }
+ }
+ }.value,
+ libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.15" % "test",
+ libraryDependencies ++= Seq(
+ "org.scalatestplus" %% "selenium-4-7" % "3.2.15.0" % "test",
+ "org.seleniumhq.selenium" % "selenium-java" % "4.16.1" % "test"
+ ) // should be upgraded when Electron upgrades its chromium version
+ )
+
+lazy val `integration-test-playwright-node` =
+ (project in file("integration-test-playwright-node"))
+ .enablePlugins(ScalaJSPlugin)
+ .settings(
+ scalaJSLinkerConfig ~= {
+ _.withModuleKind(ModuleKind.CommonJSModule)
+ },
+ Test / jsEnv := Def.taskDyn {
+ val stageTask = (app / Compile / scalaJSStage).value match {
+ case Stage.FastOpt => fastLinkJS
+ case Stage.FullOpt => fullLinkJS
+ }
+
+ Def.task {
+ (((app / Compile) / stageTask) / esbuildBundle).value
+
+ val sourcesDirectory =
+ (((app / Compile) / esbuildInstall) / crossTarget).value
+ val outputDirectory =
+ ((((app / Compile) / stageTask) / esbuildBundle) / crossTarget).value
+ val mainModuleID =
+ ((app / Compile) / esbuildElectronProcessConfiguration).value.mainModuleID
+
+ val nodePath = (sourcesDirectory / "node_modules").absolutePath
+ val mainPath = (outputDirectory / s"$mainModuleID.js").absolutePath
+
+ new NodeJSEnv(
+ NodeJSEnv
+ .Config()
+ .withEnv(
+ Map(
+ "NODE_PATH" -> nodePath,
+ "MAIN_PATH" -> mainPath
+ )
+ )
+ )
+ }
+ }.value,
+ libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.15" % "test"
+ )
+ .dependsOn(app)
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala
new file mode 100644
index 00000000..245e03ed
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/PlaywrightSpec.scala
@@ -0,0 +1,57 @@
+package example
+
+import example.facade.node.Fs.mkdtempSync
+import example.facade.node.OS.tmpdir
+import example.facade.node.Path.join
+import example.facade.node._
+import example.facade.playwright._
+import org.scalatest.Assertion
+import org.scalatest.freespec.AsyncFreeSpec
+import org.scalatest.matchers.should.Matchers
+
+import scala.concurrent.Future
+import scala.scalajs.concurrent.JSExecutionContext
+import scala.scalajs.js
+import scala.scalajs.js.Thenable.Implicits._
+
+class PlaywrightSpec extends AsyncFreeSpec with Matchers {
+
+ implicit override def executionContext =
+ JSExecutionContext.queue
+
+ "Work" in {
+ Electron
+ .launch(new LaunchConfig {
+ override val args =
+ js.Array(
+ NodeGlobals.process.env.MAIN_PATH.asInstanceOf[String],
+ s"--user-data-dir=${mkdtempSync(join(tmpdir(), "electron-app-user-data-directory-"))}"
+ )
+ })
+ .flatMap(electronApp =>
+ {
+ for {
+ // https://playwright.dev/docs/api/class-electron\
+ // TODO figure out how to `electronApp.evaluate`
+ window <- electronApp.firstWindow()
+ title <- window.title()
+ _ = println(title)
+ _ <- window.screenshot(new ScreenshotConfig {
+ override val path = "intro.png"
+ })
+ _ <- window.isVisible("text='PRELOAD WORKS!'").map(_ shouldBe true)
+ _ <- window.isVisible("text='RENDERER WORKS!'").map(_ shouldBe true)
+ - <- window
+ .evaluate[String](
+ "window.getComputedStyle(document.getElementById('css-hook'), '::after')['content']"
+ )
+ .map(_ shouldBe "\"CSS WORKS!\"")
+ } yield succeed
+ }
+ .transform { result =>
+ electronApp.close()
+ result
+ }
+ )
+ }
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala
new file mode 100644
index 00000000..76b51bd7
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-playwright-node/src/test/scala/example/facade/playwright/Electron.scala
@@ -0,0 +1,39 @@
+package example.facade.playwright
+
+import example.facade.electron
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSImport
+
+@js.native
+@JSImport("playwright", "_electron")
+object Electron extends js.Object {
+ def launch(config: LaunchConfig): js.Promise[ElectronApplication] = js.native
+}
+
+trait LaunchConfig extends js.Object {
+ val args: js.Array[String]
+}
+
+@js.native
+trait ElectronApplication extends js.Object {
+
+ def firstWindow(): js.Promise[Window] = js.native
+
+ def close(): js.Promise[Unit] = js.native
+}
+
+@js.native
+trait Window extends js.Object {
+ def title(): js.Promise[String] = js.native
+
+ def screenshot(config: ScreenshotConfig): js.Promise[js.Dynamic]
+
+ def isVisible(selector: String): js.Promise[Boolean]
+
+ def evaluate[T](pageFunction: String): js.Promise[T]
+}
+
+trait ScreenshotConfig extends js.Object {
+ val path: String
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala
new file mode 100644
index 00000000..130ad0a3
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/integration-test-selenium-jvm/src/test/scala/example/SeleniumSpec.scala
@@ -0,0 +1,100 @@
+package example
+
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.Paths
+
+import org.openqa.selenium.WebDriver
+import org.openqa.selenium.chrome.ChromeDriver
+import org.openqa.selenium.chrome.ChromeOptions
+import org.scalatest.freespec.AnyFreeSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatestplus.selenium.WebBrowser
+
+import scala.annotation.tailrec
+import scala.sys.process._
+
+class SeleniumSpec extends AnyFreeSpec with Matchers {
+
+ val targetDirectory = {
+ // test can be executed by IntelliJ Run/Debug configurations and from sbt,
+ // working directory is not the same for both of them,
+ // so we check both of them if either exists
+ val scalaTestRunnerPath = "../"
+ val sbtRunnerPath = ""
+ val targetRelativePath = "/app/target/scala-2.13/esbuild/main"
+
+ @tailrec
+ def resolve(toTry: List[String]): File = {
+ toTry match {
+ case ::(head, next) =>
+ val maybeTargetDirectory = Paths
+ .get(
+ new File(head).getAbsolutePath,
+ targetRelativePath
+ )
+ .toFile
+ if (new File(maybeTargetDirectory, "package.json").exists()) {
+ maybeTargetDirectory
+ } else {
+ resolve(next)
+ }
+ case Nil =>
+ sys.error("Target directory for esbuild project was not resolved")
+ }
+ }
+ resolve(List(scalaTestRunnerPath, sbtRunnerPath))
+ }
+ val debugPort = 9222
+
+ System.setProperty(
+ "webdriver.chrome.driver",
+ Paths
+ .get(
+ targetDirectory.getAbsolutePath, {
+ val extension =
+ if (System.getProperty("os.name").toLowerCase.contains("win"))
+ ".exe"
+ else ""
+ s"node_modules/electron-chromedriver/bin/chromedriver$extension"
+ }
+ )
+ .normalize()
+ .toString
+ )
+
+ "Work" in {
+ val userDataDir =
+ Files.createTempDirectory("electron-app-user-data-directory")
+ val process = Process(
+ "node" ::
+ "./node_modules/electron/cli" ::
+ "./out/main.js" ::
+ s"--remote-debugging-port=$debugPort" ::
+ "--remote-allow-origins=*" ::
+ s"--user-data-dir=${userDataDir.toAbsolutePath}" ::
+ Nil,
+ targetDirectory
+ ).run
+ try {
+ val options = new ChromeOptions()
+ options.addArguments("--remote-allow-origins=*")
+ options.setExperimentalOption("debuggerAddress", s"localhost:$debugPort")
+ implicit val webDriver: WebDriver = new ChromeDriver(options)
+ try {
+ new WebBrowser {
+ pageTitle shouldBe "Hello World!"
+ find(xpath("//h1[text()='PRELOAD WORKS!']")) shouldBe defined
+ find(xpath("//h1[text()='RENDERER WORKS!']")) shouldBe defined
+ executeScript(
+ "return window.getComputedStyle(document.getElementById('css-hook'), '::after')['content']"
+ ).asInstanceOf[String] shouldBe "\"CSS WORKS!\""
+ }
+ } finally {
+ webDriver.quit()
+ }
+ } finally {
+ process.destroy()
+ }
+ }
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt
new file mode 100644
index 00000000..cb2dcf72
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/project/plugins.sbt
@@ -0,0 +1,32 @@
+val sourcePlugins = sys.props
+ .get("plugin.version")
+ .map { version =>
+ println(s"Using plugin(s) version [$version]")
+ Seq.empty
+ }
+ .getOrElse {
+ println("Building plugin(s) from source")
+ Seq(
+ ProjectRef(
+ file("../../../../../../"),
+ "sbt-scalajs-esbuild-electron"
+ ): ClasspathDep[ProjectReference]
+ )
+ }
+
+lazy val root = (project in file("."))
+ .dependsOn(sourcePlugins: _*)
+
+if (sourcePlugins.nonEmpty) {
+ Seq.empty
+} else {
+ val scalaJSEsbuildVersion = sys.props.getOrElse(
+ "plugin.version",
+ sys.error("'plugin.version' environment variable is not set")
+ )
+ Seq(
+ addSbtPlugin(
+ "me.ptrdom" % "sbt-scalajs-esbuild-electron" % scalaJSEsbuildVersion
+ )
+ )
+}
diff --git a/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test
new file mode 100644
index 00000000..d249ef8a
--- /dev/null
+++ b/sbt-scalajs-esbuild-electron/src/sbt-test/sbt-scalajs-esbuild-electron/electron-project/test
@@ -0,0 +1,29 @@
+$ absent app/esbuild/package-lock.json
+> clean
+> set app/Compile/scalaJSStage := org.scalajs.sbtplugin.Stage.FastOpt
+> test
+> app/fullLinkJS/esbuildServeStart
+# TODO figure out how to test if dev mode works
+> app/fullLinkJS/esbuildServeStop
+$ exists app/esbuild/package-lock.json
+
+$ delete app/esbuild/package-lock.json
+> clean
+> set app/Compile/scalaJSStage := org.scalajs.sbtplugin.Stage.FullOpt
+> test
+> app/fullLinkJS/esbuildServeStart
+# TODO figure out how to test if dev mode works
+> app/fullLinkJS/esbuildServeStop
+$ exists app/esbuild/package-lock.json
+
+$ delete app/esbuild/package-lock.json
+> clean
+> app/fastLinkJS/viteElectronBuildPackage
+> app/fastLinkJS/viteElectronBuildDistributable
+$ exists app/esbuild/package-lock.json
+
+$ delete app/esbuild/package-lock.json
+> clean
+> app/fullLinkJS/viteElectronBuildPackage
+> app/fullLinkJS/viteElectronBuildDistributable
+$ exists app/esbuild/package-lock.json
\ No newline at end of file
diff --git a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala
index fbaba551..c7120bf7 100644
--- a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala
+++ b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/EsbuildWebScripts.scala
@@ -67,6 +67,7 @@ object EsbuildWebScripts {
|""".stripMargin
}
+ // TODO make dev server return this as a script to fix CSP issue in Electron plugin
private[scalajsesbuild] def esbuildLiveReload = {
// language=JS
"""const esbuildLiveReload = (
@@ -75,28 +76,7 @@ object EsbuildWebScripts {
| return htmlString
| .toString()
| .replace("", `
- |
+ |
|
| `);
|}
@@ -205,6 +185,34 @@ object EsbuildWebScripts {
| res.writeHead(404);
| res.end('HTML file ['+htmlFilePath+'] not found');
| }
+ | } else if (path === '/@dev-server/live-reload'){
+ | res.writeHead(200);
+ | res.end(`
+ | // Based on https://esbuild.github.io/api/#live-reload
+ | const eventSource = new EventSource('/esbuild')
+ | eventSource.addEventListener('change', e => {
+ | const { added, removed, updated } = JSON.parse(e.data)
+ |
+ | if (!added.length && !removed.length && updated.length === 1) {
+ | for (const link of document.getElementsByTagName('link')) {
+ | const url = new URL(link.href)
+ |
+ | if (url.host === location.host && url.pathname === updated[0]) {
+ | const next = link.cloneNode()
+ | next.href = updated[0] + '?' + Math.random().toString(36).slice(2)
+ | next.onload = () => link.remove()
+ | link.parentNode.insertBefore(next, link.nextSibling)
+ | return
+ | }
+ | }
+ | }
+ |
+ | location.reload()
+ | });
+ | eventSource.addEventListener('reload', () => {
+ | location.reload();
+ | });
+ | `);
| } else {
| const proxyReq = http.request(options, (proxyRes) => {
| if (proxyRes.statusCode === 404 && !multipleEntryPointsFound) {
diff --git a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala
index 10de79d3..566f5492 100644
--- a/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala
+++ b/sbt-scalajs-esbuild-web/src/main/scala/scalajsesbuild/ScalaJSEsbuildWebPlugin.scala
@@ -64,29 +64,32 @@ object ScalaJSEsbuildWebPlugin extends AutoPlugin {
IO.read(targetDir / "sbt-scalajs-esbuild-bundle-meta.json")
)
- jsFileNames(stageTask.value.data)
- .map { jsFileName =>
- metaJson
- .get("outputs")
- .asInstanceOf[JObject]
- .vs
- .collectFirst {
- case (outputBundle, output)
- if output
- .asInstanceOf[JObject]
- .get("entryPoint")
- .getString
- .contains(jsFileName) =>
- outputBundle
- }
- .getOrElse(
- sys.error(s"Output bundle not found for module [$jsFileName]")
- )
+ val mainModule = resolveMainModule(stageTask.value.data)
+
+ val outputBundle = metaJson
+ .get("outputs")
+ .asInstanceOf[JObject]
+ .vs
+ .collectFirst {
+ case (outputBundle, output)
+ if output
+ .asInstanceOf[JObject]
+ .get("entryPoint")
+ .getString
+ .contains(mainModule.jsFileName) =>
+ outputBundle
}
- .map((stageTask / esbuildInstall / crossTarget).value / _)
- .map(_.toPath)
- .map(Script)
- .toSeq
+ .getOrElse(
+ sys.error(
+ s"Output bundle not found for main module [${mainModule.moduleID}]"
+ )
+ )
+
+ Seq(
+ Script(
+ ((stageTask / esbuildInstall / crossTarget).value / outputBundle).toPath
+ )
+ )
}
}.value
) ++
@@ -163,6 +166,60 @@ object ScalaJSEsbuildWebPlugin extends AutoPlugin {
| );
| });
|""".stripMargin
+ },
+ stageTask / esbuildServeScript := {
+ val stageTaskReport = stageTask.value.data
+ val moduleConfigurations = esbuildScalaJSModuleConfigurations.value
+ val entryPoints =
+ extractEntryPointsByPlatform(stageTaskReport, moduleConfigurations)
+ .getOrElse(
+ EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Browser,
+ Set.empty
+ )
+ val entryPointsJsArray =
+ entryPoints.map("'" + _ + "'").mkString("[", ",", "]")
+ val targetDirectory = (esbuildInstall / crossTarget).value
+ val outputDirectory =
+ (stageTask / esbuildServeStart / crossTarget).value
+ val relativeOutputDirectory =
+ targetDirectory
+ .relativize(outputDirectory)
+ .getOrElse(
+ sys.error(
+ s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]"
+ )
+ )
+ val relativeOutputDirectoryJs = s"'$relativeOutputDirectory'"
+ val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value
+ require(
+ !htmlEntryPoints.forall(_.isAbsolute),
+ "HTML entry point paths must be relative"
+ )
+ val htmlEntryPointsJsArray =
+ htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]")
+
+ // language=JS
+ s"""
+ |${EsbuildScripts.esbuildOptions}
+ |
+ |${EsbuildScripts.bundle}
+ |
+ |${EsbuildWebScripts.htmlTransform}
+ |
+ |${EsbuildWebScripts.esbuildLiveReload}
+ |
+ |${EsbuildWebScripts.serve}
+ |
+ |serve(
+ | $entryPointsJsArray,
+ | $relativeOutputDirectoryJs,
+ | 'assets',
+ | 'sbt-scalajs-esbuild-serve-meta.json',
+ | 8001,
+ | 8000,
+ | $htmlEntryPointsJsArray
+ |);
+ |""".stripMargin
}
) ++ {
var process: Option[Process] = None
@@ -176,60 +233,6 @@ object ScalaJSEsbuildWebPlugin extends AutoPlugin {
}
Seq(
- stageTask / esbuildServeScript := {
- val stageTaskReport = stageTask.value.data
- val moduleConfigurations = esbuildScalaJSModuleConfigurations.value
- val entryPoints =
- extractEntryPointsByPlatform(stageTaskReport, moduleConfigurations)
- .getOrElse(
- EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Browser,
- Set.empty
- )
- val entryPointsJsArray =
- entryPoints.map("'" + _ + "'").mkString("[", ",", "]")
- val targetDirectory = (esbuildInstall / crossTarget).value
- val outputDirectory =
- (stageTask / esbuildServeStart / crossTarget).value
- val relativeOutputDirectory =
- targetDirectory
- .relativize(outputDirectory)
- .getOrElse(
- sys.error(
- s"Target directory [$targetDirectory] must be parent directory of output directory [$outputDirectory]"
- )
- )
- val relativeOutputDirectoryJs = s"'$relativeOutputDirectory'"
- val htmlEntryPoints = esbuildBundleHtmlEntryPoints.value
- require(
- !htmlEntryPoints.forall(_.isAbsolute),
- "HTML entry point paths must be relative"
- )
- val htmlEntryPointsJsArray =
- htmlEntryPoints.map("'" + _ + "'").mkString("[", ",", "]")
-
- // language=JS
- s"""
- |${EsbuildScripts.esbuildOptions}
- |
- |${EsbuildScripts.bundle}
- |
- |${EsbuildWebScripts.htmlTransform}
- |
- |${EsbuildWebScripts.esbuildLiveReload}
- |
- |${EsbuildWebScripts.serve}
- |
- |serve(
- | $entryPointsJsArray,
- | $relativeOutputDirectoryJs,
- | 'assets',
- | 'sbt-scalajs-esbuild-serve-meta.json',
- | 8001,
- | 8000,
- | $htmlEntryPointsJsArray
- |);
- |""".stripMargin
- },
stageTask / esbuildServeStart / crossTarget := (esbuildInstall / crossTarget).value / "www",
stageTask / esbuildServeStart := {
val logger = state.value.globalLogging.full
diff --git a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala
index 45170eec..18611210 100644
--- a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala
+++ b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/EsbuildScripts.scala
@@ -10,7 +10,8 @@ object EsbuildScripts {
| outDirectory,
| outputFilesDirectory,
| hashOutputFiles,
- | minify
+ | minify,
+ | additionalOptions
|) => {
| const path = require('path');
|
@@ -86,7 +87,8 @@ object EsbuildScripts {
| ),
| loader: knownAssetTypes.reduce((a, b) => ({...a, [`.${b}`]: `file`}), {}),
| ...minifyOption,
- | ...publicPathOption
+ | ...publicPathOption,
+ | ...(additionalOptions ? additionalOptions : {})
| };
|}
|""".stripMargin
@@ -101,7 +103,8 @@ object EsbuildScripts {
| outputFilesDirectory,
| hashOutputFiles,
| minify,
- | metaFileName
+ | metaFileName,
+ | additionalEsbuildOptions
|) => {
| const esbuild = require('esbuild');
| const fs = require('fs');
@@ -113,7 +116,8 @@ object EsbuildScripts {
| outDirectory,
| outputFilesDirectory,
| hashOutputFiles,
- | minify
+ | minify,
+ | additionalEsbuildOptions
| )
| );
|
diff --git a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala
index da9e8b5b..8a9cc446 100644
--- a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala
+++ b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/ScalaJSEsbuildPlugin.scala
@@ -86,7 +86,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin {
private lazy val perConfigSettings: Seq[Setting[?]] = Seq(
esbuildScalaJSModuleConfigurations := {
val moduleKind = scalaJSLinkerConfig.value.moduleKind
- val esbuildModuleConfiguration = new EsbuildScalaJSModuleConfiguration(
+ val scalaJSModuleConfiguration = new EsbuildScalaJSModuleConfiguration(
platform = moduleKind match {
case ModuleKind.CommonJSModule =>
EsbuildScalaJSModuleConfiguration.EsbuildPlatform.Node
@@ -96,7 +96,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin {
)
val modules = scalaJSModuleInitializers.value
modules
- .map(module => module.moduleID -> esbuildModuleConfiguration)
+ .map(module => module.moduleID -> scalaJSModuleConfiguration)
.toMap
},
esbuildInstall / crossTarget := {
@@ -153,18 +153,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin {
changeStatus
},
- jsEnvInput := Def.taskDyn {
- val stageTask = scalaJSStage.value.stageTask
- Def.task {
- (stageTask / esbuildBundle).value
-
- jsFileNames(stageTask.value.data)
- .map((stageTask / esbuildBundle / crossTarget).value / _)
- .map(_.toPath)
- .map(Script)
- .toSeq
- }
- }.value,
+ jsEnvInput := jsEnvInputTask.value,
esbuildFastLinkJSWrapper := {
fastLinkJS.value
FileTreeView.default
@@ -207,6 +196,7 @@ object ScalaJSEsbuildPlugin extends AutoPlugin {
installFileChanges ++ stageTaskFileChanges
},
+ // TODO move out of `stageTask` scope
stageTask / esbuildBundle / crossTarget := (esbuildInstall / crossTarget).value / "out",
stageTask / esbuildBundleScript := {
val stageTaskReport = stageTask.value.data
diff --git a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala
index 8c9a8d04..cde9f518 100644
--- a/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala
+++ b/sbt-scalajs-esbuild/src/main/scala/scalajsesbuild/package.scala
@@ -1,9 +1,17 @@
import java.nio.file.Path
+
+import org.scalajs.ir.Names.DefaultModuleID
+import org.scalajs.jsenv.Input
+import org.scalajs.linker.interface.ModuleKind
import org.scalajs.linker.interface.Report
import org.scalajs.linker.interface.unstable
import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.fastLinkJS
import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.fullLinkJS
+import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSStage
import sbt.*
+import sbt.Keys.configuration
+import sbt.Keys.crossTarget
+import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildBundle
import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildFastLinkJSWrapper
import scalajsesbuild.ScalaJSEsbuildPlugin.autoImport.esbuildFullLinkJSWrapper
@@ -114,4 +122,32 @@ package object scalajsesbuild {
)
}
}
+
+ private[scalajsesbuild] def resolveMainModule(
+ report: Report
+ ) = {
+ report.publicModules
+ .find(_.moduleID == DefaultModuleID)
+ .getOrElse(
+ sys.error(
+ "Cannot determine `jsEnvInput`: Linking result does not have a " +
+ s"module named `$DefaultModuleID`. Set jsEnvInput manually?\n" +
+ s"Full report:\n$report"
+ )
+ )
+ }
+
+ private[scalajsesbuild] def jsEnvInputTask = Def.taskDyn {
+ val stageTask = scalaJSStage.value.stageTask
+ Def.task {
+ (stageTask / esbuildBundle).value
+
+ val report = stageTask.value.data
+ val mainModule = resolveMainModule(report)
+
+ val path =
+ ((stageTask / esbuildBundle / crossTarget).value / mainModule.jsFileName).toPath
+ Seq(Input.Script(path))
+ }
+ }
}