-
Notifications
You must be signed in to change notification settings - Fork 23
feat: add wasm-gc support for Promise bridging, ReadableStream, and HTTP client #315
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
Open
duobei
wants to merge
8
commits into
moonbitlang:main
Choose a base branch
from
duobei:feat/wasm-gc-promise-http
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
0f15d84
feat: add wasm-gc support for timer, event loop, and time
duobei a7a6d9a
docs: fix wasm-gc-imports.js header comment
duobei c4416f8
feat: add wasm-gc support for Promise bridging, ReadableStream, and H…
duobei da6b6b8
docs: fix wasm-gc-imports.js usage examples to match actual exports
duobei da10060
ci: re-trigger CI
duobei 845498a
test: update readdir snapshot to include wasm-gc files
duobei 249894d
style: fix moon fmt formatting
duobei 5fd8987
ci: retry flaky request cancel test
duobei File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,298 @@ | ||
| // Copyright 2025 International Digital Economy Academy | ||
| // | ||
| // 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. | ||
|
|
||
| ///| | ||
| #external | ||
| priv type JsHeaders | ||
|
|
||
| ///| | ||
| fn JsHeaders::new() -> JsHeaders = "moonbitlang_async_http" "new_headers" | ||
|
|
||
| ///| | ||
| fn JsHeaders::append(headers : JsHeaders, name : String, value : String) = "moonbitlang_async_http" "headers_append" | ||
|
|
||
| ///| | ||
| fn JsHeaders::for_each( | ||
| headers : JsHeaders, | ||
| callback : (String, String) -> Unit, | ||
| ) -> Unit = "moonbitlang_async_http" "headers_for_each" | ||
|
|
||
| ///| | ||
| #external | ||
| priv type JsResponse | ||
|
|
||
| ///| | ||
| fn JsResponse::status(response : JsResponse) -> Int = "moonbitlang_async_http" "response_status" | ||
|
|
||
| ///| | ||
| fn JsResponse::status_text(response : JsResponse) -> String = "moonbitlang_async_http" "response_status_text" | ||
|
|
||
| ///| | ||
| fn JsResponse::headers(response : JsResponse) -> JsHeaders = "moonbitlang_async_http" "response_headers" | ||
|
|
||
| ///| | ||
| /// Local externref aliases for cross-package types used in WASM import | ||
| /// signatures. The wasm-gc backend requires WASM import parameters and | ||
| /// return types to be defined in the same compilation unit. | ||
| #external | ||
| priv type RawExternRef | ||
|
|
||
| ///| | ||
| fn[X, Y] raw_cast(x : X) -> Y = "%identity" | ||
|
|
||
| ///| | ||
| fn wasm_response_body(response : JsResponse) -> RawExternRef = "moonbitlang_async_http" "response_body" | ||
|
|
||
| ///| | ||
| fn JsResponse::body(response : JsResponse) -> @js_async.JsReadableStream { | ||
| raw_cast(wasm_response_body(response)) | ||
| } | ||
|
|
||
| ///| | ||
| priv struct OngoingRequest { | ||
| body_writer : @io.PipeWrite | ||
| abort_controller : @js_async.AbortController | ||
| response : @js_async.Promise[JsResponse] | ||
| } | ||
|
|
||
| ///| | ||
| struct Client { | ||
| host : String | ||
| port : Int | ||
| protocol : Protocol | ||
| headers : Map[String, String] | ||
| mut request : OngoingRequest? | ||
| mut response_body : @js_async.ReadableStream? | ||
| } | ||
|
|
||
| ///| | ||
| pub fn Client::close(self : Client) -> Unit { | ||
| if self.request is Some(request) { | ||
| request.body_writer.close() | ||
| self.request = None | ||
| } | ||
| if self.response_body is Some(response_body) { | ||
| response_body.close() | ||
| } | ||
| } | ||
|
|
||
| ///| | ||
| fn Client::connect( | ||
| host : String, | ||
| headers? : Map[String, String] = {}, | ||
| protocol? : Protocol = Https, | ||
| port? : Int = protocol.default_port(), | ||
| proxy? : Client, | ||
| ) -> Client { | ||
| ignore(proxy) | ||
| { host, headers, protocol, port, request: None, response_body: None } | ||
| } | ||
|
|
||
| ///| | ||
| /// Create a new HTTP client by connecting to a remote host. | ||
| /// Host should be specified via `protocol://host[:port]`, | ||
| /// where `protocol` is one of `http` or `https`. | ||
| /// | ||
| /// `headers` can be used to specify persistent headers for the client, | ||
| /// i.e. all requests made from this client will share these headers. | ||
| /// The ownership of `headers` will be transferred to the new client, | ||
| /// so `headers` should not be used by the caller later. | ||
| /// The headers mentioned in | ||
| /// https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header | ||
| /// must not be set in `headers`. | ||
| /// | ||
| /// The HTTP client make requests using native fetch API. | ||
| /// | ||
| /// The `proxy` argument is not supported on wasm-gc backend and has no effect. | ||
| #warnings("-unused_async") | ||
| pub async fn Client::new( | ||
| uri : String, | ||
| headers? : Map[String, String] = {}, | ||
| proxy? : Client, | ||
| ) -> Client { | ||
| ignore(proxy) | ||
| let (protocol, port, host, path) = resolve_url(uri) | ||
| guard path is "/" else { raise InvalidFormat } | ||
| Client::connect(host, protocol~, port~, headers~, proxy?) | ||
| } | ||
|
|
||
| ///| | ||
| pub impl @io.Writer for Client with write_once(self, buf, offset~, len~) { | ||
| guard self.request is Some(request) | ||
| request.body_writer.write_once(buf, offset~, len~) | ||
| } | ||
|
|
||
| ///| | ||
| #warnings("-unused_async") | ||
| pub async fn Client::flush(_ : Client) -> Unit { | ||
| () | ||
| } | ||
|
|
||
| ///| | ||
| pub impl @io.Reader for Client with _get_internal_buffer(self) { | ||
| guard self.response_body is Some(stream) | ||
| stream._get_internal_buffer() | ||
| } | ||
|
|
||
| ///| | ||
| pub impl @io.Reader for Client with _direct_read(self, buf, offset~, max_len~) { | ||
| guard self.response_body is Some(stream) | ||
| stream._direct_read(buf, offset~, max_len~) | ||
| } | ||
|
|
||
| ///| | ||
| pub async fn Client::end_request(self : Client) -> Response { | ||
| guard self.request is Some(request) | ||
| self.request = None | ||
| request.body_writer.close() | ||
| let js_response : JsResponse = request.response.wait( | ||
| abort_controller=request.abort_controller, | ||
| ) | ||
| self.response_body = Some( | ||
| @js_async.ReadableStream::from_js(js_response.body()), | ||
| ) | ||
| let headers = {} | ||
| js_response | ||
| .headers() | ||
| .for_each(fn(name, value) { headers[name.to_lower()] = value }) | ||
| { code: js_response.status(), reason: js_response.status_text(), headers } | ||
| } | ||
|
|
||
| ///| | ||
| fn wasm_fetch_request( | ||
| uri : String, | ||
| meth : String, | ||
| headers : JsHeaders, | ||
| body : RawExternRef, | ||
| signal : RawExternRef, | ||
| ) -> RawExternRef = "moonbitlang_async_http" "fetch_request" | ||
|
|
||
| ///| | ||
| fn request_ffi( | ||
| uri : String, | ||
| meth : String, | ||
| headers : JsHeaders, | ||
| body : @js_async.JsReadableStream, | ||
| signal : @js_async.AbortSignal, | ||
| ) -> @js_async.Promise[JsResponse] { | ||
| raw_cast( | ||
| wasm_fetch_request(uri, meth, headers, raw_cast(body), raw_cast(signal)), | ||
| ) | ||
| } | ||
|
|
||
| ///| | ||
| /// Send a HTTP request to the server. | ||
| /// Only the header of the request will be sent, | ||
| /// request body can be sent by using `Client` as a `@io.Writer`. | ||
| /// Once request body has been sent, | ||
| /// `end_request` must be called to complete the request and obtain response from the server. | ||
| /// | ||
| /// After performing a request, | ||
| /// the next request MUST NOT be made before the request is completed via `end_request`. | ||
| /// | ||
| /// In addition to headers in `Client::new`, | ||
| /// extra HTTP headers can be passed via `extra_headers`. | ||
| /// The headers mentioned in | ||
| /// https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header | ||
| /// must not be set in `extra_headers`. | ||
| #warnings("-unused_async") | ||
| pub async fn Client::request( | ||
| self : Client, | ||
| meth : RequestMethod, | ||
| path : String, | ||
| extra_headers? : Map[String, String] = {}, | ||
| ) -> Unit { | ||
| guard self.request is None | ||
| let protocol = match self.protocol { | ||
| Http => "http://" | ||
| Https => "https://" | ||
| } | ||
| let path = if path is ['/', ..] { path } else { "/\{path}" } | ||
| let port = if self.port == self.protocol.default_port() { | ||
| "" | ||
| } else { | ||
| ":\{self.port}" | ||
| } | ||
| let uri = "\{protocol}\{self.host}\{port}\{path}" | ||
| let meth = match meth { | ||
| Get => "GET" | ||
| Head => "HEAD" | ||
| Post => "POST" | ||
| Put => "PUT" | ||
| Delete => "DELETE" | ||
| Connect => "CONNECT" | ||
| Options => "OPTIONS" | ||
| Trace => "TRACE" | ||
| Patch => "PATCH" | ||
| } | ||
| let headers = JsHeaders::new() | ||
| for k, v in self.headers { | ||
| headers.append(k, v) | ||
| } | ||
| for k, v in extra_headers { | ||
| headers.append(k, v) | ||
| } | ||
| let abort_controller = @js_async.AbortController::new() | ||
| let (body, we_write) = @js_async.JsReadableStream::new_pipe() | ||
| let response_promise = request_ffi( | ||
| uri, | ||
| meth, | ||
| headers, | ||
| body, | ||
| abort_controller.signal(), | ||
| ) | ||
| let request : OngoingRequest = { | ||
| abort_controller, | ||
| body_writer: we_write, | ||
| response: response_promise, | ||
| } | ||
| self.request = Some(request) | ||
| } | ||
|
|
||
| ///| | ||
| /// Perform a `GET` request to the server, see `Client::request` for more details. | ||
| pub async fn Client::get( | ||
| self : Client, | ||
| path : String, | ||
| extra_headers? : Map[String, String] = {}, | ||
| body? : &@io.Data, | ||
| ) -> Response { | ||
| self.request(Get, path, extra_headers~) | ||
| if body is Some(body) { | ||
| self.write(body) | ||
| } | ||
| self.end_request() | ||
| } | ||
|
|
||
| ///| | ||
| /// Perform a `PUT` request to the server, see `Client::request` for more details. | ||
| pub async fn Client::put( | ||
| self : Client, | ||
| path : String, | ||
| body : &@io.Data, | ||
| extra_headers? : Map[String, String] = {}, | ||
| ) -> Response { | ||
| self..request(Put, path, extra_headers~)..write(body).end_request() | ||
| } | ||
|
|
||
| ///| | ||
| /// Perform a `POST` request to the server, see `Client::request` for more details. | ||
| pub async fn Client::post( | ||
| self : Client, | ||
| path : String, | ||
| body : &@io.Data, | ||
| extra_headers? : Map[String, String] = {}, | ||
| ) -> Response { | ||
| self..request(Post, path, extra_headers~)..write(body).end_request() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| // Import object for moonbitlang/async http wasm-gc fetch client. | ||
| // When loading the compiled wasm-gc module, merge this into the import object: | ||
| // | ||
| // import { moonbitlang_async_http } from "./wasm-gc-imports.js"; | ||
| // const imports = { ...otherImports, moonbitlang_async_http }; | ||
| // const { instance } = await WebAssembly.instantiateStreaming(fetch("module.wasm"), imports); | ||
|
|
||
|
duobei marked this conversation as resolved.
|
||
| export const moonbitlang_async_http = { | ||
| new_headers: () => new Headers(), | ||
| headers_append: (h, name, value) => h.append(name, value), | ||
| headers_for_each: (h, callback) => { | ||
| for (const [name, value] of h.entries()) callback(name, value); | ||
| }, | ||
| response_status: (r) => r.status, | ||
| response_status_text: (r) => r.statusText, | ||
| response_headers: (r) => r.headers, | ||
| response_body: (r) => r.body, | ||
| fetch_request: (uri, method, headers, body, signal) => { | ||
| const fixed_body = (method === "GET" || method === "HEAD") ? null : body; | ||
| return fetch(uri, { | ||
| body: fixed_body, | ||
| method: method, | ||
| headers: headers, | ||
| signal: signal, | ||
| duplex: 'half', | ||
| }); | ||
|
duobei marked this conversation as resolved.
|
||
| }, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // Copyright 2025 International Digital Economy Academy | ||
| // | ||
| // 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. | ||
|
|
||
| ///| | ||
| let _ignore_unused_import : Unit = { | ||
| ignore(@c_buffer.unimplemented) | ||
| ignore(@fd_util.unimplemented) | ||
| ignore(@os_string.unimplemented) | ||
| ignore(@os_error.unimplemented) | ||
| ignore(@time.ms_since_epoch) | ||
| } | ||
|
|
||
| ///| | ||
| pub fn reschedule() -> Unit { | ||
| guard !@coroutine.no_more_work() else { } | ||
| @coroutine.reschedule() | ||
| // Instead of looping blockingly until there is no ready task, | ||
| // we only perform one round of scheduling here | ||
| // (i.e., only those tasks already ready when `reschedule` is called are run). | ||
| // Remaining tasks are delayed until the next event loop iteration, | ||
| // so that those blocking jobs got a chance to execute instead of starving. | ||
| if @coroutine.has_immediately_ready_task() { | ||
| ignore(wasm_set_timeout(0, reschedule)) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.