Skip to content

feat: S3 support #1611

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions gradle/elide.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ auto-common = "1.2.2"
auto-factory = "1.1.0"
auto-service = "1.1.1"
auto-value = "1.11.0"
aws-sdk = "2.31.74"
bouncycastle = "1.81"
brotli = "0.1.2"
brotli4j = "1.18.0"
Expand Down Expand Up @@ -194,6 +195,7 @@ larray = "0.2.1"
lettuce = "6.2.5.RELEASE"
lmaxDisruptor = "4.0.0"
lmaxDisruptorProxy = "3.1.1"
locals3 = "1.25"
logback = "1.5.18"
lz4 = "1.3.0"
markdown = "0.7.3"
Expand Down Expand Up @@ -363,6 +365,8 @@ asm-core = { group = "org.ow2.asm", name = "asm", version.ref = "asm" }
asm-tree = { group = "org.ow2.asm", name = "asm-tree", version.ref = "asm" }
assertk = { group = "com.willowtreeapps.assertk", name = "assertk", version.ref = "assertk" }
autoService-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version = "1.1.0" }
aws-sdk-bom = { group = "software.amazon.awssdk", name = "bom", version.ref = "aws-sdk" }
aws-sdk-s3 = { group = "software.amazon.awssdk", name = "s3" }
bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" }
bouncycastle-pkix = { group = "org.bouncycastle", name = "bcpkix-jdk18on", version.ref = "bouncycastle" }
bouncycastle-tls = { group = "org.bouncycastle", name = "bctls-jdk18on", version.ref = "bouncycastle" }
Expand Down Expand Up @@ -706,6 +710,7 @@ larray-mmap = { group = "org.xerial.larray", name = "larray-mmap", version.ref =
lettuce-core = { group = "io.lettuce", name = "lettuce-core", version.ref = "lettuce" }
lmax-disruptor-core = { group = "com.lmax", name = "disruptor", version.ref = "lmaxDisruptor" }
lmax-disruptor-proxy = { group = "com.lmax", name = "disruptor-proxy", version.ref = "lmaxDisruptorProxy" }
locals3 = { group = "io.github.robothy", name = "local-s3-rest", version.ref = "locals3" }
logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
logback-core = { group = "ch.qos.logback", name = "logback-core", version.ref = "logback" }
lz4 = { group = "net.jpountz.lz4", name = "lz4", version.ref = "lz4" }
Expand Down
4 changes: 4 additions & 0 deletions packages/graalvm/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -555,10 +555,14 @@ dependencies {
api(libs.graalvm.polyglot.js.community)
}

implementation(platform(libs.aws.sdk.bom))
implementation(libs.aws.sdk.s3)

// Testing
testApi(project(":packages:engine", configuration = "testInternals"))
testApi(libs.graalvm.truffle.api)
testApi(libs.graalvm.truffle.runtime)
testImplementation(libs.locals3)
testImplementation(libs.jackson.core)
testImplementation(libs.jackson.databind)
testImplementation(libs.jackson.module.kotlin)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package elide.runtime.intrinsics.js.s3

import org.graalvm.polyglot.Value
import org.graalvm.polyglot.proxy.ProxyObject
import elide.runtime.gvm.js.JsError
import elide.runtime.intrinsics.js.Blob
import elide.runtime.intrinsics.js.JsPromise
import elide.runtime.intrinsics.js.ReadableStream
import elide.runtime.node.buffer.GuestBytes
import elide.vm.annotations.Polyglot

private const val ACCESS_KEY_ID = "accessKeyId"
private const val SECRET_ACCESS_KEY = "secretAccessKey"
private const val BUCKET = "bucket"
private const val SESSION_TOKEN = "sessionToken"
private const val ACL = "acl"
private const val ENDPOINT = "endpoint"
private const val REGION = "region"
private const val VIRTUAL_HOSTED_STYLE = "virtualHostedStyle"

/**
* # S3
*
* Facade for calling out to AWS S3 SDK. Meant to be used to provide an S3
* API from within the Elide runtime.
*
* It consists of two major objects, the S3Client which tracks the configuration for authentication and how to connect
* and the S3File which represents a file in S3 that can be read, written to, or presigned.
*
*  
*
* ## Usage
*
* In the following example, the S3Client will connect to Backblaze B2 (an S3 compatible service)
* using a path style endpoint. If the endpoint is not specified, by default it will use AWS S3.
* If region is unspecified it will use us-east-1.
* It will use path style URLs by default.
*
* Please look at AWS's S3 documentation or the documentation for whatever S3 compatible API is being used for more
* information on what these options mean.
*
* ```javascript
* const { S3Client } = require("elide:s3");
* const client = new S3Client({
* // required options
* accessKeyId: "...",
* secretAccessKey: "...",
* bucket: "my-bucket",
* // optional options
* sessionToken: "...",
* acl: "public-read",
* endpoint: "https://s3.us-east-005.backblazeb2.com/my-bucket",
* region: "us-east-005",
* virtualHostedStyle: false
* });
* const file = client.file("test.txt");
* await file.write("This is a test", { type: "text/plain" });
* console.assert(await file.text() === "This is a test");
* ```
*
* Write method takes either a String or any array type object and returns a promise
* containing the content length.
*
* Read methods include: text, json, bytes, and arrayBuffer. Which are self-explanatory.
*
* Presign allows one to presign a URL to be passed to the client so the client can do the
* actual action, minimizing a redundant data transfer, a common pattern with S3 usage.
* eg
* ```
* file.presign({ method: "GET", expiresIn: 3600 }); // expiresIn is in seconds
* ```
*
* `delete` and `unlink` are identical. Both allow the user to delete a file from S3.
*
* Finally, `stat` returns the metadata and objects in the HTTP header in a JSON format. It
* is equivalent to the "HeadObject" action.
*/
public interface S3Client : ProxyObject {
@Polyglot public fun file(path: String): S3File
}

public interface S3File : ProxyObject {
// write methods
@Polyglot public fun write(data: String, contentType: String?): JsPromise<Number>
@Polyglot public fun write(data: GuestBytes, contentType: String?): JsPromise<Number>
@Polyglot public fun write(data: Blob, contentType: String?): JsPromise<Number>
@Polyglot public fun write(data: ReadableStream, contentType: String?): JsPromise<Number>

// read methods
@Polyglot public fun text(): JsPromise<String>
@Polyglot public fun json(): JsPromise<Value>
@Polyglot public fun bytes(): JsPromise<Value>
@Polyglot public fun arrayBuffer(): JsPromise<Value>

@Polyglot public fun presign(method: String, duration: Long): String
@Polyglot public fun delete(): JsPromise<Unit>

@Polyglot public fun exists(): JsPromise<Boolean>
@Polyglot public fun stat(): JsPromise<ProxyObject>
}

@JvmRecord public data class S3ClientConstructorOptions(
@get:Polyglot val accessKeyId: String,
@get:Polyglot val secretAccessKey: String,
@get:Polyglot val bucket: String,
@get:Polyglot val sessionToken: String?,
@get:Polyglot val acl: String?,
@get:Polyglot val endpoint: String?,
@get:Polyglot val region: String?,
@get:Polyglot val virtualHostedStyle: Boolean?
) {
public companion object {
@JvmStatic public fun from(value: Value): S3ClientConstructorOptions {
return S3ClientConstructorOptions(
// required
accessKeyId = value.getMember(ACCESS_KEY_ID).asString()
?: throw JsError.typeError("S3Client constructor missing accessKeyId"),
secretAccessKey = value.getMember(SECRET_ACCESS_KEY).asString()
?: throw JsError.typeError("S3Client constructor missing secretAccessKey"),
bucket = value.getMember(BUCKET).asString()
?: throw JsError.typeError("S3Client constructor missing bucket"),

// optional
sessionToken = value.getMember(SESSION_TOKEN)?.asString(),
acl = value.getMember(ACL)?.asString(),
endpoint = value.getMember(ENDPOINT)?.asString(),
region = value.getMember(REGION)?.asString(),
virtualHostedStyle = value.getMember(VIRTUAL_HOSTED_STYLE)?.asBoolean()
)
}
}
}
Loading
Loading