diff --git a/build.sbt b/build.sbt index 486e95f0..5b2f23a8 100644 --- a/build.sbt +++ b/build.sbt @@ -34,20 +34,33 @@ lazy val root = project .in(file(".")) .aggregate( + core.js, + core.jvm, lambda.js, lambda.jvm, lambdaEvents.js, lambdaEvents.jvm, lambdaApiGatewayProxyHttp4s.js, - lambdaApiGatewayProxyHttp4s.jvm) + lambdaApiGatewayProxyHttp4s.jvm, + cloudflareWorker + ) .enablePlugins(NoPublishPlugin) +lazy val core = crossProject(JSPlatform, JVMPlatform) + .crossType(CrossType.Pure) + .in(file("core")) + .settings( + name := "feral-core", + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % catsEffectVersion, + ) + ) + lazy val lambda = crossProject(JSPlatform, JVMPlatform) .in(file("lambda")) .settings( name := "feral-lambda", libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-effect" % catsEffectVersion, "io.circe" %%% "circe-core" % circeVersion ) ) @@ -63,6 +76,7 @@ lazy val lambda = crossProject(JSPlatform, JVMPlatform) "io.circe" %%% "circe-fs2" % "0.14.0" ) ) + .dependsOn(core) lazy val lambdaEvents = crossProject(JSPlatform, JVMPlatform) .crossType(CrossType.Pure) @@ -84,3 +98,18 @@ lazy val lambdaApiGatewayProxyHttp4s = crossProject(JSPlatform, JVMPlatform) ) ) .dependsOn(lambda, lambdaEvents) + +lazy val cloudflareWorker = project + .in(file("cloudflare-worker")) + .enablePlugins(ScalaJSPlugin) + .settings( + name := "feral-cloudflare-worker", + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % catsEffectVersion, + "co.fs2" %%% "fs2-core" % fs2Version, + "io.circe" %%% "circe-generic" % circeVersion, + "io.circe" %%% "circe-scalajs" % circeVersion, + "org.http4s" %%% "http4s-client" % http4sVersion + ) + ).dependsOn(core.js) + diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/FetchEventListener.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/FetchEventListener.scala new file mode 100644 index 00000000..f9a11844 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/FetchEventListener.scala @@ -0,0 +1,95 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral +package cloudflare.worker + +import cats.effect.IO +import cats.effect.SyncIO +import cats.effect.kernel.Resource +import cats.effect.std.Supervisor +import io.circe.Decoder +import io.circe.Json +import io.circe.generic.semiauto._ +import io.circe.scalajs._ +import org.http4s.HttpRoutes +import org.http4s.Request +import org.typelevel.vault.Key +import org.http4s.HttpVersion + +trait FetchEventListener extends IOSetup { + + def routes: Resource[IO, HttpRoutes[IO]] + + final type Setup = HttpRoutes[IO] + protected override final val setup = routes + + private def apply(event: facade.FetchEvent): Unit = + event.waitUntil( + Supervisor[IO] + .use { supervisor => + for { + routes <- setupMemo + request <- fromRequest[IO](event.request) + properties <- IO.fromEither(decodeJs[IncomingRequestCfProperties](event.request.cf)) + httpVersion <- IO.fromEither(HttpVersion.fromString(properties.httpProtocol)) + _ <- routes(request + .withHttpVersion(httpVersion) + .withAttribute(FetchEventContext.key, FetchEventContext(supervisor, properties))) + .map(toResponse[IO]) + .foldF(IO.unit)(r => IO(event.respondWith(r))) + } yield () + } + .unsafeToPromise()(runtime)) + + final def main(args: Array[String]): Unit = + addEventListener[facade.FetchEvent]("fetch", apply) + +} + +final case class FetchEventContext( + supervisor: Supervisor[IO], + properties: IncomingRequestCfProperties) + +object FetchEventContext { + private[worker] val key = Key.newKey[SyncIO, FetchEventContext].unsafeRunSync() + def apply(request: Request[IO]): IO[FetchEventContext] = + IO.fromEither(request.attributes.lookup(key).toRight(new NoSuchElementException)) +} + +final case class IncomingRequestCfProperties( + asn: String, + colo: String, + country: Option[String], + httpProtocol: String, + requestPriority: Option[String], + tlsCipher: String, + tlsClientAuth: Option[Json], + tlsVersion: String, + city: Option[String], + continent: Option[String], + latitude: Option[String], + longitude: Option[String], + postalCode: Option[String], + metroCode: Option[String], + region: Option[String], + regionCode: Option[String], + timezone: String +) + +object IncomingRequestCfProperties { + private[worker] implicit def decoder: Decoder[IncomingRequestCfProperties] = deriveDecoder +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/KVNamespace.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/KVNamespace.scala new file mode 100644 index 00000000..38416a0a --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/KVNamespace.scala @@ -0,0 +1,276 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare.worker + +import scala.scalajs.js +import scala.scalajs.js.JSConverters._ +import scala.concurrent.duration.FiniteDuration +import io.circe.Decoder + +import fs2.Stream +import scodec.bits.ByteVector +import io.circe.Encoder +import io.circe.scalajs._ +import cats.effect.kernel.Async +import cats.syntax.all._ +import cats.data.OptionT +import fs2.Chunk +import cats.effect.kernel.Resource + +sealed trait KVNamespace[F[_]] { + + import KVNamespace._ + + def getText(key: String): F[Option[String]] + def getText(key: String, cacheTtl: FiniteDuration): F[Option[String]] + + def get[A: Decoder](key: String): F[Option[A]] + def get[A: Decoder](key: String, cacheTtl: FiniteDuration): F[Option[A]] + + def getByteVector(key: String): F[Option[ByteVector]] + def getByteVector(key: String, cacheTtl: FiniteDuration): F[Option[ByteVector]] + + def getStream(key: String): Resource[F, Option[Stream[F, Byte]]] + def getStream(key: String, cacheTtl: FiniteDuration): Resource[F, Option[Stream[F, Byte]]] + + def getTextWithMetadata[M: Decoder](key: String): F[Option[(String, Option[M])]] + + def getWithMetadata[A: Decoder, M: Decoder](key: String): F[Option[(A, Option[M])]] + + def getByteVectorWithMetadata[M: Decoder](key: String): F[Option[(ByteVector, Option[M])]] + + def getStreamWithMetadata[M: Decoder]( + key: String): Resource[F, Option[(Stream[F, Byte], Option[M])]] + + def put(key: String, value: String): F[Unit] = put[Unit](key, value, PutOptions()) + def put[M: Encoder](key: String, value: String, options: PutOptions[M]): F[Unit] + + def put[A: Encoder](key: String, value: A): F[Unit] = put[A, Unit](key, value, PutOptions()) + def put[A: Encoder, M: Encoder](key: String, value: A, options: PutOptions[M]): F[Unit] = + put(key, Encoder[A].apply(value).noSpaces, options) + + def put(key: String, value: ByteVector): F[Unit] = put[Unit](key, value, PutOptions()) + def put[M: Encoder](key: String, value: ByteVector, options: PutOptions[M]): F[Unit] + + def put(key: String, value: Stream[F, Byte]): F[Unit] = put[Unit](key, value, PutOptions()) + def put[M: Encoder](key: String, value: Stream[F, Byte], options: PutOptions[M]): F[Unit] + + def delete(key: String): F[Unit] + + def list[M: Decoder]: Stream[F, Key[M]] + def list[M: Decoder](prefix: String): Stream[F, Key[M]] + +} + +object KVNamespace { + + final case class Key[+M](name: String, expiration: Option[js.Date], metadata: Option[M]) + + final case class PutOptions[+M]( + expiration: Option[js.Date] = None, + expirationTtl: Option[FiniteDuration] = None, + metadata: Option[M] = None + ) { + private[KVNamespace] def toJS[M0 >: M](implicit e: Encoder[M0]): facade.PutOptions = + facade.PutOptions( + expiration.map(_.getDate() / 1000).orUndefined, + expirationTtl.map(_.toSeconds.toDouble).orUndefined, + metadata.map((_: M0).asJsAny).orUndefined + ) + } + + def apply[F[_]: Async](namespace: String): KVNamespace[F] = + new AsyncKVNamespace[F]( + js.special + .fileLevelThis + .asInstanceOf[js.Dynamic] + .selectDynamic(namespace) + .asInstanceOf[facade.KVNamespace]) + + private final class AsyncKVNamespace[F[_]](kv: facade.KVNamespace)(implicit F: Async[F]) + extends KVNamespace[F] { + + def getText(key: String): F[Option[String]] = + F.fromPromise(F.delay(kv.get(key, facade.GetOptions("text")))) + .map(x => Option(x.asInstanceOf[String])) + + def getText(key: String, cacheTtl: FiniteDuration): F[Option[String]] = + F.fromPromise( + F.delay(kv.get(key, facade.GetOptions("text", cacheTtl.toSeconds.toDouble)))) + .map(x => Option(x.asInstanceOf[String])) + + def get[A: Decoder](key: String): F[Option[A]] = + OptionT( + F.fromPromise(F.delay(kv.get(key, facade.GetOptions("json")))) + .map(x => Option(x.asInstanceOf[js.Any])) + ).semiflatMap(x => F.fromEither(decodeJs[A](x))).value + + def get[A: Decoder](key: String, cacheTtl: FiniteDuration): F[Option[A]] = + OptionT( + F.fromPromise( + F.delay(kv.get(key, facade.GetOptions("json", cacheTtl.toSeconds.toDouble)))) + .map(x => Option(x.asInstanceOf[js.Any])) + ).semiflatMap(x => F.fromEither(decodeJs[A](x))).value + + def getByteVector(key: String): F[Option[ByteVector]] = + OptionT( + F.fromPromise(F.delay(kv.get(key, facade.GetOptions("arrayBuffer")))) + .map(x => Option(x.asInstanceOf[js.typedarray.ArrayBuffer])) + ).map(Chunk.jsArrayBuffer(_).toByteVector).value + + def getByteVector(key: String, cacheTtl: FiniteDuration): F[Option[ByteVector]] = + OptionT( + F.fromPromise( + F.delay(kv.get(key, facade.GetOptions("arrayBuffer", cacheTtl.toSeconds.toDouble)))) + .map(x => Option(x.asInstanceOf[js.typedarray.ArrayBuffer])) + ).map(Chunk.jsArrayBuffer(_).toByteVector).value + + def getStream(key: String): Resource[F, Option[Stream[F, Byte]]] = + OptionT( + Resource.makeCase(F + .fromPromise(F.delay(kv.get(key, facade.GetOptions("stream")))) + .map(x => Option(x.asInstanceOf[facade.ReadableStream[js.typedarray.Uint8Array]]))) { + case (Some(rs), exitCase) => closeReadableStream(rs, exitCase) + case _ => F.unit + } + ).map(fromReadableStream[F]).value + + def getStream(key: String, cacheTtl: FiniteDuration): Resource[F, Option[Stream[F, Byte]]] = + OptionT( + Resource.makeCase( + F.fromPromise( + F.delay(kv.get(key, facade.GetOptions("stream", cacheTtl.toSeconds.toDouble)))) + .map(x => + Option(x.asInstanceOf[facade.ReadableStream[js.typedarray.Uint8Array]]))) { + case (Some(rs), exitCase) => closeReadableStream(rs, exitCase) + case _ => F.unit + } + ).map(fromReadableStream[F]).value + + def getTextWithMetadata[M: Decoder](key: String): F[Option[(String, Option[M])]] = + F.fromPromise( + F.delay(kv.getWithMetadata(key, "text")) + ).flatMap { vm => + OptionT + .fromOption(Option(vm.value.asInstanceOf[String])) + .semiflatMap { value => + OptionT + .fromOption(Option(vm.metadata)) + .semiflatMap(x => F.fromEither(decodeJs[M](x))) + .value + .map((value, _)) + } + .value + } + + def getWithMetadata[A: Decoder, M: Decoder](key: String): F[Option[(A, Option[M])]] = + F.fromPromise( + F.delay(kv.getWithMetadata(key, "json")) + ).flatMap { vm => + OptionT + .fromOption(Option(vm.value.asInstanceOf[js.Any])) + .semiflatMap(x => F.fromEither(decodeJs[A](x))) + .semiflatMap { value => + OptionT + .fromOption(Option(vm.metadata)) + .semiflatMap(x => F.fromEither(decodeJs[M](x))) + .value + .map((value, _)) + } + .value + } + + def getByteVectorWithMetadata[M: Decoder](key: String): F[Option[(ByteVector, Option[M])]] = + F.fromPromise( + F.delay(kv.getWithMetadata(key, "arrayBuffer")) + ).flatMap { vm => + OptionT + .fromOption(Option(vm.value.asInstanceOf[js.typedarray.ArrayBuffer])) + .semiflatMap { value => + OptionT + .fromOption(Option(vm.metadata)) + .semiflatMap(x => F.fromEither(decodeJs[M](x))) + .value + .map((Chunk.jsArrayBuffer(value).toByteVector, _)) + } + .value + } + + def getStreamWithMetadata[M: Decoder]( + key: String): Resource[F, Option[(Stream[F, Byte], Option[M])]] = + OptionT( + Resource.makeCase( + F.fromPromise( + F.delay(kv.getWithMetadata(key, "stream")) + ).flatMap { vm => + OptionT + .fromOption( + Option(vm.value.asInstanceOf[facade.ReadableStream[js.typedarray.Uint8Array]])) + .semiflatMap { value => + OptionT + .fromOption(Option(vm.metadata)) + .semiflatMap(x => F.fromEither(decodeJs[M](x))) + .value + .map((value, _)) + } + .value + }) { + case (Some((rs, _)), exitCase) => closeReadableStream(rs, exitCase) + case _ => F.unit + }).map { + case (rs, m) => + (fromReadableStream(rs), m) + }.value + + def put[M: Encoder](key: String, value: String, options: PutOptions[M]): F[Unit] = + F.fromPromise(F.delay(kv.put(key, value, options.toJS[M]))) + + def put[M: Encoder](key: String, value: ByteVector, options: PutOptions[M]): F[Unit] = + F.fromPromise( + F.delay(kv.put(key, Chunk.byteVector(value).toJSArrayBuffer, options.toJS[M]))) + + def put[M: Encoder](key: String, value: Stream[F, Byte], options: PutOptions[M]): F[Unit] = + ??? + + def delete(key: String): F[Unit] = F.fromPromise(F.delay(kv.delete(key))) + + def list[M: Decoder]: Stream[F, Key[M]] = list[M](js.undefined) + + def list[M: Decoder](prefix: String): Stream[F, Key[M]] = list[M](prefix) + + private def list[M: Decoder](prefix: js.UndefOr[String]): Stream[F, Key[M]] = + Stream + .unfoldLoopEval(js.undefined: js.UndefOr[String]) { cursor => + F.fromPromise(F.delay(kv.list(facade.ListOptions(prefix = prefix, cursor = cursor)))) + .map { response => + (response.keys, if (!response.list_complete) Some(response.cursor) else None) + } + } + .flatMap { keys => + Stream.emits(keys.toSeq).evalMap { key => + OptionT + .fromOption[F](key.metadata.toOption.flatMap(Option(_))) + .semiflatMap(x => F.fromEither(decodeJs[M](x))) + .value + .map { + Key(key.name, key.expiration.toOption.flatMap(Option(_)).map(new js.Date(_)), _) + } + } + } + } + +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/ScheduledEvent.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/ScheduledEvent.scala new file mode 100644 index 00000000..89e01bc9 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/ScheduledEvent.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare.worker + +import scala.scalajs.js + +final case class ScheduledEvent(cron: String, scheduledTime: js.Date) + +object ScheduledEvent { + private[worker] def apply(event: facade.ScheduledEvent): ScheduledEvent = + ScheduledEvent(event.cron, new js.Date(event.scheduledTime)) +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/ScheduledEventListener.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/ScheduledEventListener.scala new file mode 100644 index 00000000..8e6de01e --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/ScheduledEventListener.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral +package cloudflare.worker + +import cats.effect.IO + +trait ScheduledEventListener extends IOSetup { + + def apply(event: ScheduledEvent, setup: Setup): IO[Unit] + + final def main(args: Array[String]): Unit = + addEventListener[facade.ScheduledEvent]( + "scheduled", + event => + event.waitUntil( + setupMemo.flatMap(apply(ScheduledEvent(event), _)).unsafeToPromise()(runtime))) + +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Event.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Event.scala new file mode 100644 index 00000000..2a421941 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Event.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare.worker.facade + +import scala.annotation.nowarn +import scala.scalajs.js + +@js.native +private[worker] trait Event extends js.Object { + @nowarn def waitUntil(promise: js.Promise[Unit]): Unit = js.native +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/FetchEvent.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/FetchEvent.scala new file mode 100644 index 00000000..05a568ab --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/FetchEvent.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare.worker.facade + +import scala.scalajs.js +import scala.scalajs.js.| + +@js.native +private[worker] sealed trait FetchEvent extends Event { + def request: Request = js.native + def respondWith(response: Response | js.Promise[Response]): Unit +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Headers.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Headers.scala new file mode 100644 index 00000000..c9473a56 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Headers.scala @@ -0,0 +1,16 @@ +package feral.cloudflare.worker.facade + +import scala.annotation.nowarn +import scala.scalajs.js +import scala.scalajs.js.annotation.JSGlobal +import scala.scalajs.js.annotation.JSName + +@js.native +@JSGlobal +@nowarn +private[worker] class Headers(map: js.Dictionary[String]) + extends js.Iterable[js.Array[String]] { + + @JSName(js.Symbol.iterator) + def jsIterator(): js.Iterator[js.Array[String]] = js.native +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/KVNamespace.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/KVNamespace.scala new file mode 100644 index 00000000..bd1b6b1e --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/KVNamespace.scala @@ -0,0 +1,106 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare.worker.facade + +import scala.annotation.nowarn +import scala.scalajs.js +import scala.scalajs.js.| + +@js.native +@nowarn +private[worker] sealed trait KVNamespace extends js.Object { + + def get(key: String, options: GetOptions): js.Promise[ + String | js.Any | js.typedarray.ArrayBuffer | ReadableStream[js.typedarray.Uint8Array]] + + def getWithMetadata(key: String, `type`: String): js.Promise[KVValueWithMetadata] = js.native + + def put( + key: String, + value: String | ReadableStream[js.typedarray.Uint8Array] | js.typedarray.ArrayBuffer, + options: js.UndefOr[PutOptions] = js.native): js.Promise[Unit] = js.native + + def delete(key: String): js.Promise[Unit] = js.native + + def list(options: js.UndefOr[ListOptions]): js.Promise[ListResponse] + +} + +@js.native +private[worker] trait GetOptions extends js.Object + +private[worker] object GetOptions { + def apply( + `type`: js.UndefOr[String] = js.undefined, + cacheTtl: js.UndefOr[Double] = js.undefined + ): GetOptions = + js.Dynamic.literal(`type` = `type`, cacheTtl = cacheTtl).asInstanceOf[GetOptions] +} + +@js.native +private[worker] sealed trait KVValueWithMetadata extends js.Object { + def value + : String | js.Any | js.typedarray.ArrayBuffer | ReadableStream[js.typedarray.Uint8Array] = + js.native + def metadata: js.Any = js.native +} + +@js.native +private[worker] trait PutOptions extends js.Object + +private[worker] object PutOptions { + def apply( + expiration: js.UndefOr[Double] = js.undefined, + expirationTtl: js.UndefOr[Double] = js.undefined, + metadata: js.UndefOr[js.Any] = js.undefined + ): PutOptions = js + .Dynamic + .literal(expiration = expiration, expirationTtl = expirationTtl, metadata = metadata) + .asInstanceOf[PutOptions] +} + +@js.native +private[worker] trait ListOptions extends js.Object + +private[worker] object ListOptions { + def apply( + prefix: js.UndefOr[String] = js.undefined, + limit: js.UndefOr[Int] = js.undefined, + cursor: js.UndefOr[String] = js.undefined + ): ListOptions = js + .Dynamic + .literal( + prefix = prefix, + limit = limit, + cursor = cursor + ) + .asInstanceOf[ListOptions] +} + +@js.native +private[worker] sealed trait ListResponse extends js.Object { + def keys: js.Array[Key] = js.native + def list_complete: Boolean = js.native + def cursor: js.UndefOr[String] = js.native +} + +@js.native +private[worker] sealed trait Key extends js.Object { + def name: String = js.native + def expiration: js.UndefOr[Double] = js.native + def metadata: js.UndefOr[js.Any] = js.native +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/ReadableStream.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/ReadableStream.scala new file mode 100644 index 00000000..8b16ad91 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/ReadableStream.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare.worker.facade + +import scala.scalajs.js +import scala.annotation.nowarn + +@js.native +@nowarn +private[worker] sealed trait ReadableStream[R] extends js.Object { + def locked: Boolean = js.native + def cancel(reason: js.UndefOr[js.Any]): js.Promise[Unit] = js.native + def getReader(): ReadableStreamDefaultReader[R] = js.native +} + +@js.native +private[worker] sealed trait ReadableStreamDefaultReader[R] extends js.Object { + def read(): js.Promise[ReadableStreamDefaultReadResult[R]] = js.native + def releaseLock(): Unit = js.native +} + +@js.native +private[worker] sealed trait ReadableStreamDefaultReadResult[R] extends js.Object { + def done: Boolean = js.native + def value: R = js.native +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Request.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Request.scala new file mode 100644 index 00000000..668de3c2 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Request.scala @@ -0,0 +1,42 @@ +package feral.cloudflare.worker.facade + +import scala.annotation.nowarn +import scala.scalajs.js +import scala.scalajs.js.annotation.JSGlobal + +@js.native +@JSGlobal +@nowarn +private[worker] class Request(input: String, init: RequestInit) extends js.Object { + def body: ReadableStream[js.typedarray.Uint8Array] = js.native + def bodyUsed: Boolean = js.native + def cf: IncomingRequestCfProperties = js.native + def headers: Headers = js.native + def method: String = js.native + def redirect: String = js.native + def url: String = js.native +} + +private[worker] sealed trait RequestInit extends js.Any + +private[worker] object RequestInit { + def apply( + cf: js.UndefOr[RequestInitCfProperties] = js.undefined, + method: js.UndefOr[String] = js.undefined, + headers: js.UndefOr[Headers] = js.undefined, + body: js.UndefOr[ReadableStream[js.typedarray.Uint8Array]] = js.undefined, + redirect: js.UndefOr[String] = js.undefined + ): RequestInit = + js.Dynamic + .literal( + cf = cf, + method = method, + headers = headers, + body = body, + redirect = redirect + ).asInstanceOf[RequestInit] +} + +@js.native +private[worker] trait IncomingRequestCfProperties extends js.Any +private[worker] sealed trait RequestInitCfProperties extends js.Any diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Response.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Response.scala new file mode 100644 index 00000000..fcc2a7f3 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/Response.scala @@ -0,0 +1,37 @@ +package feral.cloudflare.worker.facade + +import scala.annotation.nowarn +import scala.scalajs.js +import scala.scalajs.js.annotation.JSGlobal + +@js.native +@JSGlobal +@nowarn +private[worker] class Response( + body: js.UndefOr[ReadableStream[js.typedarray.Uint8Array]] = js.undefined, + init: js.UndefOr[ResponseInit] = js.undefined) + extends js.Object { + def body: ReadableStream[js.typedarray.Uint8Array] = js.native + def bodyUsed: Boolean = js.native + def encodeBody: String = js.native + def headers: Headers = js.native + def ok: Boolean = js.native + def redirected: Boolean = js.native + def status: Int = js.native + def statusText: String = js.native + def url: String = js.native +} + +@js.native +private[worker] sealed trait ResponseInit extends js.Any + +private[worker] object ResponseInit { + def apply( + status: js.UndefOr[Int] = js.undefined, + statusText: js.UndefOr[String] = js.undefined, + headers: js.UndefOr[Headers] = js.undefined + ): ResponseInit = + js.Dynamic + .literal(status = status, statusText = statusText, headers = headers) + .asInstanceOf[ResponseInit] +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/ScheduledEvent.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/ScheduledEvent.scala new file mode 100644 index 00000000..21fbd4d5 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/facade/ScheduledEvent.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare.worker.facade + +import scala.scalajs.js + +@js.native +private[worker] sealed trait ScheduledEvent extends Event { + def cron: String = js.native + def scheduledTime: Double = js.native +} diff --git a/cloudflare-worker/src/main/scala/feral/cloudflare/worker/package.scala b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/package.scala new file mode 100644 index 00000000..ac2cb323 --- /dev/null +++ b/cloudflare-worker/src/main/scala/feral/cloudflare/worker/package.scala @@ -0,0 +1,105 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral.cloudflare + +import cats.effect.kernel.Async +import cats.effect.kernel.Resource +import cats.syntax.all._ +import fs2.Stream +import org.http4s.Header +import org.http4s.Headers +import org.http4s.Request +import org.http4s.Response + +import scala.annotation.nowarn +import scala.scalajs.js +import scala.scalajs.js.JSConverters._ +import org.http4s.Method +import org.http4s.Uri +import org.http4s.Status + +package object worker { + + @js.native + @js.annotation.JSGlobal("addEventListener") + @nowarn + private[worker] def addEventListener[E <: facade.Event]( + `type`: String, + listener: js.Function1[E, Unit]): Unit = js.native + + private[worker] def fromRequest[F[_]](req: facade.Request)(implicit F: Async[F]): F[Request[F]] = for { + method <- F.fromEither(Method.fromString(req.method)) + uri <- F.fromEither(Uri.fromString(req.url)) + headers = fromHeaders(req.headers) + body = fromReadableStream(req.body) + } yield Request(method, uri, headers = headers, body = body) + + private[worker] def toRequest[F[_]](req: Request[F]): facade.Request = ??? + + private[worker] def fromResponse[F[_]](res: facade.Response)(implicit F: Async[F]): F[Response[F]] = for { + status <- F.fromEither(Status.fromIntAndReason(res.status, res.statusText)) + headers = fromHeaders(res.headers) + body = fromReadableStream(res.body) + } yield Response(status, headers = headers, body = body) + + private[worker] def toResponse[F[_]](req: Response[F]): facade.Response = ??? + + private[worker] def toHeaders(headers: Headers): facade.Headers = + new facade.Headers( + headers + .headers + .view + .map { + case Header.Raw(name, value) => + name.toString -> value + } + .toMap + .toJSDictionary) + + private[worker] def fromHeaders(headers: facade.Headers): Headers = + Headers( + headers.toIterable.map { header => header(0) -> header(1) }.toList + ) + + private[worker] def fromReadableStream[F[_]]( + rs: facade.ReadableStream[js.typedarray.Uint8Array])( + implicit F: Async[F]): Stream[F, Byte] = + Stream.bracket(F.delay(rs.getReader()))(r => F.delay(r.releaseLock())).flatMap { reader => + Stream.unfoldChunkEval(reader) { reader => + F.fromPromise(F.delay(reader.read())).map { chunk => + if (chunk.done) + None + else + Some((fs2.Chunk.uint8Array(chunk.value), reader)) + } + } + } + + private[worker] def toReadableStream[F[_]](s: Stream[F, Byte]): facade.ReadableStream[js.typedarray.Uint8Array] = ??? + + private[worker] def closeReadableStream[F[_], A]( + rs: facade.ReadableStream[A], + exitCase: Resource.ExitCase)(implicit F: Async[F]): F[Unit] = exitCase match { + case Resource.ExitCase.Succeeded => + F.fromPromise(F.delay(rs.cancel(js.undefined))).void + case Resource.ExitCase.Errored(ex) => + F.fromPromise(F.delay(rs.cancel(ex.toString().asInstanceOf[js.Any]))).void + case Resource.ExitCase.Canceled => + F.fromPromise(F.delay(rs.cancel(js.undefined))).void + } + +} diff --git a/core/src/main/scala/feral/IOSetup.scala b/core/src/main/scala/feral/IOSetup.scala new file mode 100644 index 00000000..8cf84dca --- /dev/null +++ b/core/src/main/scala/feral/IOSetup.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2021 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feral + +import cats.effect.unsafe.IORuntime +import cats.effect.kernel.Resource +import cats.effect.IO +import cats.effect.kernel.Deferred +import cats.syntax.all._ + +trait IOSetup { + + protected def runtime: IORuntime = IORuntime.global + + protected type Setup + protected val setup: Resource[IO, Setup] = Resource.pure(null.asInstanceOf[Setup]) + + private[feral] final val setupMemo: IO[Setup] = { + val deferred = Deferred.unsafe[IO, Either[Throwable, Setup]] + setup + .attempt + .allocated + .flatTap { + case (setup, _) => + deferred.complete(setup) + } + .unsafeRunAndForget()(runtime) + deferred.get.rethrow + } + +} diff --git a/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala b/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala index d5e7e0a0..81d22e81 100644 --- a/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala +++ b/lambda/shared/src/main/scala/feral/lambda/IOLambda.scala @@ -14,38 +14,18 @@ * limitations under the License. */ -package feral.lambda +package feral +package lambda -import cats.effect.Deferred import cats.effect.IO -import cats.effect.Resource -import cats.effect.unsafe.IORuntime -import cats.syntax.all._ import io.circe.Decoder import io.circe.Encoder abstract class IOLambda[Event, Result]( implicit private[lambda] val decoder: Decoder[Event], private[lambda] val encoder: Encoder[Result] -) extends IOLambdaPlatform[Event, Result] { - - protected def runtime: IORuntime = IORuntime.global - - protected type Setup - protected val setup: Resource[IO, Setup] = Resource.pure(null.asInstanceOf[Setup]) - - private[lambda] final val setupMemo: IO[Setup] = { - val deferred = Deferred.unsafe[IO, Either[Throwable, Setup]] - setup - .attempt - .allocated - .flatTap { - case (setup, _) => - deferred.complete(setup) - } - .unsafeRunAndForget()(runtime) - deferred.get.rethrow - } +) extends IOLambdaPlatform[Event, Result] + with IOSetup { def apply(event: Event, context: Context, setup: Setup): IO[Option[Result]] }