Сегодня мы создадим приложение для простой игры. Это может быть одна из тех опасных игр, участники которых рискуют жизнью (как в телесериале “Игра в кальмара”). А может оказаться одной из невинных забав, позволяющих весело провести время на дружеской онлайн-вечеринке.
Для нашей игры понадобятся две команды, каждая из которых выбирает одинаковое количество URL-адресов в Интернете. Приложение, которое мы будем создавать, показывает в браузере изображения, полученные по этим URL. Поэтому выбранные URL-адреса должны отображать какие-то визуализации.
Правила игры очень просты. Прежде чем одна команда введет URL в браузер, другая должна угадать с первого раза, какое изображение появится на экране приложения. Так они делают поочередно. Необходимо подобрать URL-адреса, по которым трудно определить, что именно будет показано. Побеждает команда, назвавшая больше правильных вариантов изображений по URL-адресам соперника.
Правила игры позволяют вам заранее посмотреть картинки, скрывающиеся за URL-адресами, выбранными вашей командой (но не URL-адресами, подобранными соперником).
Средством отображения картинок из URL в приложении будет простая модель Puppeteer — самого популярного безголового (headless) Chrome API. Использовать Puppeteer довольно просто: достаточно вызвать его API, который позволит манипулировать безголовым Chrome из JavaScript.
Чтобы несколько команд могли играть одновременно, потребуется несколько экземпляров сервера для совместного выполнения множественных процессов. При разработке приложения, предназначенного для одновременного использования, крайне важно правильно реализовать параллельные процессы
Параллельное программирование: проблемы и решения
Параллельное программирование — это одновременное выполнение нескольких процессов. Оно достигается за счет использования многоядерных процессоров с несколькими потоками, параллельно выполняющимися в приложении.
К сожалению, распараллеливание потоков сопровождается гонками по данным. Чтобы избежать возможных проблем при доступе к ресурсам, разделяемым несколькими потоками, разработчики должны программно контролировать взаимодействие параллельных потоков, используя механизмы синхронизации, такие как блокировки, мьютексы, семафоры, защелки, мониторы и т.д. Поскольку параллельное программирование становится все более сложным в современных приложениях, становится все труднее правильно спроектировать все механизмы. Чрезмерное использование или неправильная реализация могут замедлить общую скорость выполнения системы или даже сделать программу неработоспособной из-за блокировок потоков.
Даже сегодня, когда облачные сервисы стали обычным явлением для параллельного запуска процессов приложений, трудности, с которыми сталкиваются разработчики при кодировании и управлении параллельными процессами, остаются нерешенными. К счастью, простая модель разработки, предложенная программистами почти 50 лет назад, позволяет реализовать масштабируемые распределенные приложения, не страдая от упомянутых проблем.
Модель акторов: особенности и преимущества
Акторы, лежащие в основе простой модели разработки, связаны между собой асинхронным обменом сообщениями. В отличие от классов в объектно-ориентированном программировании, в концепции акторов нет понятия реализации интерфейса. Поэтому ее нелегко описать в терминах объектно-ориентированного программирования.
Мы не знаем, что именно представляет собой актор, но когда запрашиваем его с помощью сообщения, он может ответить сообщением или не ответить вообще. Он может не ответить на запрошенное сообщение синхронно; однако, если полученное сообщение соответствует ожиданиям актора, он может начать асинхронное выполнение задачи в фоновом режиме и в конечном итоге вернуть ответ. Это абстракция вычислений в сочетании с концепцией времени, необходимого для их выполнения.
Архитектура программного обеспечения, в которой акторы взаимодействуют посредством асинхронного обмена сообщениями в многосерверной среде выполнения, представляет собой модель акторов, предложенную доктором Карлом Хьюиттом из Массачусетского технологического института в 1973 году.
При реализации приложения, созданного на основе модели акторов, разработчики должны учитывать четыре принципа, или возможности:
- Акторы могут получать сообщения.
- Акторы могут создавать новых акторов.
- Акторы могут посылать сообщения акторам, причем адресатом сообщений могут быть они сами.
- Акторы являются FSM (Finite State Machine — система с конечным числом состояний). Изменение своего состояния с помощью сообщения также означает, что актор решает, какое сообщение он получит следующим.
Для получения дополнительной информации о модели акторов, включая вышеизложенное, обратитесь к видео и документам на сайте доктора Карла Хьюитта.
https://professorhewitt.blogspot.com/
Разработка акторов имеет преимущество перед параллельным программированием, которое является сложной задачей для обеспечения корректности в объектно-ориентированном программировании. Ресурс, на который ссылается обработка одного актора, является приватным для этого актора. Он недоступен непосредственно для обработки другими акторами.
Без условий гонки ресурсов разработчики могут реализовать безопасное распараллеливание, не задумываясь над реализацией управления потоками. Более того, поскольку сообщения, посылаемые актором, отражают результаты обработки, точность актора может быть легко проверена. Этим модель акторов выгодно отличается от многопоточного программирования, при котором поведение параллельных процессов необходимо подтверждать, проходя по коду с помощью отладчика или наблюдая за выводом на консоль.
Поскольку акторы обрабатывают только одно сообщение в каждом цикле, порядок выполнения более предсказуем, чем в многопоточной среде, где безопасное и надежное распараллеливание труднодостижимо при объектно-ориентированном программировании.
Каждый актор является полностью независимой системой состояний и играет детерминированную роль. В концепцию акторов не входит понятие интерфейса, который используется при объектно-ориентированном программировании.
Акторы слабо связаны друг с другом посредством асинхронного обмена сообщениями. Поэтому рефакторинг или изменение кода можно легко осуществить без влияния на остальную часть приложения. Такой подход напоминает микросервисы, ставшие популярными в последние несколько лет.
Модели акторов и микросервисы
Тогда как REST и gRPC применяются для синхронного взаимодействия с микросервисами, обмен сообщениями через промежуточное ПО, такое как Kafka и AMQP, используется для асинхронного взаимодействия между микросервисами. Поскольку модель акторов также использует асинхронный обмен сообщениями, можно предположить, что взаимодействие акторов на основе сообщений похоже на асинхронную коммуникацию в микросервисах. Но это не совсем одно и то же.
Обмен сообщениями в модели разработки акторов предназначен не только для асинхронной обработки, но и для достижения эффективного распараллеливания без использования механизмов синхронизации во избежание таких проблем, как состояние гонки. Очередь сообщений актора (или почтовый ящик) концептуально содержится внутри отдельного актора как его часть, в отличие от канала или темы в промежуточном ПО обмена сообщениями, к которым обращаются несколько потоков. Ограничивая количество потоков, выполняющих обработку актора, только одним за цикл, каждое состояние актора без борьбы за ресурсы можно сравнить с блоком кода, так сказать, безопасным для потока. Это делает его подходящим местом для запуска API, не безопасных для потока, таких как Puppeteer.
Инструменты для создания приложений на основе модели акторов
Существует несколько OSS-фреймворков и библиотек для реализации приложений с использованием модели акторов. Самым популярным из них набором инструментов является, пожалуй, Akka Toolkit, разработанный для Scala/Java компанией Lightbend. Однако, поскольку Puppeteer является API Node, Akka, разработанная для Scala/Java, не может напрямую исполнять его API.
Поэтому, узнав о компиляторе Scala.js, который может “переводить” код Scala в JavaScript, я рассмотрел возможность компиляции Akka в JavaScript и запуска его на node.js.
Я нашел репозиторий на GitHub под названием Akka.js, представляющий собой порт Akka для запуска в браузере или на node.js.
В каталогах исходников Akka.js обнаружилось несколько исходных файлов. К моему удивлению, большинство из них оказались пустыми. Оказывается, Akka.js берет исходные файлы из оригинального репозитория Akka, разработанного для Scala/Java, и копирует их в свои пустые каталоги исходников во время сборки. Затем в эти несколько исходных файлов вносятся изменения, необходимые для запуска программы в среде выполнения JavaScript.
Таким образом, Akka.js — это более или менее исходный код Akka, скомпилированный с помощью Scala.js. Что еще более удивительно, так это то, что он действительно работает на node.js. К сожалению, Akka.js не эквивалентен JVM Akka с точки зрения функциональности. Например, у него нет таких же распространяемых функциональных возможностей, как у Scala/Java Akka. Вам понадобится инфраструктура, позволяющая запускать Akka.js на нескольких серверах.
Однако, достаточно слов — перейдем к делу!
Написание кода для игры с помощью Akka.js
Чтобы использовать Scala.js, понадобится плагин sbt.
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.7.1")
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta36")
Нам придется использовать Akka.js в проекте, собранном на Scala3, поэтому указываем .cross(CrossVersion.for3Use2_13). У нас нет другого способа его создать, кроме как собрать в Scala3. Если этого не сделать, не удастся сгенерировать определения типов для Scala из определений TypeScript для express.js.
Чтобы запросить Akka.js Actor для работы Puppeteer через HTTP и получить результирующие данные изображения, собранные в одном HTML-файле, используем express.js. С помощью ScalablyTyped также можно извлекать типы Scala из TypeScript-определений Puppeteer и использовать их в Akka.js. Установим библиотеку cheerio для выполнения манипуляций с DOM в акторе.
lazy val root = (project in file("."))
.enablePlugins(ScalablyTypedConverterPlugin)
.configure(baseSettings, bundlerSettings, nodeProject)
.settings(
useYarn := true,
name := "squid-game",
scalaJSUseMainModuleInitializer := true,
libraryDependencies += ("org.akka-js" %%% "akkajsactortyped" % "2.2.6.14").cross(CrossVersion.for3Use2_13),
libraryDependencies += ("org.akka-js" %%% "akkajstypedtestkit" % "2.2.6.14" % "test").cross(CrossVersion.for3Use2_13),
libraryDependencies += ("org.akka-js" %%% "akkajsactorstreamtyped" % "2.2.6.14").cross(CrossVersion.for3Use2_13),
Compile / npmDependencies ++= Seq(
"puppeteer" -> "11.0.0",
"@types/express" -> "4.17.13",
"express" -> "4.17.1",
"express-async-handler" -> "1.2.0",
"cheerio" -> "1.0.0-rc.10"
)
)
lazy val baseSettings: Project => Project =
_.enablePlugins(ScalaJSPlugin)
.settings(scalaVersion := "3.1.0",
version := "0.1.0-SNAPSHOT",
scalacOptions ++= Seq("-deprecation", "-feature", "-unchecked"),
scalaJSUseMainModuleInitializer := true,
scalaJSLinkerConfig ~= (_
/* disabled because it somehow triggers many warnings */
.withSourceMap(false)
.withModuleKind(ModuleKind.CommonJSModule))
)
lazy val bundlerSettings: Project => Project =
_.settings(
Compile / fastOptJS / webpackExtraArgs += "--mode=development",
Compile / fullOptJS / webpackExtraArgs += "--mode=production",
Compile / fastOptJS / webpackDevServerExtraArgs += "--mode=development",
Compile / fullOptJS / webpackDevServerExtraArgs += "--mode=production"
)
val nodeProject: Project => Project =
_.settings(
jsEnv := new org.scalajs.jsenv.nodejs.NodeJSEnv,
stStdlib := List("esnext"),
stUseScalaJsDom := false,
Compile / npmDependencies ++= Seq(
"@types/node" -> "16.11.7"
)
)
Вот реализация специфического поведения для манипуляций Puppeteer с акторами Akka.js. Я не объясняю, как Puppeteer обрабатывает запросы и результаты — рекомендую обратиться к документации Lightbend по реализации акторов. Тем не менее, надеюсь, что использованный мной шаблон реализации будет полезен для вашего проекта.
Вы заметили, что я реализовал все четыре вышеупомянутых принципа в этих двух акторах? Например, актор PuppeteerPage должен остановиться, если не получает никаких сообщений в течение 5 минут. Как вы помните, акторы могут посылать сообщения акторам, а адресатом сообщений могут быть они сами. Применение этого принципа можно заметить там, где актор планирует сообщение для своего завершения. Еще одна реализация использует этот же принцип, узнаваемый при тщательном изучении кода, который вы найдете в PuppeteerBrowser.
Я реализовал оба актора так, чтобы они постоянно возвращались в активное состояние, и буферизованные сообщения всегда обрабатывались, когда поток впоследствии обрабатывает актор.
Прочитав коды активного состояния обоих акторов, вы сможете понять общий ход процесса. Если же вы опытный программист, то увидите, что добавить или изменить поведение актора относительно просто.
package example
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer}
import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy}
import akka.pattern.StatusReply
import akka.util.Timeout
import typings.node.bufferMod.global.Buffer
import typings.puppeteer.mod.Browser
import java.util.UUID.randomUUID
import scala.concurrent.ExecutionContext
import scala.concurrent.duration.{DurationDouble, FiniteDuration}
import scala.reflect.classTag
import scala.scalajs.js.Thenable.Implicits.*
import scala.util.{Failure, Success}
object PuppeteerBrowser {
sealed trait Command
case class CapturePageContent(id: String, url: String, replyTo: ActorRef[StatusReply[Buffer]]) extends Command
case class CommandFailed(throwable: Throwable, replyTo: ActorRef[StatusReply[String]]) extends Command
class Actor(context: ActorContext[Command], buffer: StashBuffer[Command])(implicit ex: ExecutionContext) {
implicit val timeout: Timeout = 30.seconds
private def initializing(pages: Map[String, ActorRef[PuppeteerPage.Command]] = Map.empty): Behavior[Command] = Behaviors.receiveMessage {
case BrowserCreated(browser) =>
context.log.info("Browser Created")
idle(browser, pages)
case InitializationFailed(throwable) =>
context.log.error("Failed to initialize Browser")
throw throwable
case other =>
buffer.stash(other)
Behaviors.same
}
private def idle(browser: Browser, pages: Map[String, ActorRef[PuppeteerPage.Command]]): Behavior[Command] =
buffer.unstashAll(active(browser, pages))
private def active(browser: Browser, pages: Map[String, ActorRef[PuppeteerPage.Command]]): Behavior[Command] =
Behaviors.receiveMessagePartial {
case command@CapturePageContent(id, url, replyTo) =>
pages.get(id).fold(create(browser, pages, id, command)) {
page =>
context.log.info(s"capturing page content from url for $id")
page ! PuppeteerPage.CapturePageContent(url, replyTo)
Behaviors.same
}
case PageTerminated(id) =>
context.log.info(s"page for $id terminated, removing page from $pages")
active(browser, pages - id)
}
private def create(browser: Browser,
pages: Map[String, ActorRef[PuppeteerPage.Command]],
id: String, request: Command): Behavior[Command] = {
context.self ! request
active(browser, pages + (id -> newPage(browser, id)))
}
private def newPage(browser: Browser, id: String): ActorRef[PuppeteerPage.Command] = {
context.log.info(s"creating Page for $id")
val page = context.spawnAnonymous(PuppeteerPage.Actor(browser))
context.watchWith(page, PageTerminated(id))
page
}
}
private case class BrowserCreated(browser: Browser) extends Command
private case class PageTerminated(id: String) extends Command
private case class InitializationFailed(throwable: Throwable) extends Command
object Actor {
def apply()(implicit ec: ExecutionContext): Behavior[Command] =
Behaviors.supervise[Command](
Behaviors.withStash(100) { buffer =>
Behaviors.setup {
context =>
context.pipeToSelf(typings.puppeteer.mod.launch()) {
case Success(browser) => BrowserCreated(browser)
case Failure(exception) =>
InitializationFailed(exception)
}
new Actor(context, buffer).initializing()
}
}).onFailure(SupervisorStrategy.stop)(classTag[Throwable])
}
}
package example
import akka.Done
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer, TimerScheduler}
import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy}
import akka.pattern.StatusReply
import org.scalablytyped.runtime.StringDictionary
import typings.cheerio.cheerioMod.Cheerio
import typings.cheerio.mod.Node
import typings.devtoolsProtocol.mod.Protocol.Network.ResourceType
import typings.node.bufferMod.global.{Buffer, BufferEncoding}
import typings.node.nodeStrings.undefined
import typings.node.nodeUrlMod.URL
import typings.puppeteer.anon.WaitForOptionsrefererstriTimeout
import typings.puppeteer.mod.*
import typings.puppeteer.mod.global.{Document, Element, NodeListOf}
import typings.puppeteer.puppeteerStrings.{request, response}
import wvlet.airframe.log
import typings.cheerio.{loadMod, mod as cheerio}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.{DurationDouble, FiniteDuration}
import scala.concurrent.{ExecutionContext, Future}
import scala.reflect.classTag
import scala.scalajs.js
import scala.scalajs.js.Object.keys
import scala.scalajs.js.Promise
import scala.scalajs.js.Thenable.Implicits.*
import scala.util.chaining.scalaUtilChainingOps
import scala.util.{Failure, Success, Try}
object PuppeteerPage {
trait Command
case class CapturePageContent(url: String, replyTo: ActorRef[StatusReply[Buffer]]) extends Command
case class PageContentCaptured(buffer: Buffer) extends Command
case class CommandFailed(throwable: Throwable) extends Command
private case class Created(page: typings.puppeteer.mod.Page) extends Command
private case class InitializationFailed(throwable: Throwable) extends Command
private class Actor(context: ActorContext[Command],
buffer: StashBuffer[Command],
timers: TimerScheduler[Command]) {
private val resources = scala.collection.mutable.Set[String]()
val handler: js.Function1[HTTPResponse, Unit] = res => {
res.headers().get("content-type").fold(Future.successful(resources)) { value =>
value match {
case "image/jpeg" | "image/png" | "image/gif" =>
res.buffer().map(buffer => resources += s"data:$value;charset=utf-8;base64,"
.concat(buffer.toString(BufferEncoding.base64)))
case other => resources
}
}
}
def initialize(): Behavior[Command] =
Behaviors.receiveMessage {
case Created(page) =>
page.on_response(response, handler)
idle(page)
case InitializationFailed(throwable) =>
context.log.error("Initialization failed")
Behaviors.stopped
case other =>
buffer.stash(other)
Behaviors.same
}
def idle(page: Page): Behavior[Command] = {
if (timers.isTimerActive(TimeoutKey)) timers.cancel(TimeoutKey)
timers.startSingleTimer(TimeoutKey, ClosePage, 5.minutes)
buffer.unstashAll(active(page))
}
def terminating: Behavior[Command] =
Behaviors.receiveMessagePartial {
case CapturePageContent(url, replyTo) =>
replyTo ! StatusReply.Error("Actor is terminating")
Behaviors.same
case PageClosed =>
context.log.info("Page closed")
Behaviors.stopped
case CommandFailed(throwable) =>
context.log.error(s"failed to close Page ${throwable.getCause.getLocalizedMessage}", throwable.getCause)
Behaviors.same
}
def capturingPageContent(page: Page, replyTo: ActorRef[StatusReply[Buffer]]): Behavior[Command] =
Behaviors.receiveMessage {
case PageContentCaptured(value) =>
replyTo ! StatusReply.Success(value)
idle(page)
case CommandFailed(throwable) =>
replyTo ! StatusReply.Error(throwable)
idle(page)
case other =>
buffer.stash(other)
Behaviors.same
}
def active(page: Page): Behavior[Command] = Behaviors.receiveMessage {
case CapturePageContent(url, replyTo) =>
resources.clear()
context.pipeToSelf(capturePageContent(page, url)) {
case Success(value) => PageContentCaptured(value)
case Failure(throwable) => CommandFailed(throwable)
}
capturingPageContent(page, replyTo)
case ClosePage =>
context.pipeToSelf(page.close()) {
case Success(_) => PageClosed
case Failure(throwable) => CommandFailed(throwable)
}
terminating
}
def capturePageContent(page: Page, url: String): Future[Buffer] = {
val option = ScreenshotOptions()
option.fullPage = true
option.captureBeyondViewport = true
for {_ <- page.setViewport(Viewport(1024, 768))
_ <- page.goto(url, WaitForOptionsrefererstriTimeout().setWaitUntil(PuppeteerLifeCycleEvent.domcontentloaded))
pageContent <- page.content().map(content => render(content, url))
} yield Buffer.from(pageContent)
}
def render(content: String, url: String): String = {
val $ = cheerio.load(s"<table id=\"images\"><thead>$url</thead><tbody></tbody></table>")
resources.foldLeft($("#images > tbody")) {(images, value) => images.append(js.Array(s"<tr><td><image src=\"$value\"></tr>"))}
$.html()
}
}
case object ClosePage extends Command
object Actor {
def apply(browser: Browser)(implicit ec: ExecutionContext): Behavior[Command] =
Behaviors.supervise[Command](
Behaviors.withStash(100) { buffer =>
Behaviors.setup {
context =>
Behaviors.withTimers { timers =>
context.pipeToSelf(browser.newPage()) {
case Success(page) => Created(page)
case Failure(exception) =>
InitializationFailed(exception)
}
new Actor(context, buffer, timers).initialize()
}
}
}).onFailure(SupervisorStrategy.stop)(classTag[Throwable])
}
private case object PageClosed extends Command
private case object TimeoutKey
}
App запускает приложение с помощью этих двух акторов, управляющих Puppeteer. Запрос, полученный express.js, преобразуется в сообщение и отправляется акторам, и наоборот.
package example
import akka.actor.typed.ActorSystem
import akka.actor.typed.scaladsl.AskPattern.{Askable, schedulerFromActorSystem}
import akka.util.Timeout
import com.typesafe.config.ConfigFactory
import example.asyncHandler.{Req, Res}
import org.scalablytyped.runtime.StringDictionary
import typings.express.mod as express
import typings.node.bufferMod.global.Buffer
import wvlet.airframe.log
import java.net.URLDecoder
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.{DurationDouble, FiniteDuration}
import scala.concurrent.{ExecutionContext, Future}
import scala.scalajs.js
import scala.scalajs.js.{isUndefined, undefined}
import scala.util.{Failure, Success}
object App {
implicit val system: ActorSystem[PuppeteerBrowser.Command] = ActorSystem(PuppeteerBrowser.Actor(), "actorSystem")
implicit val timeout: Timeout = 30.seconds
val Handler = asyncHandler((req, res, next) => handleRequest(req, res))
val app = express()
def handleRequest(req: Req, res: Res): Future[Any] = (for {
id <- req.params.get("id")
url <- req.query.asInstanceOf[StringDictionary[String]].get("url")
} yield system.askWithStatus[Buffer](PuppeteerBrowser.CapturePageContent(id, url, _)) map { buffer =>
res.set("Content-Type", "text/html")
res.send(buffer)
}).getOrElse(Future.successful {
res.set("Content-Type", "text/html")
res.send(Buffer.from("requires both id and url"))
})
app.get("/:id", Handler)
log.initNoColor
def main(args: Array[String]): Unit = app.listen(3000, () => {
println("Server started!")
})
}
import typings.express.mod as express
import typings.express.mod.{RequestHandler, request_=}
import typings.expressServeStaticCore.mod.*
import typings.node.bufferMod.global.Buffer
import typings.node.processMod as process
import wvlet.airframe.log
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.DurationInt
import scala.concurrent.{ExecutionContext, Future}
import scala.language.postfixOps
import scala.scalajs.js
import scala.scalajs.js.JSConverters.*
import scala.scalajs.js.Object.{entries, keys}
import scala.scalajs.js.Thenable.Implicits.*
import scala.scalajs.js.UndefOr
package object example {
trait ReqBody extends js.Object {
val payload: js.UndefOr[js.Any]
}
object asyncHandler {
type Req = Request[ParamsDictionary, Buffer, ReqBody, Query, typings.std.Record[String, js.Any]]
type Res = Response[Buffer, typings.std.Record[String, js.Any], Double]
type Handler = RequestHandler[ParamsDictionary, Buffer, ReqBody, Query, typings.std.Record[String, js.Any]]
def apply(fn: js.Function3[Req, Res, NextFunction, Future[Any]]): Handler =
typings.expressAsyncHandler.mod(
(param) => handleRequest(param.asInstanceOf[Req], fn))
private def handleRequest(request: Req,
fn: js.Function3[Req, Res, NextFunction, Future[Any]]): UndefOr[js.Promise[Unit]] =
for {res <- request.res
next <- request.next} yield fn(request, res, next).map(_ => ()).toJSPromise
}
}
По ссылке вы найдете информацию о том, как взаимодействовать с акторами и играть в игру. Можете использовать любой идентификатор для начала пути для URL (например, 1234).
Ну что, сыграем?
И напоследок: вот репозиторий GitHub, где вы найдете весь исходный код, включая приведенный выше.
Читайте также:
- Пять причин поместить функции в класс
- Введение в WebAssembly (WASM)
- Кто есть кто: обратные вызовы, промисы и асинхронные функции
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Haruhiko Nishi, Run Puppeteer in Akka.js