Skip to content
Merged
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
51 changes: 51 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Project Agents.md Guide

This is a [MoonBit](https://docs.moonbitlang.com) project.

## Project Structure

- MoonBit packages are organized per directory, for each directory, there is a
`moon.pkg.json` file listing its dependencies. Each package has its files and
blackbox test files (common, ending in `_test.mbt`) and whitebox test files
(ending in `_wbtest.mbt`).

- In the toplevel directory, this is a `moon.mod.json` file listing about the
module and some meta information.

## Coding convention

- MoonBit code is organized in block style, each block is separated by `///|`,
the order of each block is irrelevant. In some refactorings, you can process
block by block independently.

- Try to keep deprecated blocks in file called `deprecated.mbt` in each
directory.

## Tooling

- `moon fmt` is used to format your code properly.

- `moon info` is used to update the generated interface of the package, each
package has a generated interface file `.mbti`, it is a brief formal
description of the package. If nothing in `.mbti` changes, this means your
change does not bring the visible changes to the external package users, it is
typically a safe refactoring.

- In the last step, run `moon info && moon fmt` to update the interface and
format the code. Check the diffs of `.mbti` file to see if the changes are
expected.

- Run `moon test` to check the test is passed. MoonBit supports snapshot
testing, so when your changes indeed change the behavior of the code, you
should run `moon test --update` to update the snapshot.

- You can run `moon check` to check the code is linted correctly.

- When writing tests, you are encouraged to use `inspect` and run
`moon test --update` to update the snapshots, only use assertions like
`assert_eq` when you are in some loops where each snapshot may vary. You can
use `moon coverage analyze > uncovered.log` to see which parts of your code
are not covered by tests.

- agent-todo.md has some small tasks that are easy for AI to pick up, agent is
welcome to finish the tasks and check the box when you are done
2 changes: 1 addition & 1 deletion moon.mod.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oboard/mocket",
"version": "0.5.6",
"version": "0.5.7",
"deps": {
"illusory0x0/native": "0.2.1",
"moonbitlang/x": "0.4.34",
Expand Down
2 changes: 1 addition & 1 deletion src/async.mbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
///|
pub fn run(f : async () -> Unit noraise) -> Unit = "%async.run"
pub fn async_run(f : async () -> Unit noraise) -> Unit = "%async.run"

///|
/// `suspend` 会中断当前协程的运行。
Expand Down
94 changes: 82 additions & 12 deletions src/body_reader.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,89 @@ fn read_body(
body_bytes : BytesView,
) -> HttpBody raise BodyError {
let content_type = req_headers.get("Content-Type")
match content_type {
Some([.. "application/json", ..]) => {
let json = @encoding/utf8.decode(body_bytes) catch {
_ => raise BodyError::InvalidJsonCharset
if content_type is Some(content_type) {
let content_type = parse_content_type(content_type)
if content_type is Some(content_type) {
return match content_type {
{ subtype: "json", .. } => {
let json = @encoding/utf8.decode(body_bytes) catch {
_ => raise BodyError::InvalidJsonCharset
}
Json(@json.parse(json) catch { _ => raise BodyError::InvalidJson })
}
{ media_type: "text", .. } =>
Text(
@encoding/utf8.decode(body_bytes) catch {
_ => raise BodyError::InvalidText
},
)
{ subtype: "x-www-form-urlencoded", .. } =>
Form(parse_form_data(body_bytes))
_ => Bytes(body_bytes)
}
Json(@json.parse(json) catch { _ => raise BodyError::InvalidJson })
}
Some([.. "text/plain", ..] | [.. "text/html", ..]) =>
Text(
@encoding/utf8.decode(body_bytes) catch {
_ => raise BodyError::InvalidText
},
)
_ => Bytes(body_bytes)
}
Bytes(body_bytes)
}

///|
priv struct ContentType {
media_type : StringView
subtype : StringView
params : Map[StringView, StringView]
} derive(Show)

///|
fn parse_content_type(s : String) -> ContentType? {
let parts = s.split(";").to_array()
if parts.is_empty() {
None
} else {
let main_part_str = parts[0].trim_space()
let media_type_parts = main_part_str.split("/").to_array()
if media_type_parts.length() != 2 {
None
} else {
let media_type = media_type_parts[0].trim_space()
let subtype = media_type_parts[1].trim_space()
let params = {}
for i in 1..<parts.length() {
let param_part = parts[i].trim_space()
let eq_index = param_part.find("=")
try {
match eq_index {
Some(idx) => {
let key = param_part[0:idx].trim_space()
let value = param_part[idx + 1:].trim_space()
params[key] = value
}
None => () // Ignore malformed parameters
}
} catch {
_ => ()
}
}
Some({ media_type, subtype, params })
}
}
}

///|
test "parse_content_type" {
inspect(
parse_content_type("application/json; charset=utf-8"),
content=(
#|Some({media_type: "application", subtype: "json", params: {"charset": "utf-8"}})
),
)
}

///|
test "parse_form_data" {
inspect(
parse_form_data(b"name=John+Doe&age=30"),
content=(
#|{"name": "John Doe", "age": "30"}
),
)
}
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated using `moon info`, DON'T EDIT IT
package "oboard/mocket/example"
package "oboard/mocket/examples/route"

// Values

Expand Down
6 changes: 6 additions & 0 deletions src/examples/websocket/moon.pkg.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"is-main": true,
"import": [
"oboard/mocket"
]
}
13 changes: 13 additions & 0 deletions src/examples/websocket/pkg.generated.mbti
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Generated using `moon info`, DON'T EDIT IT
package "oboard/mocket/examples/websocket"

// Values

// Errors

// Types and methods

// Type aliases

// Traits

18 changes: 18 additions & 0 deletions src/examples/websocket/websocket_echo.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
///|
fn main {
let app = @mocket.new([email protected]_logger())
app.ws("/ws", event => match event {
Open(peer) => println("WS open: " + peer.to_string())
Message(peer, body) => {
let msg = match body {
Text(s) => s.to_string()
_ => ""
}
println("WS message: " + msg)
peer.send(msg)
}
Close(peer) => println("WS close: " + peer.to_string())
})
println("WebSocket echo server listening on ws://localhost:8080/ws")
app.serve(port=8080)
}
62 changes: 62 additions & 0 deletions src/handler.mbt
Original file line number Diff line number Diff line change
@@ -1,2 +1,64 @@
///|
pub type HttpHandler = async (HttpEvent) -> HttpBody noraise

///|
pub(all) struct WebSocketPeer {
connection_id : String
mut subscribed_channels : Array[String]
}

///|
pub fn WebSocketPeer::send(self : WebSocketPeer, message : String) -> Unit {
// 真正发送到后端连接
ws_send(self.connection_id, message)
}

///|
pub fn WebSocketPeer::subscribe(self : WebSocketPeer, channel : String) -> Unit {
if not(self.subscribed_channels.contains(channel)) {
self.subscribed_channels.push(channel)
ws_subscribe(self.connection_id, channel)
}
}

///|
pub fn WebSocketPeer::unsubscribe(
self : WebSocketPeer,
channel : String,
) -> Unit {
let mut index = None
for i = 0; i < self.subscribed_channels.length(); i = i + 1 {
if self.subscribed_channels[i] == channel {
index = Some(i)
break
}
}
match index {
Some(i) => {
ignore(self.subscribed_channels.remove(i))
ws_unsubscribe(self.connection_id, channel)
}
None => ()
}
}

///|
pub fn WebSocketPeer::publish(channel : String, message : String) -> Unit {
ws_publish(channel, message)
}

///|
pub fn WebSocketPeer::to_string(self : WebSocketPeer) -> String {
"WebSocketPeer(\{self.connection_id})"
}

///|
pub enum WebSocketEvent {
Open(WebSocketPeer)
Message(WebSocketPeer, HttpBody)
Close(WebSocketPeer)
}

///|
// WebSocket 路由的事件处理器类型(不返回 HttpBody)
pub type WebSocketHandler = (WebSocketEvent) -> Unit
41 changes: 38 additions & 3 deletions src/index.mbt
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
///|
pub(all) struct MultipartFormValue {
filename : String?
content_type : String?
data : BytesView
}

///|
pub(all) enum HttpBody {
Json(Json)
Text(StringView)
HTML(StringView)
Bytes(BytesView)
Form(Map[String, String])
Multipart(Map[String, MultipartFormValue])
Empty
}

///|
#alias(T)
pub(all) struct Mocket {
base_path : String
mappings : Map[(String, String), HttpHandler]
Expand All @@ -18,11 +28,14 @@ pub(all) struct Mocket {
dynamic_routes : Map[String, Array[(String, HttpHandler)]]
// 日志记录器
logger : Logger
// WebSocket 路由(按路径匹配,不区分方法)
ws_static_routes : Map[String, WebSocketHandler]
ws_dynamic_routes : Array[(String, WebSocketHandler)]
ws_clients : Map[String, Unit]
ws_channels : Map[String, Map[String, Unit]]
ws_client_port : Map[String, Int]
}

///|
pub typealias Mocket as T

///|
pub fn new(
base_path? : String = "",
Expand All @@ -35,6 +48,11 @@ pub fn new(
static_routes: {},
dynamic_routes: {},
logger,
ws_static_routes: {},
ws_dynamic_routes: [],
ws_clients: {},
ws_channels: {},
ws_client_port: {},
}
}

Expand Down Expand Up @@ -209,3 +227,20 @@ pub fn Mocket::group(
// 合并中间件
group.middlewares.iter().each(self.middlewares.push(_))
}

///|
// 注册 WebSocket 路由(不区分方法,按路径匹配)
pub fn Mocket::ws(
self : Mocket,
path : String,
handler : WebSocketHandler,
) -> Unit {
let path = self.base_path + path
// 静态路径直接缓存
if path.find(":").unwrap_or(-1) == -1 && path.find("*").unwrap_or(-1) == -1 {
self.ws_static_routes.set(path, handler)
} else {
// 动态路径加入列表
self.ws_dynamic_routes.push((path, handler))
}
}
2 changes: 1 addition & 1 deletion src/js/cast.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ pub impl[A : Cast] Cast for Array[A] with into(value) {
checked_cast_array_ffi(value)
.to_option()
.bind(fn(arr) {
let is_type_a = fn(elem) { not((Cast::into(elem) : A?).is_empty()) }
let is_type_a = fn(elem) { not((Cast::into(elem) : A?) is None) }
if arr.iter().all(is_type_a) {
Some(Value::cast_from(arr).cast())
} else {
Expand Down
1 change: 0 additions & 1 deletion src/js/null.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ pub fn[T] Nullable::is_null(self : Nullable[T]) -> Bool {
}

///|
#deprecated("get_exn does not check for null values. Use unwrap instead")
pub fn[T] Nullable::get_exn(self : Nullable[T]) -> T = "%identity"

///|
Expand Down
1 change: 0 additions & 1 deletion src/js/optional.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ pub fn[T] Optional::is_undefined(self : Optional[T]) -> Bool {
}

///|
#deprecated("get_exn does not check for undefined values. Use unwrap instead")
pub fn[T] Optional::get_exn(self : Optional[T]) -> T = "%identity"

///|
Expand Down
Loading
Loading