├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── cmd └── flatend │ └── main.go ├── config.toml ├── examples ├── go │ ├── broadcast │ │ ├── README.md │ │ └── main.go │ ├── clock │ │ ├── README.md │ │ └── main.go │ ├── counter │ │ ├── README.md │ │ ├── config.toml │ │ └── main.go │ ├── file │ │ ├── README.md │ │ ├── config.toml │ │ └── main.go │ ├── hello_world │ │ ├── README.md │ │ ├── config.toml │ │ └── main.go │ ├── pipe │ │ ├── README.md │ │ ├── config.toml │ │ └── main.go │ └── todo │ │ ├── README.md │ │ ├── config.toml │ │ ├── main.go │ │ └── public │ │ ├── index.html │ │ ├── main.js │ │ └── style.css └── nodejs │ ├── counter │ ├── README.md │ ├── config.toml │ ├── index.js │ ├── package.json │ └── yarn.lock │ ├── e2e │ ├── README.md │ ├── bootstrap.js │ ├── node.js │ └── package.json │ ├── file │ ├── README.md │ ├── config.toml │ ├── index.js │ ├── package.json │ └── yarn.lock │ ├── hello_world │ ├── README.md │ ├── config.toml │ ├── index.js │ ├── package.json │ └── yarn.lock │ ├── pipe │ ├── README.md │ ├── config.toml │ ├── index.js │ ├── package.json │ └── yarn.lock │ └── todo │ ├── README.md │ ├── config.toml │ ├── index.js │ ├── package.json │ ├── public │ ├── index.html │ ├── main.js │ └── style.css │ └── yarn.lock ├── flathttp ├── config.go ├── middleware.go └── service.go ├── go.mod ├── go.sum ├── io.go ├── net.go ├── node.go ├── nodejs ├── README.md ├── package.json ├── src │ ├── context.ts │ ├── index.ts │ ├── kademlia.ts │ ├── net.ts │ ├── node.ts │ ├── packet.ts │ ├── provider.ts │ ├── session.ts │ └── stream.ts ├── tests │ ├── globals.d.spec.ts │ ├── sock.spec.ts │ └── tsconfig.json ├── tsconfig.json └── yarn.lock ├── packet.go ├── packet_test.go └── provider.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | **/node_modules/ 3 | **/dist/ 4 | /flatend 5 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | builds: 5 | - main: ./cmd/flatend/ 6 | id: "flatend" 7 | binary: flatend 8 | env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - darwin 12 | - linux 13 | - freebsd 14 | - windows 15 | goarch: 16 | - amd64 17 | - arm 18 | - arm64 19 | - 386 20 | goarm: 21 | - 6 22 | - 7 23 | checksum: 24 | name_template: 'checksums.txt' 25 | snapshot: 26 | name_template: "{{ .Tag }}-next" 27 | changelog: 28 | sort: asc 29 | filters: 30 | exclude: 31 | - '^docs:' 32 | - '^test:' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kenta Iwasaki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flatend 2 | 3 | [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](LICENSE) 4 | [![Discord Chat](https://img.shields.io/discord/697002823123992617)](https://discord.gg/HZEbkeQ) 5 | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/lithdew/flatend) 6 | [![npm version](https://img.shields.io/npm/v/flatend.svg?style=flat)](https://www.npmjs.com/package/flatend) 7 | [![npm downloads](https://img.shields.io/npm/dm/flatend.svg?style=flat)](https://www.npmjs.com/package/flatend) 8 | [![Security Responsible Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/nodejs/security-wg/blob/master/processes/responsible_disclosure_template.md) 9 | 10 | 11 | 12 | 13 | **flatend** is an experimental framework and protocol to make microservices more modular, simpler, safer, cheaper, and faster to build using [p2p networking](https://github.com/lithdew/monte). 14 | 15 | **flatend** aims to provide the benefits low-code tools try to bring to increase developer productivity, but with [zero vendor lock-in](https://news.ycombinator.com/item?id=20985429), [strong performance](https://projectricochet.com/blog/top-10-meteor-performance-problems), and [zero bias towards certain coding styles/patterns](https://news.ycombinator.com/item?id=12166666). 16 | 17 | ## Features 18 | 19 | * Fully agnostic and compatible with any type of language, database, tool, library, or framework. 20 | * P2P-based service discovery, load balancing, routing, and PKI via [Kademlia](https://en.wikipedia.org/wiki/Kademlia). 21 | * Fully-encrypted, end-to-end, bidirectional streaming RPC via [Monte](https://github.com/lithdew/monte). 22 | * Automatic reconnect/retry upon crashes or connection loss. 23 | * Zero-hassle serverless: every function is a microservice. 24 | * Stream multiple gigabytes of data across microservices. 25 | 26 | ## Gateways 27 | 28 | **flatend** additionally comes with scalable, high-performance, production-ready, easily-deployable API gateways that are bundled into a [small, single executable binary](https://github.com/lithdew/flatend/releases) to help you quickly deploy your microservices. 29 | 30 | * Written in [Go](https://golang.org/). 31 | * HTTP/1.1, HTTP/2 support. 32 | * Automatic HTTPS via [LetsEncrypt](https://letsencrypt.org/). 33 | * Expose/load-balance across microservices. 34 | * Serve static files and directories. 35 | * REPL for real-time management (*coming soon!*). 36 | * Prometheus metrics (*coming soon!*). 37 | * WebSocket support (*coming soon!*). 38 | * gRPC support (*coming soon!*). 39 | 40 | All gateways have been extensively tested on [Rackspace](https://www.rackspace.com/), [Scaleway](https://www.scaleway.com/en/), [AWS](https://aws.amazon.com/), [Google Cloud](https://cloud.google.com/), and [DigitalOcean](https://www.digitalocean.com/). 41 | 42 | 43 | ## Requirements 44 | 45 | Although **flatend** at its core is a protocol, and hence agnostic to whichever programming langauge you use, there are currently only two reference implementations in NodeJS and Go. 46 | 47 | - NodeJS v12.18.1+ (Windows, Linux, Mac) 48 | - Go v1.14.1 (Windows, Linux Mac) 49 | 50 | The rationale for starting with NodeJS and Go is so that, for any new product/service, you may: 51 | 52 | 1. Quickly prototype and deploy in NodeJS with SQLite using a 2USD/month bare-metal server. 53 | 2. Once you start scaling up, split up your microservice and rewrite the performance-critical parts in Go. 54 | 3. Run a red/blue deployment easily to gradually deploy your new microservices and experience zero downtime. 55 | 56 | Support is planned for the following runtimes/languages: 57 | 58 | 1. [Zig v0.7+](https://ziglang.org/) 59 | 2. [Deno v1.0+](https://deno.land/) 60 | 3. [Python v3.8+](https://www.python.org/) 61 | 62 | Have any questions? Come chat with us on [Discord](https://discord.gg/HZEbkeQ). 63 | 64 | ## Usage 65 | 66 | To get started quickly, download the API gateway binary for your platform [here](https://github.com/lithdew/flatend/releases). Otherwise, build the binary from source by following the instructions [here](#build-from-source). 67 | 68 | Create a new `config.toml`, and paste in: 69 | 70 | ```toml 71 | addr = "127.0.0.1:9000" 72 | 73 | [[http]] 74 | addr = ":3000" 75 | 76 | [[http.routes]] 77 | path = "GET /hello" 78 | service = "hello_world" 79 | ``` 80 | 81 | Run: 82 | 83 | ```shell 84 | $ ./flatend 85 | 2020/06/18 04:07:07 Listening for Flatend nodes on '127.0.0.1:9000'. 86 | 2020/06/18 04:07:07 Listening for HTTP requests on '[::]:3000'. 87 | ``` 88 | 89 | Now, let's build your first microservice in [Go](#go)/[NodeJS](#nodejs). 90 | 91 | ### Go 92 | 93 | Add [`flatend`](https://pkg.go.dev/github.com/lithdew/flatend) to a new Go modules project. 94 | 95 | ```shell 96 | $ go mod init github.com/lithdew/flatend-testbed 97 | go: creating new go.mod: module github.com/lithdew/flatend-testbed 98 | 99 | $ go get github.com/lithdew/flatend 100 | go: downloading github.com/lithdew/flatend vX.X.X 101 | go: github.com/lithdew/flatend upgrade => vX.X.X 102 | ``` 103 | 104 | Write a function that describes how to handle requests for the service `hello_world` in `main.go`. 105 | 106 | ```go 107 | package main 108 | 109 | import "github.com/lithdew/flatend" 110 | 111 | func helloWorld(ctx *flatend.Context) { 112 | ctx.WriteHeader("Content-Type", "text/plain; charset=utf-8") 113 | ctx.Write([]byte("Hello world!")) 114 | } 115 | ``` 116 | 117 | Register the function as a handler for the service `hello_world`. 118 | 119 | ```go 120 | func main() { 121 | _ = &flatend.Node{ 122 | Services: map[string]flatend.Handler{ 123 | "hello_world": helloWorld, 124 | }, 125 | } 126 | } 127 | ``` 128 | 129 | Start the node and have it connect to Flatend's API gateway. 130 | 131 | ```go 132 | func main() { 133 | node := &flatend.Node{ 134 | Services: map[string]flatend.Handler{ 135 | "hello_world": helloWorld, 136 | }, 137 | } 138 | node.Start("127.0.0.1:9000") 139 | 140 | ch := make(chan os.Signal, 1) 141 | signal.Notify(ch, os.Interrupt) 142 | <-ch 143 | 144 | node.Shutdown() 145 | } 146 | ``` 147 | 148 | Run it. 149 | 150 | ```shell 151 | $ go run main.go 152 | 2020/06/18 04:09:25 Listening for Flatend nodes on '[::]:41581'. 153 | 2020/06/18 04:09:25 You are now connected to 127.0.0.1:9000. Services: [] 154 | 2020/06/18 04:09:25 Re-probed 127.0.0.1:9000. Services: [] 155 | 2020/06/18 04:09:25 Discovered 0 peer(s). 156 | ``` 157 | 158 | Visit [localhost:3000/hello](http://localhost:3000/hello). 159 | 160 | ```shell 161 | $ curl http://localhost:3000/hello 162 | Hello world! 163 | ``` 164 | 165 | Try restart your API gateway and watch your service re-discover it. 166 | 167 | ```shell 168 | $ go run main.go 169 | 2020/06/18 04:11:06 Listening for Flatend nodes on '[::]:39313'. 170 | 2020/06/18 04:11:06 You are now connected to 127.0.0.1:9000. Services: [] 171 | 2020/06/18 04:11:06 Re-probed 127.0.0.1:9000. Services: [] 172 | 2020/06/18 04:11:06 Discovered 0 peer(s). 173 | 2020/06/18 04:11:07 127.0.0.1:9000 has disconnected from you. Services: [] 174 | 2020/06/18 04:11:07 Trying to reconnect to 127.0.0.1:9000. Sleeping for 500ms. 175 | 2020/06/18 04:11:08 Trying to reconnect to 127.0.0.1:9000. Sleeping for 617.563636ms. 176 | 2020/06/18 04:11:08 Trying to reconnect to 127.0.0.1:9000. Sleeping for 686.907514ms. 177 | 2020/06/18 04:11:09 You are now connected to 127.0.0.1:9000. Services: [] 178 | ``` 179 | 180 |

181 | 182 |

183 | 184 | Check out more examples [here](https://github.com/lithdew/flatend/tree/master/examples/go). I recommend checking out the [Todo List](https://github.com/lithdew/flatend/tree/master/examples/go/todo) one which stores data in [SQLite](http://sqlite.org/). 185 | 186 | ### NodeJS 187 | 188 | Add [`flatend`](https://www.npmjs.com/package/flatend) to a new npm/yarn project. 189 | 190 | ```shell 191 | $ yarn init -y 192 | yarn init vX.X.X 193 | success Saved package.json 194 | 195 | $ yarn add flatend 196 | yarn add vX.X.X 197 | info No lockfile found. 198 | [1/4] Resolving packages... 199 | [2/4] Fetching packages... 200 | [3/4] Linking dependencies... 201 | [4/4] Building fresh packages... 202 | 203 | success Saved lockfile. 204 | success Saved X new dependencies. 205 | ``` 206 | 207 | Write a function that describes how to handle requests for the service `hello_world` in `index.js`. 208 | 209 | ```js 210 | const {Node, Context} = require("flatend"); 211 | 212 | const helloWorld = ctx => ctx.send("Hello world!"); 213 | ``` 214 | 215 | Register the function as a handler for the service `hello_world`. Start the node and have it connect to Flatend's API gateway. 216 | 217 | ```js 218 | const {Node, Context} = require("flatend"); 219 | 220 | const helloWorld = ctx => ctx.send("Hello world!"); 221 | 222 | async function main() { 223 | await Node.start({ 224 | addrs: ["127.0.0.1:9000"], 225 | services: { 226 | 'hello_world': helloWorld, 227 | }, 228 | }); 229 | } 230 | 231 | main().catch(err => console.error(err)); 232 | ``` 233 | 234 | Run it. 235 | 236 | ```shell 237 | $ DEBUG=* node index.js 238 | flatend You are now connected to 127.0.0.1:9000. Services: [] +0ms 239 | flatend Discovered 0 peer(s). +19ms 240 | ``` 241 | 242 | Visit [localhost:3000/hello](http://localhost:3000/hello). 243 | 244 | ```shell 245 | $ curl http://localhost:3000/hello 246 | Hello world! 247 | ``` 248 | 249 | Try restart your API gateway and watch your service re-discover it. 250 | 251 | ```shell 252 | $ DEBUG=* node index.js 253 | flatend You are now connected to 127.0.0.1:9000. Services: [] +0ms 254 | flatend Discovered 0 peer(s). +19ms 255 | flatend Trying to reconnect to 127.0.0.1:9000. Sleeping for 500ms. +41s 256 | flatend Trying to reconnect to 127.0.0.1:9000. Sleeping for 500ms. +504ms 257 | flatend Trying to reconnect to 127.0.0.1:9000. Sleeping for 500ms. +503ms 258 | flatend Trying to reconnect to 127.0.0.1:9000. Sleeping for 500ms. +503ms 259 | flatend Trying to reconnect to 127.0.0.1:9000. Sleeping for 500ms. +503ms 260 | flatend You are now connected to 127.0.0.1:9000. Services: [] +21ms 261 | ``` 262 | 263 |

264 | 265 |

266 | 267 | Check out more examples [here](https://github.com/lithdew/flatend/tree/master/examples/nodejs). I recommend checking out the [Todo List](https://github.com/lithdew/flatend/tree/master/examples/nodejs/todo) one which stores data in [SQLite](http://sqlite.org/). 268 | 269 | ## Options 270 | 271 | ### Go SDK 272 | 273 | ```go 274 | package flatend 275 | 276 | import "github.com/lithdew/kademlia" 277 | 278 | type Node struct { 279 | // A reachable, public address which peers may reach you on. 280 | // The format of the address must be [host]:[port]. 281 | PublicAddr string 282 | 283 | // A 32-byte Ed25519 private key. A secret key must be provided 284 | // to allow for peers to reach you. A secret key may be generated 285 | // by calling `flatend.GenerateSecretKey()`. 286 | SecretKey kademlia.PrivateKey 287 | 288 | // A list of IPv4/IPv6 addresses and ports assembled as [host]:[port] which 289 | // your Flatend node will listen for other nodes from. 290 | BindAddrs []string 291 | 292 | // A mapping of service names to their respective handlers. 293 | Services map[string]Handler 294 | 295 | // .... 296 | } 297 | 298 | // Start takes in 'addrs', which is list of addresses to nodes to 299 | // initially reach out for/bootstrap from first. 300 | (*Node).Start(addrs string) 301 | 302 | import "io" 303 | import "io/ioutil" 304 | 305 | func helloWorld(ctx *flatend.Context) { 306 | // The ID of the requester may be accessed via `ctx.ID`. 307 | _ = ctx.ID 308 | 309 | // All headers must be written before writing any response body data. 310 | 311 | // Headers are used to send small amounts of metadata to a requester. 312 | 313 | // For example, the HTTP API gateway directly sets headers provided 314 | // as a response as the headers of a HTTP response to a HTTP request 315 | // which has been transcribed to a Flatend service request that is 316 | // handled by some given node. 317 | 318 | ctx.WriteHeader("header key", "header val") 319 | 320 | // The first response body write call will send all set headers to the 321 | // requester. Any other headers set after the first call are ignored. 322 | ctx.Write([]byte("Hello world!")) 323 | 324 | 325 | // All request headers may be accessed via `ctx.Headers`. Headers 326 | // are represented as map[string]string. 327 | header, exists := ctx.Headers["params.id"] 328 | _, _ = header, exists 329 | 330 | // The body of a request may be accessed via `ctx.Body`. Request bodies 331 | // are unbounded in size, and represented as a `io.ReadCloser`. 332 | 333 | // It is advised to wrap the body under an `io.LimitReader` to limit 334 | // the size of the bodies of requests. 335 | 336 | buf, err := ioutil.ReadAll(io.LimitReader(ctx.Body, 65536)) 337 | _, _ = buf, err 338 | 339 | // If no 'ctx.Write' calls are made by the end of the handler, an 340 | // empty response body is provided. 341 | } 342 | ``` 343 | 344 | ### NodeJS SDK 345 | 346 | ```js 347 | const {Node} = require("flatend"); 348 | 349 | export interface NodeOptions { 350 | // A reachable, public address which peers may reach you on. 351 | // The format of the address must be [host]:[port]. 352 | publicAddr?: string; 353 | 354 | // A list of [host]:[port] addresses which this node will bind a listener 355 | // against to accept new Flatend nodes. 356 | bindAddrs?: string[]; 357 | 358 | // A list of addresses to nodes to initially reach out 359 | // for/bootstrap from first. 360 | addrs?: string[]; 361 | 362 | // An Ed25519 secret key. A secret key must be provided to allow for 363 | // peers to reach you. A secret key may be generated by calling 364 | // 'flatend.generateSecretKey()'. 365 | secretKey?: Uint8Array; 366 | 367 | // A mapping of service names to their respective handlers. 368 | services?: { [key: string]: Handler }; 369 | } 370 | 371 | await Node.start(opts: NodeOpts); 372 | 373 | const {Context} = require("flatend"); 374 | 375 | // Handlers may optionally be declared as async, and may optionally 376 | // return promises. 377 | 378 | const helloWorld = async ctx => { 379 | // 'ctx' is a NodeJS Duplex stream. Writing to it writes a response 380 | // body, and reading from it reads a request body. 381 | 382 | _ = ctx.id; // The ID of the requester. 383 | 384 | ctx.pipe(ctx); // This would pipe all request data as response data. 385 | 386 | // Headers are used to send small amounts of metadata to a requester. 387 | 388 | // For example, the HTTP API gateway directly sets headers provided 389 | // as a response as the headers of a HTTP response to a HTTP request 390 | // which has been transcribed to a Flatend service request that is 391 | // handled by some given node. 392 | 393 | ctx.header("header key", "header val"); 394 | 395 | // All request headers may be accessed via 'ctx.headers'. Headers 396 | // are represented as an object. 397 | 398 | // The line below closes the response with the body being a 399 | // JSON-encoded version of the request headers provided. 400 | 401 | ctx.json(ctx.headers); 402 | 403 | // Arbitrary streams may be piped into 'ctx', like the contents of 404 | // a file for example. 405 | 406 | const fs = require("fs"); 407 | fs.createFileStream("index.js").pipe(ctx); 408 | 409 | // Any errors thrown in a handler are caught and sent as a JSON 410 | // response. 411 | 412 | throw new Error("This shouldn't happen!"); 413 | 414 | // The 'ctx' stream must be closed, either manually via 'ctx.end()' or 415 | // via a function. Not closing 'ctx' will cause the handler to deadlock. 416 | 417 | // DO NOT DO THIS! 418 | // ctx.write("hello world!"); 419 | 420 | // DO THIS! 421 | ctx.write("hello world!"); 422 | ctx.end(); 423 | 424 | // OR THIS! 425 | ctx.send("hello world!"); 426 | 427 | // The line below reads the request body into a buffer up to 65536 bytes. 428 | // If the body exceeds 65536 bytes, an error will be thrown. 429 | 430 | const body = await ctx.read({limit: 65536}); 431 | console.log("I got this message:", body.toString("utf8")); 432 | }; 433 | ``` 434 | 435 | ### API Gateway 436 | 437 | The configuration file for the API gateway is written in [TOML](https://github.com/toml-lang/toml). 438 | 439 | ```toml 440 | # Address to listen for other Flatend nodes on. 441 | addr = "127.0.0.1:9000" 442 | 443 | [[http]] 444 | https = true # Enable/disable HTTPS support. Default is false. 445 | 446 | # Domain(s) for HTTPS support. Ignored if https = false. 447 | domain = "lithdew.net" 448 | domains = ["a.lithdew.net", "b.lithdew.net"] 449 | 450 | # Addresses to serve HTTP requests on. 451 | # Default is :80 if https = false, and :443 if https = true. 452 | 453 | addr = ":3000" 454 | addrs = [":3000", ":4000", "127.0.0.1:9000"] 455 | 456 | # Remove trailing slashes in HTTP route path? Default is true. 457 | redirect_trailing_slash = true 458 | 459 | # Redirect to the exact configured HTTP route path? Default is true. 460 | redirect_fixed_path = true 461 | 462 | [http.timeout] 463 | read = "10s" # HTTP request read timeout. Default is 10s. 464 | read_header = "10s" # HTTP request header read timeout. Default is 10s. 465 | idle = "10s" # Idle connection timeout. Default is 10s. 466 | write = "10s" # HTTP response write timeout. Default is 10s. 467 | shutdown = "10s" # Graceful shutdown timeout. Default is 10s. 468 | 469 | [http.min] 470 | body_size = 1048576 # Min HTTP request body size in bytes. 471 | 472 | [http.max] 473 | header_size = 1048576 # Max HTTP request header size in bytes. 474 | body_size = 1048576 # Max HTTP request body size in bytes. 475 | 476 | # The route below serves the contents of the file 'config.toml' upon 477 | # recipient of a 'GET' request at path '/'. The contents of the file 478 | # are instructed to not be cached to the requester. 479 | 480 | # By default, caching for static files that are served is enabled. 481 | # Instead of a file, a directory may be statically served as well. 482 | 483 | [[http.routes]] 484 | path = "GET /" 485 | static = "config.toml" 486 | nocache = true 487 | 488 | # The route below takes an URL route parameter ':id', and includes it 489 | # in a request sent to any Flatend node we know that advertises 490 | # themselves of handling the service 'a', 'b', or 'c'. The HTTP 491 | # request body, query parameters, and headers are additionally 492 | # sent to the node. 493 | 494 | [[http.routes]] 495 | path = "POST /:id" 496 | services = ["a", "b", "c"] 497 | ``` 498 | 499 | ## Build from source 500 | 501 | ```shell 502 | $ git clone https://github.com/lithdew/flatend.git && cd flatend 503 | Cloning into 'flatend'... 504 | remote: Enumerating objects: 290, done. 505 | remote: Counting objects: 100% (290/290), done. 506 | remote: Compressing objects: 100% (186/186), done. 507 | remote: Total 1063 (delta 144), reused 231 (delta 97), pack-reused 773 508 | Receiving objects: 100% (1063/1063), 419.83 KiB | 796.00 KiB/s, done. 509 | Resolving deltas: 100% (571/571), done. 510 | 511 | $ go version 512 | go version go1.14.4 linux/amd64 513 | 514 | $ go build ./cmd/flatend 515 | ``` 516 | 517 | ## Showcase 518 | 519 | 520 | 521 | [**Mask Demand Calculator**](https://wars-mask.surge.sh/en) - Helps you quickly calculate the amount of masks your household needs. Serving scraped RSS feeds with Flatend to more than 200K+ site visitors. 522 | 523 | ## Help 524 | 525 | Got a question? Either: 526 | 527 | 1. Create an [issue](https://github.com/lithdew/flatend/issues/new). 528 | 2. Chat with us on [Discord](https://discord.gg/HZEbkeQ). 529 | 530 | ## FAQ 531 | 532 | #### Is flatend production-ready? Who uses flatend today? 533 | 534 | *flatend is still a heavy work-in-progress*. That being said, it is being field tested with a few enterprise projects related to energy and IoT right now. 535 | 536 | Deployments of flatend have also been made with a few hundred thousand visitors. 537 | 538 | #### Will I be able to run flatend myself? 539 | 540 | It was built from the start to allow for self-hosting on the cloud, on bare-metal servers, in Docker containers, on Kubernetes, etc. The cloud is your limit (see the pun I did there?). 541 | 542 | #### I'm worried about vendor lock-in - what happens if flatend goes out of business? 543 | 544 | flatend's code is completely open in this single Github repository: there's no funny business going on here. 545 | 546 | The mission of flatend is to eliminate vendor lock-in and be agnostic to any kinds of hosting environments starting from day one. Also to be somewhat of a breath of fresh air to the existing low-code tools out there. 547 | 548 | #### How does flatend compare to `XXX`? 549 | 550 | flatend gives me enough flexibility as a developer to use the tools and deployment patterns I want, gives me the scalability/performance I need, and at the same time lets me be very productive in building products/services quick. 551 | 552 | flatend amalgamates a lot of what I sort of wish I had while building roughly tens of hackathon projects and startup projects. 553 | 554 | For example, in many cases I just want to spend two bucks a month knowing that the things I build can easily handle a load of thousands of request per second. 555 | 556 | Using the API gateways pre-provided with flatend, I can easily build a system that supports that and rapidly prototype its business logic in NodeJS. 557 | 558 | #### Who owns the code that I write in flatend, and the data that I and my users save in flatend? 559 | 560 | You own the data and the code. All the code is MIT licensed, and strongly compliant with GDPR/CCPA as well. 561 | 562 | All communication across microservices are fully-encrypted end-to-end using AES-256 Galois Counter Mode (GCM). Encryption keys are ephemeral and established per-session, and are established using a X25519 Diffie-Hellman handshake followed by a single pass of BLAKE-2b 256-bit. 563 | 564 | Y'know, basically just a hyper-specific standard configuration setting of the [Noise Protocol](http://www.noiseprotocol.org/). 565 | 566 | #### I have a 3rd party/legacy system that I need to use with my backend. Can I still use flatend? 567 | 568 | flatend from the start was made to be agnostic to whichever databases, programming languages, tools, or hosting environments you choose to put it through. 569 | 570 | At the end of the day, flatend is just a protocol. That being said, to use flatend with your system would require writing a sort of shim or SDK for it. 571 | 572 | Reach out to us on Discord, maybe the system you are looking to support may be an integration point well worth providing a reference implementation for. 573 | 574 | ## License 575 | 576 | **flatend**, and all of its source code is released under the [MIT License](LICENSE). -------------------------------------------------------------------------------- /cmd/flatend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "github.com/BurntSushi/toml" 8 | "github.com/caddyserver/certmagic" 9 | "github.com/julienschmidt/httprouter" 10 | "github.com/lithdew/flatend" 11 | "github.com/lithdew/flatend/flathttp" 12 | "github.com/spf13/pflag" 13 | "io/ioutil" 14 | "log" 15 | "net" 16 | "net/http" 17 | "os" 18 | "os/signal" 19 | "path/filepath" 20 | "strconv" 21 | "strings" 22 | ) 23 | 24 | func check(err error) { 25 | if err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | func hostOnly(hostPort string) string { 31 | host, _, err := net.SplitHostPort(hostPort) 32 | if err != nil { 33 | return hostPort 34 | } 35 | return host 36 | } 37 | 38 | func main() { 39 | var configPath string 40 | var bindHost net.IP 41 | var bindPort uint16 42 | 43 | pflag.StringVarP(&configPath, "config", "c", "config.toml", "path to config file") 44 | pflag.IPVarP(&bindHost, "host", "h", net.ParseIP("127.0.0.1"), "bind host") 45 | pflag.Uint16VarP(&bindPort, "port", "p", 9000, "bind port") 46 | pflag.Parse() 47 | 48 | var cfg flathttp.Config 49 | 50 | buf, err := ioutil.ReadFile(configPath) 51 | if err == nil { 52 | check(toml.Unmarshal(buf, &cfg)) 53 | } else { 54 | log.Printf("Unable to find a configuration file '%s'.", configPath) 55 | } 56 | check(cfg.Validate()) 57 | 58 | if cfg.Addr != "" { 59 | host, port, err := net.SplitHostPort(cfg.Addr) 60 | check(err) 61 | 62 | bindHost = net.ParseIP(host) 63 | 64 | { 65 | port, err := strconv.ParseUint(port, 10, 16) 66 | check(err) 67 | 68 | bindPort = uint16(port) 69 | } 70 | } 71 | 72 | addr := flatend.Addr(bindHost, bindPort) 73 | 74 | node := &flatend.Node{PublicAddr: addr, SecretKey: flatend.GenerateSecretKey()} 75 | check(node.Start()) 76 | 77 | defer node.Shutdown() 78 | 79 | for _, cfg := range cfg.HTTP { 80 | router := httprouter.New() 81 | 82 | if cfg.RedirectTrailingSlash != nil { 83 | router.RedirectTrailingSlash = *cfg.RedirectTrailingSlash 84 | } 85 | 86 | if cfg.RedirectFixedPath != nil { 87 | router.RedirectFixedPath = *cfg.RedirectFixedPath 88 | } 89 | 90 | for _, route := range cfg.Routes { 91 | fields := strings.Fields(route.Path) 92 | services := route.GetServices() 93 | 94 | var handler http.Handler 95 | 96 | switch { 97 | case route.Static != "": 98 | static := route.Static 99 | 100 | info, err := os.Lstat(static) 101 | check(err) 102 | 103 | if info.IsDir() { 104 | if fields[1] == "/" { 105 | router.NotFound = http.FileServer(http.Dir(static)) 106 | } else { 107 | if info.IsDir() && !strings.HasPrefix(fields[1], "/*filepath") { 108 | fields[1] = filepath.Join(fields[1], "/*filepath") 109 | } 110 | router.ServeFiles(fields[1], http.Dir(static)) 111 | } 112 | } else { 113 | handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 114 | http.ServeFile(w, r, static) 115 | }) 116 | } 117 | case len(services) > 0: 118 | handler = flathttp.Handle(node, services) 119 | } 120 | 121 | if handler != nil { 122 | if route.NoCache { 123 | handler = flathttp.NoCache(handler) 124 | } 125 | router.Handler(fields[0], fields[1], handler) 126 | } 127 | } 128 | 129 | srv := &http.Server{ 130 | Handler: router, 131 | ReadTimeout: cfg.Timeout.Read.Duration, 132 | ReadHeaderTimeout: cfg.Timeout.ReadHeader.Duration, 133 | IdleTimeout: cfg.Timeout.Idle.Duration, 134 | WriteTimeout: cfg.Timeout.Write.Duration, 135 | MaxHeaderBytes: cfg.Max.HeaderSize, 136 | } 137 | 138 | defer func() { 139 | srv.SetKeepAlivesEnabled(false) 140 | 141 | timeout := cfg.GetShutdownTimeout() 142 | if timeout > 0 { 143 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 144 | check(srv.Shutdown(ctx)) 145 | cancel() 146 | } else { 147 | check(srv.Close()) 148 | } 149 | }() 150 | 151 | addrs := cfg.GetAddrs() 152 | 153 | if cfg.HTTPS { 154 | magic := certmagic.NewDefault() 155 | check(magic.ManageSync(cfg.GetDomains())) 156 | 157 | acme := certmagic.NewACMEManager(magic, certmagic.DefaultACME) 158 | srv.Handler = acme.HTTPChallengeHandler(srv.Handler) 159 | 160 | redirect := &http.Server{ 161 | Handler: acme.HTTPChallengeHandler( 162 | http.HandlerFunc( 163 | func(w http.ResponseWriter, r *http.Request) { 164 | toURL := "https://" 165 | 166 | requestHost := hostOnly(r.Host) 167 | 168 | toURL += requestHost 169 | toURL += r.URL.RequestURI() 170 | 171 | w.Header().Set("Connection", "close") 172 | 173 | http.Redirect(w, r, toURL, http.StatusMovedPermanently) 174 | }, 175 | ), 176 | ), 177 | ReadTimeout: cfg.Timeout.Read.Duration, 178 | ReadHeaderTimeout: cfg.Timeout.ReadHeader.Duration, 179 | IdleTimeout: cfg.Timeout.Idle.Duration, 180 | WriteTimeout: cfg.Timeout.Write.Duration, 181 | MaxHeaderBytes: cfg.Max.HeaderSize, 182 | } 183 | 184 | defer func() { 185 | redirect.SetKeepAlivesEnabled(false) 186 | 187 | timeout := cfg.GetShutdownTimeout() 188 | if timeout > 0 { 189 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 190 | check(redirect.Shutdown(ctx)) 191 | cancel() 192 | } else { 193 | check(redirect.Close()) 194 | } 195 | }() 196 | 197 | for _, addr := range addrs { 198 | addr := addr 199 | 200 | go func() { 201 | bindAddr := addr 202 | 203 | ln, err := tls.Listen("tcp", bindAddr, magic.TLSConfig()) 204 | check(err) 205 | 206 | log.Printf("Listening for HTTPS on '%s'.", ln.Addr().String()) 207 | 208 | err = srv.Serve(ln) 209 | if !errors.Is(err, http.ErrServerClosed) { 210 | check(err) 211 | } 212 | }() 213 | 214 | go func() { 215 | redirectAddr := addr 216 | 217 | host, _, err := net.SplitHostPort(redirectAddr) 218 | if err == nil { 219 | redirectAddr = net.JoinHostPort(host, "80") 220 | } else { 221 | redirectAddr = net.JoinHostPort(redirectAddr, "80") 222 | } 223 | 224 | ln, err := net.Listen("tcp", redirectAddr) 225 | check(err) 226 | 227 | log.Printf("Redirecting HTTP->HTTPS on '%s'.", ln.Addr().String()) 228 | 229 | err = redirect.Serve(ln) 230 | if !errors.Is(err, http.ErrServerClosed) { 231 | check(err) 232 | } 233 | }() 234 | } 235 | } else { 236 | for _, addr := range addrs { 237 | addr := addr 238 | 239 | go func() { 240 | ln, err := net.Listen("tcp", addr) 241 | check(err) 242 | 243 | log.Printf("Listening for HTTP requests on '%s'.", ln.Addr().String()) 244 | 245 | err = srv.Serve(ln) 246 | if !errors.Is(err, http.ErrServerClosed) { 247 | check(err) 248 | } 249 | }() 250 | } 251 | } 252 | } 253 | 254 | ch := make(chan os.Signal, 1) 255 | signal.Notify(ch, os.Interrupt) 256 | <-ch 257 | } 258 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /hello" 8 | service = "hello_world" -------------------------------------------------------------------------------- /examples/go/broadcast/README.md: -------------------------------------------------------------------------------- 1 | # broadcast 2 | 3 | A demo of broadcasting a message from one node to several nodes providing the service named `'chat'`. 4 | 5 | ``` 6 | [terminal 1] $ go run main.go -l :9000 7 | 2020/06/27 02:37:52 Listening for Flatend nodes on '[::]:9000'. 8 | 2020/06/27 02:37:53 0.0.0.0:9001 has connected. Services: [chat] 9 | 2020/06/27 02:37:56 0.0.0.0:9002 has connected. Services: [chat] 10 | hello 11 | Got 'world' from 0.0.0.0:9001! 12 | Got 'test' from 0.0.0.0:9002! 13 | 2020/06/27 02:39:09 0.0.0.0:9002 has disconnected from you. Services: [chat] 14 | 2020/06/27 02:39:10 0.0.0.0:9001 has disconnected from you. Services: [chat] 15 | 16 | [terminal 2] $ go run main.go -l :9001 :9000 17 | 2020/06/27 02:37:53 Listening for Flatend nodes on '[::]:9001'. 18 | 2020/06/27 02:37:53 You are now connected to 0.0.0.0:9000. Services: [chat] 19 | 2020/06/27 02:37:53 Re-probed 0.0.0.0:9000. Services: [chat] 20 | 2020/06/27 02:37:53 Discovered 0 peer(s). 21 | 2020/06/27 02:37:56 0.0.0.0:9002 has connected. Services: [chat] 22 | Got 'hello' from 0.0.0.0:9000! 23 | world 24 | Got 'test' from 0.0.0.0:9002! 25 | 2020/06/27 02:39:09 0.0.0.0:9002 has disconnected from you. Services: [chat] 26 | 27 | [terminal 3] $ go run main.go -l :9002 :9000 28 | 2020/06/27 02:37:56 Listening for Flatend nodes on '[::]:9002'. 29 | 2020/06/27 02:37:56 You are now connected to 0.0.0.0:9000. Services: [chat] 30 | 2020/06/27 02:37:56 Re-probed 0.0.0.0:9000. Services: [chat] 31 | 2020/06/27 02:37:56 You are now connected to 0.0.0.0:9001. Services: [chat] 32 | 2020/06/27 02:37:56 Discovered 1 peer(s). 33 | Got 'hello' from 0.0.0.0:9000! 34 | Got 'world' from 0.0.0.0:9001! 35 | ``` 36 | 37 | ```go 38 | package main 39 | 40 | import ( 41 | "bufio" 42 | "bytes" 43 | "flag" 44 | "fmt" 45 | "github.com/lithdew/flatend" 46 | "io/ioutil" 47 | "os" 48 | ) 49 | 50 | func check(err error) { 51 | if err != nil { 52 | panic(err) 53 | } 54 | } 55 | 56 | func main() { 57 | var listenAddr string 58 | flag.StringVar(&listenAddr, "l", ":9000", "address to listen for peers on") 59 | flag.Parse() 60 | 61 | node := &flatend.Node{ 62 | PublicAddr: listenAddr, 63 | BindAddrs: []string{listenAddr}, 64 | SecretKey: flatend.GenerateSecretKey(), 65 | Services: map[string]flatend.Handler{ 66 | "chat": func(ctx *flatend.Context) { 67 | buf, err := ioutil.ReadAll(ctx.Body) 68 | if err != nil { 69 | return 70 | } 71 | fmt.Printf("Got '%s' from %s:%d!\n", string(buf), ctx.ID.Host.String(), ctx.ID.Port) 72 | }, 73 | }, 74 | } 75 | defer node.Shutdown() 76 | 77 | check(node.Start(flag.Args()...)) 78 | 79 | br := bufio.NewReader(os.Stdin) 80 | for { 81 | line, _, err := br.ReadLine() 82 | if err != nil { 83 | break 84 | } 85 | 86 | line = bytes.TrimSpace(line) 87 | if len(line) == 0 { 88 | continue 89 | } 90 | 91 | providers := node.ProvidersFor("chat") 92 | for _, provider := range providers { 93 | _, err := provider.Push([]string{"chat"}, nil, ioutil.NopCloser(bytes.NewReader(line))) 94 | if err != nil { 95 | fmt.Printf("Unable to broadcast to %s: %s\n", provider.Addr(), err) 96 | } 97 | } 98 | } 99 | } 100 | ``` 101 | -------------------------------------------------------------------------------- /examples/go/broadcast/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "flag" 7 | "fmt" 8 | "github.com/lithdew/flatend" 9 | "io/ioutil" 10 | "os" 11 | ) 12 | 13 | func check(err error) { 14 | if err != nil { 15 | panic(err) 16 | } 17 | } 18 | 19 | func main() { 20 | var listenAddr string 21 | flag.StringVar(&listenAddr, "l", ":9000", "address to listen for peers on") 22 | flag.Parse() 23 | 24 | node := &flatend.Node{ 25 | PublicAddr: listenAddr, 26 | BindAddrs: []string{listenAddr}, 27 | SecretKey: flatend.GenerateSecretKey(), 28 | Services: map[string]flatend.Handler{ 29 | "chat": func(ctx *flatend.Context) { 30 | buf, err := ioutil.ReadAll(ctx.Body) 31 | if err != nil { 32 | return 33 | } 34 | fmt.Printf("Got '%s' from %s:%d!\n", string(buf), ctx.ID.Host.String(), ctx.ID.Port) 35 | }, 36 | }, 37 | } 38 | defer node.Shutdown() 39 | 40 | check(node.Start(flag.Args()...)) 41 | 42 | br := bufio.NewReader(os.Stdin) 43 | for { 44 | line, _, err := br.ReadLine() 45 | if err != nil { 46 | break 47 | } 48 | 49 | line = bytes.TrimSpace(line) 50 | if len(line) == 0 { 51 | continue 52 | } 53 | 54 | providers := node.ProvidersFor("chat") 55 | for _, provider := range providers { 56 | _, err := provider.Push([]string{"chat"}, nil, ioutil.NopCloser(bytes.NewReader(line))) 57 | if err != nil { 58 | fmt.Printf("Unable to broadcast to %s: %s\n", provider.Addr(), err) 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /examples/go/clock/README.md: -------------------------------------------------------------------------------- 1 | # clock 2 | 3 | A demo of peer discovery and bidirectional streaming, and Flatend by itself without its built-in HTTP server. 4 | 5 | Run `go run main.go` on one terminal. Run `go run main.go clock` on several other terminals. 6 | 7 | Watch nodes randomly query and respond to each others requests regarding their current systems time. 8 | 9 | ``` 10 | [terminal 1] $ go run main.go 11 | 2020/06/18 00:06:56 Listening for Flatend nodes on '127.0.0.1:9000'. 12 | 2020/06/18 00:06:57 [::]:44369 has connected. Services: [clock] 13 | Got someone's time ('Jun 18 00:06:57')! Sent back ours ('Jun 18 00:06:57'). 14 | 2020/06/18 00:06:57 [::]:45309 has connected. Services: [clock] 15 | Got someone's time ('Jun 18 00:06:57')! Sent back ours ('Jun 18 00:06:57'). 16 | [1] Asked someone for their current time. Ours is 'Jun 18 00:06:57'. 17 | [1] Got a response! Their current time is: 'Jun 18 00:06:57'. 18 | [2] Asked someone for their current time. Ours is 'Jun 18 00:06:57'. 19 | [2] Got a response! Their current time is: 'Jun 18 00:06:57'. 20 | [3] Asked someone for their current time. Ours is 'Jun 18 00:06:58'. 21 | 22 | [terminal 2] $ go run main.go clock 23 | 2020/06/18 00:06:57 Listening for Flatend nodes on '[::]:44369'.go clock 24 | 2020/06/18 00:06:57 You are now connected to 127.0.0.1:9000. Services: [clock] 25 | 2020/06/18 00:06:57 Re-probed 127.0.0.1:9000. Services: [clock] 26 | 2020/06/18 00:06:57 Discovered 0 peer(s). 27 | [0] Asked someone for their current time. Ours is 'Jun 18 00:06:57'. 28 | [0] Got a response! Their current time is: 'Jun 18 00:06:57'. 29 | 2020/06/18 00:06:57 [::]:45309 has connected. Services: [clock] 30 | Got someone's time ('Jun 18 00:06:57')! Sent back ours ('Jun 18 00:06:57'). 31 | [1] Asked someone for their current time. Ours is 'Jun 18 00:06:58'. 32 | [1] Got a response! Their current time is: 'Jun 18 00:06:58'. 33 | [2] Asked someone for their current time. Ours is 'Jun 18 00:06:58'. 34 | [2] Got a response! Their current time is: 'Jun 18 00:06:58'. 35 | Got someone's time ('Jun 18 00:06:58')! Sent back ours ('Jun 18 00:06:58'). 36 | Got someone's time ('Jun 18 00:06:58')! Sent back ours ('Jun 18 00:06:58'). 37 | [3] Asked someone for their current time. Ours is 'Jun 18 00:06:58'. 38 | [3] Got a response! Their current time is: 'Jun 18 00:06:58'. 39 | 40 | [terminal 3] $ go run main.go clock 41 | 2020/06/18 00:06:57 Listening for Flatend nodes on '[::]:45309'.go clock 42 | 2020/06/18 00:06:57 You are now connected to 127.0.0.1:9000. Services: [clock] 43 | 2020/06/18 00:06:57 Re-probed 127.0.0.1:9000. Services: [clock] 44 | 2020/06/18 00:06:57 You are now connected to [::]:44369. Services: [clock] 45 | 2020/06/18 00:06:57 Discovered 1 peer(s). 46 | [0] Asked someone for their current time. Ours is 'Jun 18 00:06:57'. 47 | [0] Got a response! Their current time is: 'Jun 18 00:06:57'. 48 | Got someone's time ('Jun 18 00:06:57')! Sent back ours ('Jun 18 00:06:57'). 49 | Got someone's time ('Jun 18 00:06:58')! Sent back ours ('Jun 18 00:06:58'). 50 | Got someone's time ('Jun 18 00:06:58')! Sent back ours ('Jun 18 00:06:58'). 51 | [1] Asked someone for their current time. Ours is 'Jun 18 00:06:58'. 52 | [1] Got a response! Their current time is: 'Jun 18 00:06:58'. 53 | [2] Asked someone for their current time. Ours is 'Jun 18 00:06:58'. 54 | [2] Got a response! Their current time is: 'Jun 18 00:06:58'. 55 | Got someone's time ('Jun 18 00:06:58')! Sent back ours ('Jun 18 00:06:58'). 56 | ``` 57 | 58 | ```go 59 | package main 60 | 61 | import ( 62 | "errors" 63 | "flag" 64 | "fmt" 65 | "github.com/lithdew/flatend" 66 | "io" 67 | "io/ioutil" 68 | "math/rand" 69 | "os" 70 | "os/signal" 71 | "strings" 72 | "time" 73 | ) 74 | 75 | func check(err error) { 76 | if err != nil { 77 | panic(err) 78 | } 79 | } 80 | 81 | func clock(ctx *flatend.Context) { 82 | latest := time.Now() 83 | ours := latest.Format(time.Stamp) 84 | 85 | timestamp, err := ioutil.ReadAll(ctx.Body) 86 | if err != nil { 87 | return 88 | } 89 | 90 | fmt.Printf("Got someone's time ('%s')! Sent back ours ('%s').\n", string(timestamp), ours) 91 | 92 | ctx.Write([]byte(ours)) 93 | } 94 | 95 | func main() { 96 | flag.Parse() 97 | 98 | node := &flatend.Node{ 99 | SecretKey: flatend.GenerateSecretKey(), 100 | Services: map[string]flatend.Handler{ 101 | "clock": clock, 102 | }, 103 | } 104 | defer node.Shutdown() 105 | 106 | if flag.Arg(0) == "" { 107 | node.PublicAddr = "127.0.0.1:9000" 108 | check(node.Start()) 109 | } else { 110 | check(node.Start("127.0.0.1:9000")) 111 | } 112 | 113 | exit := make(chan struct{}) 114 | defer close(exit) 115 | 116 | go func() { 117 | for i := 0; ; i++{ 118 | select { 119 | case <-exit: 120 | return 121 | case <-time.After(time.Duration(rand.Intn(1000)) * time.Millisecond): 122 | timestamp := time.Now().Format(time.Stamp) 123 | body := ioutil.NopCloser(strings.NewReader(timestamp)) 124 | 125 | stream, err := node.Push([]string{"clock"}, nil, body) 126 | if err != nil { 127 | if strings.Contains(err.Error(), "no nodes were able to process") { 128 | continue 129 | } 130 | if errors.Is(err, io.EOF) { 131 | continue 132 | } 133 | check(err) 134 | } 135 | 136 | fmt.Printf("[%d] Asked someone for their current time. Ours is '%s'.\n", i, timestamp) 137 | 138 | res, err := ioutil.ReadAll(stream.Reader) 139 | check(err) 140 | 141 | fmt.Printf("[%d] Got a response! Their current time is: '%s'\n", i, string(res)) 142 | } 143 | } 144 | }() 145 | 146 | ch := make(chan os.Signal, 1) 147 | signal.Notify(ch, os.Interrupt) 148 | <-ch 149 | } 150 | ``` 151 | -------------------------------------------------------------------------------- /examples/go/clock/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "github.com/lithdew/flatend" 8 | "io" 9 | "io/ioutil" 10 | "math/rand" 11 | "os" 12 | "os/signal" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | func check(err error) { 18 | if err != nil { 19 | panic(err) 20 | } 21 | } 22 | 23 | func clock(ctx *flatend.Context) { 24 | latest := time.Now() 25 | ours := latest.Format(time.Stamp) 26 | 27 | timestamp, err := ioutil.ReadAll(ctx.Body) 28 | if err != nil { 29 | return 30 | } 31 | 32 | fmt.Printf("Got someone's time ('%s')! Sent back ours ('%s').\n", string(timestamp), ours) 33 | 34 | ctx.Write([]byte(ours)) 35 | } 36 | 37 | func main() { 38 | flag.Parse() 39 | 40 | node := &flatend.Node{ 41 | SecretKey: flatend.GenerateSecretKey(), 42 | Services: map[string]flatend.Handler{ 43 | "clock": clock, 44 | }, 45 | } 46 | defer node.Shutdown() 47 | 48 | if flag.Arg(0) == "" { 49 | node.PublicAddr = "127.0.0.1:9000" 50 | check(node.Start()) 51 | } else { 52 | check(node.Start("127.0.0.1:9000")) 53 | } 54 | 55 | exit := make(chan struct{}) 56 | defer close(exit) 57 | 58 | go func() { 59 | for i := 0; ; i++ { 60 | select { 61 | case <-exit: 62 | return 63 | case <-time.After(time.Duration(rand.Intn(1000)) * time.Millisecond): 64 | timestamp := time.Now().Format(time.Stamp) 65 | body := ioutil.NopCloser(strings.NewReader(timestamp)) 66 | 67 | stream, err := node.Push([]string{"clock"}, nil, body) 68 | if err != nil { 69 | if strings.Contains(err.Error(), "no nodes were able to process") { 70 | continue 71 | } 72 | if errors.Is(err, io.EOF) { 73 | continue 74 | } 75 | check(err) 76 | } 77 | 78 | fmt.Printf("[%d] Asked someone for their current time. Ours is '%s'.\n", i, timestamp) 79 | 80 | res, err := ioutil.ReadAll(stream.Reader) 81 | if !errors.Is(err, io.ErrClosedPipe) { 82 | check(err) 83 | } 84 | 85 | fmt.Printf("[%d] Got a response! Their current time is: '%s'\n", i, string(res)) 86 | } 87 | } 88 | }() 89 | 90 | ch := make(chan os.Signal, 1) 91 | signal.Notify(ch, os.Interrupt) 92 | <-ch 93 | } 94 | -------------------------------------------------------------------------------- /examples/go/counter/README.md: -------------------------------------------------------------------------------- 1 | # count 2 | 3 | Count from zero. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 02:12:53 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 02:12:53 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 02:12:59 has connected to you. Services: [count] 10 | 11 | $ go run main.go 12 | 2020/06/17 02:13:00 You are now connected to 127.0.0.1:9000. Services: [] 13 | 14 | $ http://localhost:3000 15 | 0 16 | 17 | $ http://localhost:3000 18 | 1 19 | 20 | $ http://localhost:3000 21 | 2 22 | ``` 23 | 24 | ```toml 25 | addr = "127.0.0.1:9000" 26 | 27 | [[http]] 28 | addr = ":3000" 29 | 30 | [[http.routes]] 31 | path = "GET /" 32 | service = "count" 33 | ``` 34 | 35 | ```go 36 | package main 37 | 38 | import ( 39 | "github.com/lithdew/flatend" 40 | "os" 41 | "os/signal" 42 | "strconv" 43 | "sync/atomic" 44 | ) 45 | 46 | func check(err error) { 47 | if err != nil { 48 | panic(err) 49 | } 50 | } 51 | 52 | func main() { 53 | counter := uint64(0) 54 | 55 | node := &flatend.Node{ 56 | Services: map[string]flatend.Handler{ 57 | "count": func(ctx *flatend.Context) { 58 | current := atomic.AddUint64(&counter, 1) - 1 59 | ctx.Write(strconv.AppendUint(nil, current, 10)) 60 | }, 61 | }, 62 | } 63 | check(node.Start("127.0.0.1:9000")) 64 | 65 | ch := make(chan os.Signal, 1) 66 | signal.Notify(ch, os.Interrupt) 67 | <-ch 68 | 69 | node.Shutdown() 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /examples/go/counter/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /" 8 | service = "count" -------------------------------------------------------------------------------- /examples/go/counter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/lithdew/flatend" 5 | "os" 6 | "os/signal" 7 | "strconv" 8 | "sync/atomic" 9 | ) 10 | 11 | func check(err error) { 12 | if err != nil { 13 | panic(err) 14 | } 15 | } 16 | 17 | func main() { 18 | counter := uint64(0) 19 | 20 | node := &flatend.Node{ 21 | Services: map[string]flatend.Handler{ 22 | "count": func(ctx *flatend.Context) { 23 | current := atomic.AddUint64(&counter, 1) - 1 24 | ctx.Write(strconv.AppendUint(nil, current, 10)) 25 | }, 26 | }, 27 | } 28 | check(node.Start("127.0.0.1:9000")) 29 | 30 | ch := make(chan os.Signal, 1) 31 | signal.Notify(ch, os.Interrupt) 32 | <-ch 33 | 34 | node.Shutdown() 35 | } 36 | -------------------------------------------------------------------------------- /examples/go/file/README.md: -------------------------------------------------------------------------------- 1 | # file 2 | 3 | Here's something trippy: a service that responds with its own source code. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 02:36:30 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 02:36:30 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 02:36:41 has connected to you. Services: [file] 10 | 11 | $ node index.js 12 | Successfully dialed 127.0.0.1:9000. Services: [] 13 | 14 | $ http://localhost:3000/ 15 | const {Node} = require("flatend"); 16 | const fs = require("fs"); 17 | 18 | const main = async () => { 19 | const node = new Node(); 20 | node.register('file', ctx => fs.createReadStream("index.js").pipe(ctx)); 21 | await node.dial("127.0.0.1:9000"); 22 | } 23 | 24 | main().catch(err => console.error(err)); 25 | ``` 26 | 27 | ```toml 28 | addr = "127.0.0.1:9000" 29 | 30 | [[http]] 31 | addr = ":3000" 32 | 33 | [[http.routes]] 34 | path = "GET /" 35 | service = "file" 36 | ``` 37 | 38 | ```js 39 | const { Node } = require("flatend"); 40 | const fs = require("fs"); 41 | 42 | const main = async () => { 43 | const node = new Node(); 44 | node.register("file", (ctx) => fs.createReadStream("index.js").pipe(ctx)); 45 | await node.dial("127.0.0.1:9000"); 46 | }; 47 | 48 | main().catch((err) => console.error(err)); 49 | ``` 50 | -------------------------------------------------------------------------------- /examples/go/file/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /" 8 | service = "file" -------------------------------------------------------------------------------- /examples/go/file/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/lithdew/flatend" 5 | "io" 6 | "os" 7 | "os/signal" 8 | ) 9 | 10 | func check(err error) { 11 | if err != nil { 12 | panic(err) 13 | } 14 | } 15 | 16 | func main() { 17 | node := &flatend.Node{ 18 | Services: map[string]flatend.Handler{ 19 | "file": func(ctx *flatend.Context) { 20 | f, err := os.Open("main.go") 21 | if err != nil { 22 | ctx.Write([]byte(err.Error())) 23 | return 24 | } 25 | _, _ = io.Copy(ctx, f) 26 | f.Close() 27 | }, 28 | }, 29 | } 30 | check(node.Start("127.0.0.1:9000")) 31 | 32 | ch := make(chan os.Signal, 1) 33 | signal.Notify(ch, os.Interrupt) 34 | <-ch 35 | 36 | node.Shutdown() 37 | } 38 | -------------------------------------------------------------------------------- /examples/go/hello_world/README.md: -------------------------------------------------------------------------------- 1 | # hello_world 2 | 3 | Create a service `hello_world` that replies with "Hello world!". 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 00:44:34 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 00:44:34 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 00:44:37 has connected to you. Services: [hello_world] 10 | 2020/06/17 00:44:56 has disconnected from you. Services: [hello_world] 11 | 12 | $ http://localhost:3000/hello 13 | no nodes were able to process your request for service(s): [hello_world] 14 | 15 | $ go run main.go 16 | 2020/06/17 00:44:37 You are now connected to 127.0.0.1:9000. Services: [] 17 | 18 | $ http://localhost:3000/hello 19 | Hello world! 20 | ``` 21 | 22 | ```toml 23 | addr = "127.0.0.1:9000" 24 | 25 | [[http]] 26 | addr = ":3000" 27 | 28 | [[http.routes]] 29 | path = "GET /hello" 30 | service = "hello_world" 31 | ``` 32 | 33 | ```go 34 | package main 35 | 36 | import ( 37 | "github.com/lithdew/flatend" 38 | "os" 39 | "os/signal" 40 | ) 41 | 42 | func check(err error) { 43 | if err != nil { 44 | panic(err) 45 | } 46 | } 47 | 48 | func helloWorld(ctx *flatend.Context) { 49 | ctx.WriteHeader("Content-Type", "text/plain; charset=utf-8") 50 | ctx.Write([]byte("Hello world!")) 51 | } 52 | 53 | func main() { 54 | node := &flatend.Node{ 55 | Services: map[string]flatend.Handler{ 56 | "hello_world": helloWorld, 57 | }, 58 | } 59 | check(node.Start("127.0.0.1:9000")) 60 | 61 | ch := make(chan os.Signal, 1) 62 | signal.Notify(ch, os.Interrupt) 63 | <-ch 64 | 65 | node.Shutdown() 66 | } 67 | ``` 68 | -------------------------------------------------------------------------------- /examples/go/hello_world/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /hello" 8 | service = "hello_world" -------------------------------------------------------------------------------- /examples/go/hello_world/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/lithdew/flatend" 5 | "os" 6 | "os/signal" 7 | ) 8 | 9 | func check(err error) { 10 | if err != nil { 11 | panic(err) 12 | } 13 | } 14 | 15 | func helloWorld(ctx *flatend.Context) { 16 | ctx.WriteHeader("Content-Type", "text/plain; charset=utf-8") 17 | ctx.Write([]byte("Hello world!")) 18 | } 19 | 20 | func main() { 21 | node := &flatend.Node{ 22 | SecretKey: flatend.GenerateSecretKey(), 23 | Services: map[string]flatend.Handler{ 24 | "hello_world": helloWorld, 25 | }, 26 | } 27 | check(node.Start("127.0.0.1:9000")) 28 | 29 | ch := make(chan os.Signal, 1) 30 | signal.Notify(ch, os.Interrupt) 31 | <-ch 32 | 33 | node.Shutdown() 34 | } 35 | -------------------------------------------------------------------------------- /examples/go/pipe/README.md: -------------------------------------------------------------------------------- 1 | # pipe 2 | 3 | Whatever comes in must come out. Simple piping example: upload a file to POST /pipe. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 01:07:12 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 01:07:12 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 01:07:17 has connected to you. Services: [pipe] 10 | 11 | $ go run main.go 12 | 2020/06/17 01:07:17 You are now connected to 127.0.0.1:9000. Services: [] 13 | 14 | $ POST /pipe (1.6MiB GIF) 15 | ``` 16 | 17 | ```toml 18 | addr = "127.0.0.1:9000" 19 | 20 | [[http]] 21 | addr = ":3000" 22 | 23 | [[http.routes]] 24 | path = "POST /pipe" 25 | service = "pipe" 26 | ``` 27 | 28 | ```go 29 | package main 30 | 31 | import ( 32 | "github.com/lithdew/flatend" 33 | "io" 34 | "os" 35 | "os/signal" 36 | ) 37 | 38 | func check(err error) { 39 | if err != nil { 40 | panic(err) 41 | } 42 | } 43 | 44 | func pipe(ctx *flatend.Context) { 45 | _, _ = io.Copy(ctx, ctx.Body) 46 | } 47 | 48 | func main() { 49 | node := &flatend.Node{ 50 | Services: map[string]flatend.Handler{ 51 | "pipe": pipe, 52 | }, 53 | } 54 | check(node.Start("127.0.0.1:9000")) 55 | 56 | ch := make(chan os.Signal, 1) 57 | signal.Notify(ch, os.Interrupt) 58 | <-ch 59 | 60 | node.Shutdown() 61 | } 62 | ``` 63 | -------------------------------------------------------------------------------- /examples/go/pipe/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "POST /pipe" 8 | service = "pipe" -------------------------------------------------------------------------------- /examples/go/pipe/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/lithdew/flatend" 5 | "io" 6 | "os" 7 | "os/signal" 8 | ) 9 | 10 | func check(err error) { 11 | if err != nil { 12 | panic(err) 13 | } 14 | } 15 | 16 | func pipe(ctx *flatend.Context) { 17 | _, _ = io.Copy(ctx, ctx.Body) 18 | } 19 | 20 | func main() { 21 | node := &flatend.Node{ 22 | Services: map[string]flatend.Handler{ 23 | "pipe": pipe, 24 | }, 25 | } 26 | check(node.Start("127.0.0.1:9000")) 27 | 28 | ch := make(chan os.Signal, 1) 29 | signal.Notify(ch, os.Interrupt) 30 | <-ch 31 | 32 | node.Shutdown() 33 | } 34 | -------------------------------------------------------------------------------- /examples/go/todo/README.md: -------------------------------------------------------------------------------- 1 | # todo 2 | 3 | Ye 'ole todo list example using SQLite. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 01:27:03 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 01:27:03 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 01:27:10 has connected to you. Services: [all_todos add_todo remove_todo done_todo] 10 | 11 | $ go run main.go 12 | 2020/06/17 01:27:10 You are now connected to 127.0.0.1:9000. Services: [] 13 | 14 | $ http://localhost:3000/ 15 | ``` 16 | 17 | ```toml 18 | addr = "127.0.0.1:9000" 19 | 20 | [[http]] 21 | addr = ":3000" 22 | 23 | [[http.routes]] 24 | path = "GET /" 25 | static = "./public" 26 | 27 | [[http.routes]] 28 | path = "GET /todos" 29 | service = "all_todos" 30 | 31 | [[http.routes]] 32 | path = "GET /todos/add/:content" 33 | service = "add_todo" 34 | 35 | [[http.routes]] 36 | path = "GET /todos/remove/:id" 37 | service = "remove_todo" 38 | 39 | [[http.routes]] 40 | path = "GET /todos/done/:id" 41 | service = "done_todo" 42 | ``` 43 | 44 | ```go 45 | package main 46 | 47 | import ( 48 | "database/sql" 49 | "encoding/json" 50 | "github.com/lithdew/flatend" 51 | _ "github.com/mattn/go-sqlite3" 52 | "os" 53 | "os/signal" 54 | ) 55 | 56 | func check(err error) { 57 | if err != nil { 58 | panic(err) 59 | } 60 | } 61 | 62 | func main() { 63 | db, err := sql.Open("sqlite3", ":memory:") 64 | check(err) 65 | 66 | _, err = db.Exec("CREATE TABLE todo (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, state TEXT)") 67 | check(err) 68 | 69 | node := &flatend.Node{ 70 | Services: map[string]flatend.Handler{ 71 | "all_todos": func(ctx *flatend.Context) { 72 | rows, err := db.Query("SELECT id, content, state FROM todo") 73 | if err != nil { 74 | ctx.Write([]byte(err.Error())) 75 | return 76 | } 77 | defer rows.Close() 78 | 79 | type Todo struct { 80 | ID int `json:"id"` 81 | Content string `json:"content"` 82 | State string `json:'state'` 83 | } 84 | 85 | todos := make([]Todo, 0) 86 | 87 | for rows.Next() { 88 | var todo Todo 89 | if err := rows.Scan(&todo.ID, &todo.Content, &todo.State); err != nil { 90 | ctx.Write([]byte(err.Error())) 91 | return 92 | } 93 | todos = append(todos, todo) 94 | } 95 | 96 | buf, err := json.Marshal(todos) 97 | if err != nil { 98 | ctx.Write([]byte(err.Error())) 99 | return 100 | } 101 | 102 | ctx.WriteHeader("Content-Type", "application/json") 103 | ctx.Write(buf) 104 | }, 105 | "add_todo": func(ctx *flatend.Context) { 106 | content := ctx.Headers["params.content"] 107 | 108 | res, err := db.Exec("INSERT INTO todo (content, state) VALUES (?, '')", content) 109 | if err != nil { 110 | ctx.Write([]byte(err.Error())) 111 | return 112 | } 113 | 114 | var result struct { 115 | LastInsertID int64 `json:"lastInsertRowid"` 116 | RowsAffected int64 `json:"changes"` 117 | } 118 | 119 | result.LastInsertID, _ = res.LastInsertId() 120 | result.RowsAffected, _ = res.RowsAffected() 121 | 122 | buf, err := json.Marshal(result) 123 | if err != nil { 124 | ctx.Write([]byte(err.Error())) 125 | return 126 | } 127 | 128 | ctx.WriteHeader("Content-Type", "application/json") 129 | ctx.Write(buf) 130 | }, 131 | "remove_todo": func(ctx *flatend.Context) { 132 | id := ctx.Headers["params.id"] 133 | 134 | res, err := db.Exec("DELETE FROM todo WHERE id = ?", id) 135 | if err != nil { 136 | ctx.Write([]byte(err.Error())) 137 | return 138 | } 139 | 140 | var result struct { 141 | LastInsertID int64 `json:"lastInsertRowid"` 142 | RowsAffected int64 `json:"changes"` 143 | } 144 | 145 | result.LastInsertID, _ = res.LastInsertId() 146 | result.RowsAffected, _ = res.RowsAffected() 147 | 148 | buf, err := json.Marshal(result) 149 | if err != nil { 150 | ctx.Write([]byte(err.Error())) 151 | return 152 | } 153 | 154 | ctx.WriteHeader("Content-Type", "application/json") 155 | ctx.Write(buf) 156 | }, 157 | "done_todo": func(ctx *flatend.Context) { 158 | id := ctx.Headers["params.id"] 159 | 160 | res, err := db.Exec("UPDATE todo SET state = 'done' WHERE id = ?", id) 161 | if err != nil { 162 | ctx.Write([]byte(err.Error())) 163 | return 164 | } 165 | 166 | var result struct { 167 | LastInsertID int64 `json:"lastInsertRowid"` 168 | RowsAffected int64 `json:"changes"` 169 | } 170 | 171 | result.LastInsertID, _ = res.LastInsertId() 172 | result.RowsAffected, _ = res.RowsAffected() 173 | 174 | buf, err := json.Marshal(result) 175 | if err != nil { 176 | ctx.Write([]byte(err.Error())) 177 | return 178 | } 179 | 180 | ctx.WriteHeader("Content-Type", "application/json") 181 | ctx.Write(buf) 182 | }, 183 | }, 184 | } 185 | check(node.Start("127.0.0.1:9000")) 186 | 187 | ch := make(chan os.Signal, 1) 188 | signal.Notify(ch, os.Interrupt) 189 | <-ch 190 | 191 | node.Shutdown() 192 | check(db.Close()) 193 | } 194 | ``` 195 | -------------------------------------------------------------------------------- /examples/go/todo/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /" 8 | static = "./public" 9 | 10 | [[http.routes]] 11 | path = "GET /todos" 12 | service = "all_todos" 13 | 14 | [[http.routes]] 15 | path = "GET /todos/add/:content" 16 | service = "add_todo" 17 | 18 | [[http.routes]] 19 | path = "GET /todos/remove/:id" 20 | service = "remove_todo" 21 | 22 | [[http.routes]] 23 | path = "GET /todos/done/:id" 24 | service = "done_todo" -------------------------------------------------------------------------------- /examples/go/todo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "github.com/lithdew/flatend" 7 | _ "github.com/mattn/go-sqlite3" 8 | "os" 9 | "os/signal" 10 | ) 11 | 12 | func check(err error) { 13 | if err != nil { 14 | panic(err) 15 | } 16 | } 17 | 18 | func main() { 19 | db, err := sql.Open("sqlite3", ":memory:") 20 | check(err) 21 | 22 | _, err = db.Exec("CREATE TABLE todo (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, state TEXT)") 23 | check(err) 24 | 25 | node := &flatend.Node{ 26 | Services: map[string]flatend.Handler{ 27 | "all_todos": func(ctx *flatend.Context) { 28 | rows, err := db.Query("SELECT id, content, state FROM todo") 29 | if err != nil { 30 | ctx.Write([]byte(err.Error())) 31 | return 32 | } 33 | defer rows.Close() 34 | 35 | type Todo struct { 36 | ID int `json:"id"` 37 | Content string `json:"content"` 38 | State string `json:'state'` 39 | } 40 | 41 | todos := make([]Todo, 0) 42 | 43 | for rows.Next() { 44 | var todo Todo 45 | if err := rows.Scan(&todo.ID, &todo.Content, &todo.State); err != nil { 46 | ctx.Write([]byte(err.Error())) 47 | return 48 | } 49 | todos = append(todos, todo) 50 | } 51 | 52 | buf, err := json.Marshal(todos) 53 | if err != nil { 54 | ctx.Write([]byte(err.Error())) 55 | return 56 | } 57 | 58 | ctx.WriteHeader("Content-Type", "application/json") 59 | ctx.Write(buf) 60 | }, 61 | "add_todo": func(ctx *flatend.Context) { 62 | content := ctx.Headers["params.content"] 63 | 64 | res, err := db.Exec("INSERT INTO todo (content, state) VALUES (?, '')", content) 65 | if err != nil { 66 | ctx.Write([]byte(err.Error())) 67 | return 68 | } 69 | 70 | var result struct { 71 | LastInsertID int64 `json:"lastInsertRowid"` 72 | RowsAffected int64 `json:"changes"` 73 | } 74 | 75 | result.LastInsertID, _ = res.LastInsertId() 76 | result.RowsAffected, _ = res.RowsAffected() 77 | 78 | buf, err := json.Marshal(result) 79 | if err != nil { 80 | ctx.Write([]byte(err.Error())) 81 | return 82 | } 83 | 84 | ctx.WriteHeader("Content-Type", "application/json") 85 | ctx.Write(buf) 86 | }, 87 | "remove_todo": func(ctx *flatend.Context) { 88 | id := ctx.Headers["params.id"] 89 | 90 | res, err := db.Exec("DELETE FROM todo WHERE id = ?", id) 91 | if err != nil { 92 | ctx.Write([]byte(err.Error())) 93 | return 94 | } 95 | 96 | var result struct { 97 | LastInsertID int64 `json:"lastInsertRowid"` 98 | RowsAffected int64 `json:"changes"` 99 | } 100 | 101 | result.LastInsertID, _ = res.LastInsertId() 102 | result.RowsAffected, _ = res.RowsAffected() 103 | 104 | buf, err := json.Marshal(result) 105 | if err != nil { 106 | ctx.Write([]byte(err.Error())) 107 | return 108 | } 109 | 110 | ctx.WriteHeader("Content-Type", "application/json") 111 | ctx.Write(buf) 112 | }, 113 | "done_todo": func(ctx *flatend.Context) { 114 | id := ctx.Headers["params.id"] 115 | 116 | res, err := db.Exec("UPDATE todo SET state = 'done' WHERE id = ?", id) 117 | if err != nil { 118 | ctx.Write([]byte(err.Error())) 119 | return 120 | } 121 | 122 | var result struct { 123 | LastInsertID int64 `json:"lastInsertRowid"` 124 | RowsAffected int64 `json:"changes"` 125 | } 126 | 127 | result.LastInsertID, _ = res.LastInsertId() 128 | result.RowsAffected, _ = res.RowsAffected() 129 | 130 | buf, err := json.Marshal(result) 131 | if err != nil { 132 | ctx.Write([]byte(err.Error())) 133 | return 134 | } 135 | 136 | ctx.WriteHeader("Content-Type", "application/json") 137 | ctx.Write(buf) 138 | }, 139 | }, 140 | } 141 | check(node.Start("127.0.0.1:9000")) 142 | 143 | ch := make(chan os.Signal, 1) 144 | signal.Notify(ch, os.Interrupt) 145 | <-ch 146 | 147 | node.Shutdown() 148 | check(db.Close()) 149 | } 150 | -------------------------------------------------------------------------------- /examples/go/todo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Flatend Demo - Todo 8 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/go/todo/public/style.css: -------------------------------------------------------------------------------- 1 | form { 2 | display: flex; 3 | padding: 10px; 4 | } 5 | 6 | .wrapper { 7 | min-height: 100vh; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | background-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="4" height="4" viewBox="0 0 4 4"%3E%3Cpath fill="%239C92AC" fill-opacity="0.4" d="M1 3h1v1H1V3zm2-2h1v1H3V1z"%3E%3C/path%3E%3C/svg%3E'); 12 | } 13 | 14 | .input { 15 | margin-right: 10px; 16 | } 17 | 18 | .frame { 19 | width: 40vw; 20 | max-width: 400px; 21 | } 22 | 23 | .header { 24 | display: inline; 25 | text-align: center; 26 | } 27 | 28 | .list-wrapper { 29 | max-height: 200px; 30 | overflow-y: auto; 31 | } 32 | -------------------------------------------------------------------------------- /examples/nodejs/counter/README.md: -------------------------------------------------------------------------------- 1 | # count 2 | 3 | Count from zero. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 02:12:53 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 02:12:53 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 02:12:59 has connected to you. Services: [count] 10 | 11 | $ DEBUG=* node index.js 12 | flatend You are now connected to 127.0.0.1:9000. Services: [] +0ms 13 | flatend Discovered 0 peer(s). +14ms 14 | 15 | $ http://localhost:3000 16 | 0 17 | 18 | $ http://localhost:3000 19 | 1 20 | 21 | $ http://localhost:3000 22 | 2 23 | ``` 24 | 25 | ```toml 26 | addr = "127.0.0.1:9000" 27 | 28 | [[http]] 29 | addr = ":3000" 30 | 31 | [[http.routes]] 32 | path = "GET /" 33 | service = "count" 34 | ``` 35 | 36 | ```js 37 | const { Node } = require("flatend"); 38 | 39 | let counter = 0; 40 | 41 | const count = (ctx) => ctx.send(`${counter++}`); 42 | 43 | const main = async () => { 44 | await Node.start({ 45 | addrs: ["127.0.0.1:9000"], 46 | services: { count: count }, 47 | }); 48 | }; 49 | 50 | main().catch((err) => console.error(err)); 51 | ``` 52 | -------------------------------------------------------------------------------- /examples/nodejs/counter/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /" 8 | service = "count" -------------------------------------------------------------------------------- /examples/nodejs/counter/index.js: -------------------------------------------------------------------------------- 1 | const { Node } = require("flatend"); 2 | 3 | let counter = 0; 4 | 5 | const count = (ctx) => ctx.send(`${counter++}`); 6 | 7 | const main = async () => { 8 | await Node.start({ 9 | addrs: ["127.0.0.1:9000"], 10 | services: { count: count }, 11 | }); 12 | }; 13 | 14 | main().catch((err) => console.error(err)); 15 | -------------------------------------------------------------------------------- /examples/nodejs/counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": {} 7 | } 8 | -------------------------------------------------------------------------------- /examples/nodejs/counter/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/nodejs/e2e/README.md: -------------------------------------------------------------------------------- 1 | # e2e 2 | 3 | Make a single bootstrap node providing a `bootstrap` service, and have several other nodes provide a `node` service 4 | which queries the `bootstrap` service every second. 5 | 6 | ``` 7 | $ DEBUG=* node bootstrap.js 8 | flatend Public Key: 071ed6b6ece42b31a4e0eaf4efd0689ae953c4a89f8672634fdbbf276df3c18a +0ms 9 | flatend Listening for Flatend nodes on '0.0.0.0:9000'. +6ms 10 | No nodes were able to process your request for service(s): [node] 11 | No nodes were able to process your request for service(s): [node] 12 | No nodes were able to process your request for service(s): [node] 13 | flatend '0.0.0.0:40309' has connected to you. Services: [node] +3s 14 | GOT 774 byte(s) from service ["node"]. 15 | GOT 774 byte(s) from service ["node"]. 16 | GOT 774 byte(s) from service ["node"]. 17 | GOT 774 byte(s) from service ["node"]. 18 | 19 | $ DEBUG=* node node.js 20 | flatend Public Key: 1ee37308010ef5d41a107dc50984edf6df5cd497dcc7e826ef829babd2b61558 +0ms 21 | flatend Listening for Flatend nodes on '0.0.0.0:40309'. +7ms 22 | flatend You have connected to '0.0.0.0:9000'. Services: [bootstrap] +63ms 23 | flatend Discovered 0 peer(s). +3ms 24 | GOT 778 byte(s) from service ["bootstrap"]. 25 | GOT 778 byte(s) from service ["bootstrap"]. 26 | GOT 778 byte(s) from service ["bootstrap"]. 27 | GOT 778 byte(s) from service ["bootstrap"]. 28 | 29 | $ DEBUG=* node node.js 30 | flatend Public Key: bd6fc11af1f5b7956b810c439d74d9ca82abbae9847380c6b3dc4223a172f230 +0ms 31 | flatend Listening for Flatend nodes on '0.0.0.0:46329'. +9ms 32 | flatend You have connected to '0.0.0.0:9000'. Services: [bootstrap] +64ms 33 | flatend You have connected to '0.0.0.0:40309'. Services: [node] +63ms 34 | flatend Discovered 1 peer(s). +3ms 35 | GOT 778 byte(s) from service ["bootstrap"]. 36 | GOT 778 byte(s) from service ["bootstrap"]. 37 | GOT 778 byte(s) from service ["bootstrap"]. 38 | GOT 778 byte(s) from service ["bootstrap"]. 39 | ``` 40 | 41 | ```js 42 | const { Node, generateSecretKey } = require("flatend"); 43 | const { Readable } = require("stream"); 44 | const fs = require("fs"); 45 | 46 | const main = async () => { 47 | const node = await Node.start({ 48 | secretKey: generateSecretKey(), 49 | bindAddrs: [`:9000`], 50 | services: { 51 | bootstrap: (ctx) => fs.createReadStream("bootstrap.js").pipe(ctx), 52 | }, 53 | }); 54 | 55 | setInterval(async () => { 56 | try { 57 | const stream = await node.push(["node"], {}, Readable.from([])); 58 | 59 | for await (const data of stream.body) { 60 | console.log(`GOT ${data.byteLength} byte(s) from service ["node"].`); 61 | } 62 | } catch (err) { 63 | console.error(err.message); 64 | } 65 | }, 1000); 66 | }; 67 | 68 | main().catch((err) => console.error(err)); 69 | ``` 70 | 71 | ```js 72 | const { Node, generateSecretKey } = require("flatend"); 73 | const { Readable } = require("stream"); 74 | const fs = require("fs"); 75 | 76 | const main = async () => { 77 | const node = await Node.start({ 78 | secretKey: generateSecretKey(), 79 | addrs: [`:9000`], 80 | services: { 81 | node: (ctx) => fs.createReadStream("node.js").pipe(ctx), 82 | }, 83 | }); 84 | 85 | setInterval(async () => { 86 | try { 87 | const stream = await node.push(["bootstrap"], {}, Readable.from([])); 88 | 89 | for await (const data of stream.body) { 90 | console.log( 91 | `GOT ${data.byteLength} byte(s) from service ["bootstrap"].` 92 | ); 93 | } 94 | } catch (err) { 95 | console.error(err.message); 96 | } 97 | }, 1000); 98 | }; 99 | 100 | main().catch((err) => console.error(err)); 101 | ``` 102 | -------------------------------------------------------------------------------- /examples/nodejs/e2e/bootstrap.js: -------------------------------------------------------------------------------- 1 | const { Node, generateSecretKey } = require("flatend"); 2 | const { Readable } = require("stream"); 3 | const fs = require("fs"); 4 | 5 | const main = async () => { 6 | const node = await Node.start({ 7 | secretKey: generateSecretKey(), 8 | bindAddrs: [`:9000`], 9 | services: { 10 | bootstrap: (ctx) => fs.createReadStream("bootstrap.js").pipe(ctx), 11 | }, 12 | }); 13 | 14 | setInterval(async () => { 15 | try { 16 | const stream = await node.push(["node"], {}, Readable.from([])); 17 | 18 | for await (const data of stream.body) { 19 | console.log(`GOT ${data.byteLength} byte(s) from service ["node"].`); 20 | } 21 | } catch (err) { 22 | console.error(err.message); 23 | } 24 | }, 1000); 25 | }; 26 | 27 | main().catch((err) => console.error(err)); 28 | -------------------------------------------------------------------------------- /examples/nodejs/e2e/node.js: -------------------------------------------------------------------------------- 1 | const { Node, generateSecretKey } = require("flatend"); 2 | const { Readable } = require("stream"); 3 | const fs = require("fs"); 4 | 5 | const main = async () => { 6 | const node = await Node.start({ 7 | secretKey: generateSecretKey(), 8 | addrs: [`:9000`], 9 | services: { 10 | node: (ctx) => fs.createReadStream("node.js").pipe(ctx), 11 | }, 12 | }); 13 | 14 | setInterval(async () => { 15 | try { 16 | const stream = await node.push(["bootstrap"], {}, Readable.from([])); 17 | 18 | for await (const data of stream.body) { 19 | console.log( 20 | `GOT ${data.byteLength} byte(s) from service ["bootstrap"].` 21 | ); 22 | } 23 | } catch (err) { 24 | console.error(err.message); 25 | } 26 | }, 1000); 27 | }; 28 | 29 | main().catch((err) => console.error(err)); 30 | -------------------------------------------------------------------------------- /examples/nodejs/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT" 6 | } 7 | -------------------------------------------------------------------------------- /examples/nodejs/file/README.md: -------------------------------------------------------------------------------- 1 | # file 2 | 3 | Here's something trippy: a service that responds with its own source code. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 02:36:30 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 02:36:30 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 02:36:41 has connected to you. Services: [file] 10 | 11 | $ DEBUG=* node index.js 12 | flatend You are now connected to 127.0.0.1:9000. Services: [] +0ms 13 | flatend Discovered 0 peer(s). +10ms 14 | 15 | $ http://localhost:3000/ 16 | const { Node } = require("flatend"); 17 | const fs = require("fs"); 18 | 19 | const main = async () => { 20 | await Node.start({ 21 | addrs: ["127.0.0.1:9000"], 22 | services: { 23 | file: (ctx) => fs.createReadStream("index.js").pipe(ctx), 24 | }, 25 | }); 26 | }; 27 | 28 | main().catch((err) => console.error(err)); 29 | ``` 30 | 31 | ```toml 32 | addr = "127.0.0.1:9000" 33 | 34 | [[http]] 35 | addr = ":3000" 36 | 37 | [[http.routes]] 38 | path = "GET /" 39 | service = "file" 40 | ``` 41 | 42 | ```js 43 | const { Node } = require("flatend"); 44 | const fs = require("fs"); 45 | 46 | const main = async () => { 47 | await Node.start({ 48 | addrs: ["127.0.0.1:9000"], 49 | services: { 50 | file: (ctx) => fs.createReadStream("index.js").pipe(ctx), 51 | }, 52 | }); 53 | }; 54 | 55 | main().catch((err) => console.error(err)); 56 | ``` 57 | -------------------------------------------------------------------------------- /examples/nodejs/file/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /" 8 | service = "file" -------------------------------------------------------------------------------- /examples/nodejs/file/index.js: -------------------------------------------------------------------------------- 1 | const { Node } = require("flatend"); 2 | const fs = require("fs"); 3 | 4 | const main = async () => { 5 | await Node.start({ 6 | addrs: ["127.0.0.1:9000"], 7 | services: { 8 | file: (ctx) => fs.createReadStream("index.js").pipe(ctx), 9 | }, 10 | }); 11 | }; 12 | 13 | main().catch((err) => console.error(err)); 14 | -------------------------------------------------------------------------------- /examples/nodejs/file/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": {} 7 | } 8 | -------------------------------------------------------------------------------- /examples/nodejs/file/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/nodejs/hello_world/README.md: -------------------------------------------------------------------------------- 1 | # hello_world 2 | 3 | Create a service `hello_world` that replies with "Hello world!". 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 00:44:34 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 00:44:34 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 00:44:37 has connected to you. Services: [hello_world] 10 | 2020/06/17 00:44:56 has disconnected from you. Services: [hello_world] 11 | 12 | $ http://localhost:3000/hello 13 | no nodes were able to process your request for service(s): [hello_world] 14 | 15 | $ DEBUG=* node index.js 16 | flatend You are now connected to 127.0.0.1:9000. Services: [] +0ms 17 | flatend Discovered 0 peer(s). +11ms 18 | 19 | $ http://localhost:3000/hello 20 | Hello world! 21 | ``` 22 | 23 | ```toml 24 | addr = "127.0.0.1:9000" 25 | 26 | [[http]] 27 | addr = ":3000" 28 | 29 | [[http.routes]] 30 | path = "GET /hello" 31 | service = "hello_world" 32 | ``` 33 | 34 | ```js 35 | const { Node } = require("flatend"); 36 | 37 | const main = async () => { 38 | await Node.start({ 39 | addrs: ["127.0.0.1:9000"], 40 | services: { 41 | hello_world: (ctx) => ctx.send("Hello world!"), 42 | }, 43 | }); 44 | }; 45 | 46 | main().catch((err) => console.error(err)); 47 | ``` 48 | -------------------------------------------------------------------------------- /examples/nodejs/hello_world/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /hello" 8 | service = "hello_world" -------------------------------------------------------------------------------- /examples/nodejs/hello_world/index.js: -------------------------------------------------------------------------------- 1 | const { Node } = require("flatend"); 2 | const { Readable } = require("stream"); 3 | 4 | const main = async () => { 5 | const node = await Node.start({ 6 | addrs: [`127.0.0.1:9000`], 7 | services: { 8 | hello_world: (ctx) => ctx.send("Hello world!"), 9 | }, 10 | }); 11 | 12 | setInterval(async () => { 13 | try { 14 | const stream = await node.push( 15 | ["hello_world"], 16 | {}, 17 | Readable.from("hello world!") 18 | ); 19 | 20 | for await (const data of stream) { 21 | console.log("GOT", data.toString("utf8")); 22 | } 23 | } catch (err) { 24 | console.error(err.message); 25 | } 26 | }, 1000); 27 | }; 28 | 29 | main().catch((err) => console.error(err)); 30 | -------------------------------------------------------------------------------- /examples/nodejs/hello_world/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello_world", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": {} 7 | } 8 | -------------------------------------------------------------------------------- /examples/nodejs/hello_world/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/nodejs/pipe/README.md: -------------------------------------------------------------------------------- 1 | # pipe 2 | 3 | Whatever comes in must come out. Simple piping example: upload a file to POST /pipe. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 01:07:12 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 01:07:12 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 01:07:17 has connected to you. Services: [pipe] 10 | 11 | $ DEBUG=* node index.js 12 | flatend You are now connected to 127.0.0.1:9000. Services: [] +0ms 13 | flatend Discovered 0 peer(s). +19ms 14 | 15 | $ POST /pipe (1.6MiB GIF) 16 | ``` 17 | 18 | ```toml 19 | addr = "127.0.0.1:9000" 20 | 21 | [[http]] 22 | addr = ":3000" 23 | 24 | [[http.routes]] 25 | path = "POST /pipe" 26 | service = "pipe" 27 | ``` 28 | 29 | ```js 30 | const { Node } = require("flatend"); 31 | 32 | const main = async () => { 33 | await Node.start({ 34 | addrs: ["127.0.0.1:9000"], 35 | services: { 36 | pipe: (ctx) => ctx.pipe(ctx), 37 | }, 38 | }); 39 | }; 40 | 41 | main().catch((err) => console.error(err)); 42 | ``` 43 | -------------------------------------------------------------------------------- /examples/nodejs/pipe/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "POST /pipe" 8 | service = "pipe" -------------------------------------------------------------------------------- /examples/nodejs/pipe/index.js: -------------------------------------------------------------------------------- 1 | const { Node } = require("flatend"); 2 | 3 | const main = async () => { 4 | await Node.start({ 5 | addrs: ["127.0.0.1:9000"], 6 | services: { 7 | pipe: (ctx) => ctx.pipe(ctx), 8 | }, 9 | }); 10 | }; 11 | 12 | main().catch((err) => console.error(err)); 13 | -------------------------------------------------------------------------------- /examples/nodejs/pipe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pipe", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": {} 7 | } 8 | -------------------------------------------------------------------------------- /examples/nodejs/pipe/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/nodejs/todo/README.md: -------------------------------------------------------------------------------- 1 | # todo 2 | 3 | Ye 'ole todo list example using SQLite. 4 | 5 | ``` 6 | $ flatend 7 | 2020/06/17 01:27:03 Listening for Flatend nodes on '127.0.0.1:9000'. 8 | 2020/06/17 01:27:03 Listening for HTTP requests on '[::]:3000'. 9 | 2020/06/17 01:27:10 has connected to you. Services: [all_todos add_todo remove_todo done_todo] 10 | 11 | $ DEBUG=* node index.js 12 | flatend You are now connected to 127.0.0.1:9000. Services: [] +0ms 13 | flatend Discovered 0 peer(s). +11ms 14 | 15 | $ http://localhost:3000/ 16 | ``` 17 | 18 | ```toml 19 | addr = "127.0.0.1:9000" 20 | 21 | [[http]] 22 | addr = ":3000" 23 | 24 | [[http.routes]] 25 | path = "GET /" 26 | static = "./public" 27 | 28 | [[http.routes]] 29 | path = "GET /todos" 30 | service = "all_todos" 31 | 32 | [[http.routes]] 33 | path = "GET /todos/add/:content" 34 | service = "add_todo" 35 | 36 | [[http.routes]] 37 | path = "GET /todos/remove/:id" 38 | service = "remove_todo" 39 | 40 | [[http.routes]] 41 | path = "GET /todos/done/:id" 42 | service = "done_todo" 43 | ``` 44 | 45 | ```js 46 | const { Node } = require("flatend"); 47 | const Database = require("better-sqlite3"); 48 | 49 | const db = new Database(":memory:"); 50 | db.exec( 51 | `CREATE TABLE todo (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, state TEXT)` 52 | ); 53 | 54 | const all = async (ctx) => 55 | ctx.json(await db.prepare(`SELECT id, content, state FROM todo`).all()); 56 | 57 | const add = async (ctx) => { 58 | const content = ctx.headers["params.content"]; 59 | ctx.json( 60 | await db 61 | .prepare(`INSERT INTO todo (content, state) VALUES (?, '')`) 62 | .run(content) 63 | ); 64 | }; 65 | 66 | const remove = async (ctx) => { 67 | const id = ctx.headers["params.id"]; 68 | ctx.json(await db.prepare(`DELETE FROM todo WHERE id = ?`).run(id)); 69 | }; 70 | 71 | const done = async (ctx) => { 72 | const id = ctx.headers["params.id"]; 73 | ctx.json( 74 | await db.prepare(`UPDATE todo SET state = 'done' WHERE id = ?`).run(id) 75 | ); 76 | }; 77 | 78 | const main = async () => { 79 | await Node.start({ 80 | addrs: ["127.0.0.1:9000"], 81 | services: { 82 | all_todos: all, 83 | add_todo: add, 84 | remove_todo: remove, 85 | done_todo: done, 86 | }, 87 | }); 88 | }; 89 | 90 | main().catch((err) => console.error(err)); 91 | ``` 92 | -------------------------------------------------------------------------------- /examples/nodejs/todo/config.toml: -------------------------------------------------------------------------------- 1 | addr = "127.0.0.1:9000" 2 | 3 | [[http]] 4 | addr = ":3000" 5 | 6 | [[http.routes]] 7 | path = "GET /" 8 | static = "./public" 9 | 10 | [[http.routes]] 11 | path = "GET /todos" 12 | service = "all_todos" 13 | 14 | [[http.routes]] 15 | path = "GET /todos/add/:content" 16 | service = "add_todo" 17 | 18 | [[http.routes]] 19 | path = "GET /todos/remove/:id" 20 | service = "remove_todo" 21 | 22 | [[http.routes]] 23 | path = "GET /todos/done/:id" 24 | service = "done_todo" -------------------------------------------------------------------------------- /examples/nodejs/todo/index.js: -------------------------------------------------------------------------------- 1 | const { Node } = require("flatend"); 2 | const Database = require("better-sqlite3"); 3 | 4 | const db = new Database(":memory:"); 5 | db.exec( 6 | `CREATE TABLE todo (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT, state TEXT)` 7 | ); 8 | 9 | const all = async (ctx) => 10 | ctx.json(await db.prepare(`SELECT id, content, state FROM todo`).all()); 11 | 12 | const add = async (ctx) => { 13 | const content = ctx.headers["params.content"]; 14 | ctx.json( 15 | await db 16 | .prepare(`INSERT INTO todo (content, state) VALUES (?, '')`) 17 | .run(content) 18 | ); 19 | }; 20 | 21 | const remove = async (ctx) => { 22 | const id = ctx.headers["params.id"]; 23 | ctx.json(await db.prepare(`DELETE FROM todo WHERE id = ?`).run(id)); 24 | }; 25 | 26 | const done = async (ctx) => { 27 | const id = ctx.headers["params.id"]; 28 | ctx.json( 29 | await db.prepare(`UPDATE todo SET state = 'done' WHERE id = ?`).run(id) 30 | ); 31 | }; 32 | 33 | const main = async () => { 34 | await Node.start({ 35 | addrs: ["127.0.0.1:9000"], 36 | services: { 37 | all_todos: all, 38 | add_todo: add, 39 | remove_todo: remove, 40 | done_todo: done, 41 | }, 42 | }); 43 | }; 44 | 45 | main().catch((err) => console.error(err)); 46 | -------------------------------------------------------------------------------- /examples/nodejs/todo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "better-sqlite3": "^7.1.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/nodejs/todo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Flatend Demo - Todo 8 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/nodejs/todo/public/style.css: -------------------------------------------------------------------------------- 1 | form { 2 | display: flex; 3 | padding: 10px; 4 | } 5 | 6 | .wrapper { 7 | min-height: 100vh; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | background-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="4" height="4" viewBox="0 0 4 4"%3E%3Cpath fill="%239C92AC" fill-opacity="0.4" d="M1 3h1v1H1V3zm2-2h1v1H3V1z"%3E%3C/path%3E%3C/svg%3E'); 12 | } 13 | 14 | .input { 15 | margin-right: 10px; 16 | } 17 | 18 | .frame { 19 | width: 40vw; 20 | max-width: 400px; 21 | } 22 | 23 | .header { 24 | display: inline; 25 | text-align: center; 26 | } 27 | 28 | .list-wrapper { 29 | max-height: 200px; 30 | overflow-y: auto; 31 | } 32 | -------------------------------------------------------------------------------- /examples/nodejs/todo/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ansi-regex@^2.0.0: 6 | version "2.1.1" 7 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" 8 | integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= 9 | 10 | ansi-regex@^3.0.0: 11 | version "3.0.0" 12 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" 13 | integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= 14 | 15 | aproba@^1.0.3: 16 | version "1.2.0" 17 | resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" 18 | integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== 19 | 20 | are-we-there-yet@~1.1.2: 21 | version "1.1.5" 22 | resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" 23 | integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== 24 | dependencies: 25 | delegates "^1.0.0" 26 | readable-stream "^2.0.6" 27 | 28 | base64-js@^1.0.2: 29 | version "1.3.1" 30 | resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" 31 | integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== 32 | 33 | better-sqlite3@^7.1.0: 34 | version "7.1.0" 35 | resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-7.1.0.tgz#4894d0fa4002bd7859a0a539577f14f24b245f6a" 36 | integrity sha512-FV/snQ8F/kyqhdxsevzbojVtMowDWOfe1A5N3lYu1KJwoho2t7JgITmdlSc7DkOh3Zq65I+ZyeNWXQrkLEDFTg== 37 | dependencies: 38 | bindings "^1.5.0" 39 | prebuild-install "^5.3.3" 40 | tar "4.4.10" 41 | 42 | bindings@^1.5.0: 43 | version "1.5.0" 44 | resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" 45 | integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== 46 | dependencies: 47 | file-uri-to-path "1.0.0" 48 | 49 | bl@^4.0.1: 50 | version "4.0.2" 51 | resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.2.tgz#52b71e9088515d0606d9dd9cc7aa48dc1f98e73a" 52 | integrity sha512-j4OH8f6Qg2bGuWfRiltT2HYGx0e1QcBTrK9KAHNMwMZdQnDZFk0ZSYIpADjYCB3U12nicC5tVJwSIhwOWjb4RQ== 53 | dependencies: 54 | buffer "^5.5.0" 55 | inherits "^2.0.4" 56 | readable-stream "^3.4.0" 57 | 58 | buffer@^5.5.0: 59 | version "5.6.0" 60 | resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" 61 | integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== 62 | dependencies: 63 | base64-js "^1.0.2" 64 | ieee754 "^1.1.4" 65 | 66 | chownr@^1.1.1: 67 | version "1.1.4" 68 | resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" 69 | integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== 70 | 71 | code-point-at@^1.0.0: 72 | version "1.1.0" 73 | resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" 74 | integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= 75 | 76 | console-control-strings@^1.0.0, console-control-strings@~1.1.0: 77 | version "1.1.0" 78 | resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" 79 | integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= 80 | 81 | core-util-is@~1.0.0: 82 | version "1.0.2" 83 | resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" 84 | integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= 85 | 86 | decompress-response@^4.2.0: 87 | version "4.2.1" 88 | resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" 89 | integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== 90 | dependencies: 91 | mimic-response "^2.0.0" 92 | 93 | deep-extend@^0.6.0: 94 | version "0.6.0" 95 | resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" 96 | integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== 97 | 98 | delegates@^1.0.0: 99 | version "1.0.0" 100 | resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" 101 | integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= 102 | 103 | detect-libc@^1.0.3: 104 | version "1.0.3" 105 | resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" 106 | integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= 107 | 108 | end-of-stream@^1.1.0, end-of-stream@^1.4.1: 109 | version "1.4.4" 110 | resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" 111 | integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== 112 | dependencies: 113 | once "^1.4.0" 114 | 115 | expand-template@^2.0.3: 116 | version "2.0.3" 117 | resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" 118 | integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== 119 | 120 | file-uri-to-path@1.0.0: 121 | version "1.0.0" 122 | resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" 123 | integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== 124 | 125 | fs-constants@^1.0.0: 126 | version "1.0.0" 127 | resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" 128 | integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== 129 | 130 | fs-minipass@^1.2.5: 131 | version "1.2.7" 132 | resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" 133 | integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== 134 | dependencies: 135 | minipass "^2.6.0" 136 | 137 | gauge@~2.7.3: 138 | version "2.7.4" 139 | resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" 140 | integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= 141 | dependencies: 142 | aproba "^1.0.3" 143 | console-control-strings "^1.0.0" 144 | has-unicode "^2.0.0" 145 | object-assign "^4.1.0" 146 | signal-exit "^3.0.0" 147 | string-width "^1.0.1" 148 | strip-ansi "^3.0.1" 149 | wide-align "^1.1.0" 150 | 151 | github-from-package@0.0.0: 152 | version "0.0.0" 153 | resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" 154 | integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= 155 | 156 | has-unicode@^2.0.0: 157 | version "2.0.1" 158 | resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" 159 | integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= 160 | 161 | ieee754@^1.1.4: 162 | version "1.1.13" 163 | resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" 164 | integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== 165 | 166 | inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: 167 | version "2.0.4" 168 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 169 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 170 | 171 | ini@~1.3.0: 172 | version "1.3.5" 173 | resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" 174 | integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== 175 | 176 | is-fullwidth-code-point@^1.0.0: 177 | version "1.0.0" 178 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" 179 | integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= 180 | dependencies: 181 | number-is-nan "^1.0.0" 182 | 183 | is-fullwidth-code-point@^2.0.0: 184 | version "2.0.0" 185 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" 186 | integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= 187 | 188 | isarray@~1.0.0: 189 | version "1.0.0" 190 | resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" 191 | integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= 192 | 193 | mimic-response@^2.0.0: 194 | version "2.1.0" 195 | resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" 196 | integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== 197 | 198 | minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: 199 | version "1.2.5" 200 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" 201 | integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== 202 | 203 | minipass@^2.3.5, minipass@^2.6.0, minipass@^2.9.0: 204 | version "2.9.0" 205 | resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" 206 | integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== 207 | dependencies: 208 | safe-buffer "^5.1.2" 209 | yallist "^3.0.0" 210 | 211 | minizlib@^1.2.1: 212 | version "1.3.3" 213 | resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" 214 | integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== 215 | dependencies: 216 | minipass "^2.9.0" 217 | 218 | mkdirp-classic@^0.5.2: 219 | version "0.5.3" 220 | resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" 221 | integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== 222 | 223 | mkdirp@^0.5.0, mkdirp@^0.5.1: 224 | version "0.5.5" 225 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" 226 | integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== 227 | dependencies: 228 | minimist "^1.2.5" 229 | 230 | napi-build-utils@^1.0.1: 231 | version "1.0.2" 232 | resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" 233 | integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== 234 | 235 | node-abi@^2.7.0: 236 | version "2.18.0" 237 | resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.18.0.tgz#1f5486cfd7d38bd4f5392fa44a4ad4d9a0dffbf4" 238 | integrity sha512-yi05ZoiuNNEbyT/xXfSySZE+yVnQW6fxPZuFbLyS1s6b5Kw3HzV2PHOM4XR+nsjzkHxByK+2Wg+yCQbe35l8dw== 239 | dependencies: 240 | semver "^5.4.1" 241 | 242 | noop-logger@^0.1.1: 243 | version "0.1.1" 244 | resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" 245 | integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= 246 | 247 | npmlog@^4.0.1: 248 | version "4.1.2" 249 | resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" 250 | integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== 251 | dependencies: 252 | are-we-there-yet "~1.1.2" 253 | console-control-strings "~1.1.0" 254 | gauge "~2.7.3" 255 | set-blocking "~2.0.0" 256 | 257 | number-is-nan@^1.0.0: 258 | version "1.0.1" 259 | resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" 260 | integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= 261 | 262 | object-assign@^4.1.0: 263 | version "4.1.1" 264 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 265 | integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= 266 | 267 | once@^1.3.1, once@^1.4.0: 268 | version "1.4.0" 269 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 270 | integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 271 | dependencies: 272 | wrappy "1" 273 | 274 | prebuild-install@^5.3.3: 275 | version "5.3.4" 276 | resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.4.tgz#6982d10084269d364c1856550b7d090ea31fa293" 277 | integrity sha512-AkKN+pf4fSEihjapLEEj8n85YIw/tN6BQqkhzbDc0RvEZGdkpJBGMUYx66AAMcPG2KzmPQS7Cm16an4HVBRRMA== 278 | dependencies: 279 | detect-libc "^1.0.3" 280 | expand-template "^2.0.3" 281 | github-from-package "0.0.0" 282 | minimist "^1.2.3" 283 | mkdirp "^0.5.1" 284 | napi-build-utils "^1.0.1" 285 | node-abi "^2.7.0" 286 | noop-logger "^0.1.1" 287 | npmlog "^4.0.1" 288 | pump "^3.0.0" 289 | rc "^1.2.7" 290 | simple-get "^3.0.3" 291 | tar-fs "^2.0.0" 292 | tunnel-agent "^0.6.0" 293 | which-pm-runs "^1.0.0" 294 | 295 | process-nextick-args@~2.0.0: 296 | version "2.0.1" 297 | resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" 298 | integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== 299 | 300 | pump@^3.0.0: 301 | version "3.0.0" 302 | resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" 303 | integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== 304 | dependencies: 305 | end-of-stream "^1.1.0" 306 | once "^1.3.1" 307 | 308 | rc@^1.2.7: 309 | version "1.2.8" 310 | resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" 311 | integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== 312 | dependencies: 313 | deep-extend "^0.6.0" 314 | ini "~1.3.0" 315 | minimist "^1.2.0" 316 | strip-json-comments "~2.0.1" 317 | 318 | readable-stream@^2.0.6: 319 | version "2.3.7" 320 | resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" 321 | integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== 322 | dependencies: 323 | core-util-is "~1.0.0" 324 | inherits "~2.0.3" 325 | isarray "~1.0.0" 326 | process-nextick-args "~2.0.0" 327 | safe-buffer "~5.1.1" 328 | string_decoder "~1.1.1" 329 | util-deprecate "~1.0.1" 330 | 331 | readable-stream@^3.1.1, readable-stream@^3.4.0: 332 | version "3.6.0" 333 | resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" 334 | integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== 335 | dependencies: 336 | inherits "^2.0.3" 337 | string_decoder "^1.1.1" 338 | util-deprecate "^1.0.1" 339 | 340 | safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: 341 | version "5.2.1" 342 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 343 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 344 | 345 | safe-buffer@~5.1.0, safe-buffer@~5.1.1: 346 | version "5.1.2" 347 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 348 | integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== 349 | 350 | semver@^5.4.1: 351 | version "5.7.1" 352 | resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" 353 | integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== 354 | 355 | set-blocking@~2.0.0: 356 | version "2.0.0" 357 | resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" 358 | integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= 359 | 360 | signal-exit@^3.0.0: 361 | version "3.0.3" 362 | resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" 363 | integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== 364 | 365 | simple-concat@^1.0.0: 366 | version "1.0.0" 367 | resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.0.tgz#7344cbb8b6e26fb27d66b2fc86f9f6d5997521c6" 368 | integrity sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY= 369 | 370 | simple-get@^3.0.3: 371 | version "3.1.0" 372 | resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" 373 | integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== 374 | dependencies: 375 | decompress-response "^4.2.0" 376 | once "^1.3.1" 377 | simple-concat "^1.0.0" 378 | 379 | string-width@^1.0.1: 380 | version "1.0.2" 381 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" 382 | integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= 383 | dependencies: 384 | code-point-at "^1.0.0" 385 | is-fullwidth-code-point "^1.0.0" 386 | strip-ansi "^3.0.0" 387 | 388 | "string-width@^1.0.2 || 2": 389 | version "2.1.1" 390 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" 391 | integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== 392 | dependencies: 393 | is-fullwidth-code-point "^2.0.0" 394 | strip-ansi "^4.0.0" 395 | 396 | string_decoder@^1.1.1: 397 | version "1.3.0" 398 | resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" 399 | integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== 400 | dependencies: 401 | safe-buffer "~5.2.0" 402 | 403 | string_decoder@~1.1.1: 404 | version "1.1.1" 405 | resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" 406 | integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== 407 | dependencies: 408 | safe-buffer "~5.1.0" 409 | 410 | strip-ansi@^3.0.0, strip-ansi@^3.0.1: 411 | version "3.0.1" 412 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" 413 | integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= 414 | dependencies: 415 | ansi-regex "^2.0.0" 416 | 417 | strip-ansi@^4.0.0: 418 | version "4.0.0" 419 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" 420 | integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= 421 | dependencies: 422 | ansi-regex "^3.0.0" 423 | 424 | strip-json-comments@~2.0.1: 425 | version "2.0.1" 426 | resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" 427 | integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= 428 | 429 | tar-fs@^2.0.0: 430 | version "2.1.0" 431 | resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.0.tgz#d1cdd121ab465ee0eb9ccde2d35049d3f3daf0d5" 432 | integrity sha512-9uW5iDvrIMCVpvasdFHW0wJPez0K4JnMZtsuIeDI7HyMGJNxmDZDOCQROr7lXyS+iL/QMpj07qcjGYTSdRFXUg== 433 | dependencies: 434 | chownr "^1.1.1" 435 | mkdirp-classic "^0.5.2" 436 | pump "^3.0.0" 437 | tar-stream "^2.0.0" 438 | 439 | tar-stream@^2.0.0: 440 | version "2.1.2" 441 | resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.2.tgz#6d5ef1a7e5783a95ff70b69b97455a5968dc1325" 442 | integrity sha512-UaF6FoJ32WqALZGOIAApXx+OdxhekNMChu6axLJR85zMMjXKWFGjbIRe+J6P4UnRGg9rAwWvbTT0oI7hD/Un7Q== 443 | dependencies: 444 | bl "^4.0.1" 445 | end-of-stream "^1.4.1" 446 | fs-constants "^1.0.0" 447 | inherits "^2.0.3" 448 | readable-stream "^3.1.1" 449 | 450 | tar@4.4.10: 451 | version "4.4.10" 452 | resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.10.tgz#946b2810b9a5e0b26140cf78bea6b0b0d689eba1" 453 | integrity sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA== 454 | dependencies: 455 | chownr "^1.1.1" 456 | fs-minipass "^1.2.5" 457 | minipass "^2.3.5" 458 | minizlib "^1.2.1" 459 | mkdirp "^0.5.0" 460 | safe-buffer "^5.1.2" 461 | yallist "^3.0.3" 462 | 463 | tunnel-agent@^0.6.0: 464 | version "0.6.0" 465 | resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" 466 | integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= 467 | dependencies: 468 | safe-buffer "^5.0.1" 469 | 470 | util-deprecate@^1.0.1, util-deprecate@~1.0.1: 471 | version "1.0.2" 472 | resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" 473 | integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= 474 | 475 | which-pm-runs@^1.0.0: 476 | version "1.0.0" 477 | resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" 478 | integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= 479 | 480 | wide-align@^1.1.0: 481 | version "1.1.3" 482 | resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" 483 | integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== 484 | dependencies: 485 | string-width "^1.0.2 || 2" 486 | 487 | wrappy@1: 488 | version "1.0.2" 489 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 490 | integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 491 | 492 | yallist@^3.0.0, yallist@^3.0.3: 493 | version "3.1.1" 494 | resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" 495 | integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== 496 | -------------------------------------------------------------------------------- /flathttp/config.go: -------------------------------------------------------------------------------- 1 | package flathttp 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var Methods = map[string]struct{}{ 14 | http.MethodGet: {}, 15 | http.MethodPost: {}, 16 | http.MethodPut: {}, 17 | http.MethodDelete: {}, 18 | http.MethodPatch: {}, 19 | } 20 | 21 | const DefaultShutdownTimeout = 10 * time.Second 22 | 23 | type Config struct { 24 | Addr string 25 | HTTP []ConfigHTTP 26 | } 27 | 28 | func (c Config) Validate() error { 29 | for _, srv := range c.HTTP { 30 | err := srv.Validate() 31 | if err != nil { 32 | return err 33 | } 34 | } 35 | 36 | return nil 37 | } 38 | 39 | type Duration struct { 40 | time.Duration 41 | } 42 | 43 | func (d *Duration) UnmarshalText(text []byte) error { 44 | var err error 45 | d.Duration, err = time.ParseDuration(string(text)) 46 | return err 47 | } 48 | 49 | type ConfigHTTP struct { 50 | Domain string 51 | Domains []string 52 | 53 | Addr string 54 | Addrs []string 55 | 56 | HTTPS bool 57 | 58 | RedirectTrailingSlash *bool `toml:"redirect_trailing_slash"` 59 | RedirectFixedPath *bool `toml:"redirect_fixed_path"` 60 | 61 | Timeout struct { 62 | Read Duration 63 | ReadHeader Duration 64 | Idle Duration 65 | Write Duration 66 | Shutdown Duration 67 | } 68 | 69 | Min struct { 70 | BodySize *int `toml:"body_size"` 71 | } 72 | 73 | Max struct { 74 | HeaderSize int `toml:"header_size"` 75 | BodySize *int `toml:"body_size"` 76 | } 77 | 78 | Routes []ConfigRoute 79 | } 80 | 81 | func (h ConfigHTTP) GetShutdownTimeout() time.Duration { 82 | if h.Timeout.Shutdown.Duration < 0 { 83 | return DefaultShutdownTimeout 84 | } 85 | return h.Timeout.Shutdown.Duration 86 | } 87 | 88 | func (h ConfigHTTP) GetDomains() []string { 89 | if h.Domain != "" { 90 | return []string{h.Domain} 91 | } 92 | return h.Domains 93 | } 94 | 95 | func (h ConfigHTTP) GetAddrs() []string { 96 | if len(h.Addrs) > 0 { 97 | return h.Addrs 98 | } 99 | if h.Addr != "" { 100 | return []string{h.Addr} 101 | } 102 | if h.HTTPS { 103 | return []string{net.JoinHostPort("", "443")} 104 | } 105 | return []string{net.JoinHostPort("", "80")} 106 | } 107 | 108 | func (h ConfigHTTP) Validate() error { 109 | if h.Domain != "" && h.Domains != nil { 110 | return errors.New("'domain' and 'domains' cannot both be non-nil at the same time") 111 | } 112 | 113 | if h.Addr != "" && h.Addrs != nil { 114 | return errors.New("'addr' and 'addrs' cannot both be non-nil at the same time") 115 | } 116 | 117 | for _, route := range h.Routes { 118 | err := route.Validate() 119 | if err != nil { 120 | return err 121 | } 122 | } 123 | 124 | return nil 125 | } 126 | 127 | type ConfigRoute struct { 128 | Path string 129 | Dispatch string 130 | Static string // static files 131 | Service string 132 | Services []string 133 | 134 | NoCache bool 135 | 136 | Min struct { 137 | BodySize *int `toml:"body_size"` 138 | } 139 | 140 | Max struct { 141 | BodySize *int `toml:"body_size"` 142 | } 143 | } 144 | 145 | func (r ConfigRoute) GetServices() []string { 146 | if r.Service == "" { 147 | return r.Services 148 | } 149 | return []string{r.Service} 150 | } 151 | 152 | func (r ConfigRoute) Validate() error { 153 | if r.Service != "" && r.Services != nil { 154 | return errors.New("'service' and 'services' cannot both be non-nil at the same time") 155 | } 156 | 157 | fields := strings.Fields(r.Path) 158 | if len(fields) != 2 { 159 | return fmt.Errorf("invalid number of fields in route path '%s' (format: 'HTTP_METHOD /path/here')", 160 | r.Path) 161 | } 162 | 163 | method := strings.ToUpper(fields[0]) 164 | _, exists := Methods[method] 165 | if !exists { 166 | return fmt.Errorf("unknown http method '%s'", method) 167 | } 168 | 169 | if len(fields[1]) < 1 || fields[1][0] != '/' { 170 | return fmt.Errorf("path must begin with '/' in path '%s'", fields[1]) 171 | } 172 | 173 | _, err := url.ParseRequestURI(fields[1]) 174 | if err != nil { 175 | return fmt.Errorf("invalid http path '%s': %w", fields[1], err) 176 | } 177 | 178 | if r.Static != "" && method != http.MethodGet { 179 | return fmt.Errorf("path '%s' method must be 'GET' to serve files", r.Path) 180 | } 181 | 182 | return nil 183 | } 184 | -------------------------------------------------------------------------------- /flathttp/middleware.go: -------------------------------------------------------------------------------- 1 | package flathttp 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | var epoch = time.Unix(0, 0).Format(http.TimeFormat) 9 | 10 | var noCacheHeaders = map[string]string{ 11 | "Expires": epoch, 12 | "Cache-Control": "no-cache, private, max-age=0", 13 | "Pragma": "no-cache", 14 | "X-Accel-Expires": "0", 15 | } 16 | 17 | var etagHeaders = []string{ 18 | "ETag", 19 | "If-Modified-Since", 20 | "If-Match", 21 | "If-None-Match", 22 | "If-Range", 23 | "If-Unmodified-Since", 24 | } 25 | 26 | func NoCache(h http.Handler) http.Handler { 27 | fn := func(w http.ResponseWriter, r *http.Request) { 28 | for _, v := range etagHeaders { 29 | if r.Header.Get(v) != "" { 30 | r.Header.Del(v) 31 | } 32 | } 33 | 34 | for k, v := range noCacheHeaders { 35 | w.Header().Set(k, v) 36 | } 37 | 38 | h.ServeHTTP(w, r) 39 | } 40 | 41 | return http.HandlerFunc(fn) 42 | } 43 | -------------------------------------------------------------------------------- /flathttp/service.go: -------------------------------------------------------------------------------- 1 | package flathttp 2 | 3 | import ( 4 | "github.com/julienschmidt/httprouter" 5 | "github.com/lithdew/flatend" 6 | "io" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | func Handle(node *flatend.Node, services []string) http.Handler { 12 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | headers := make(map[string]string) 14 | for key := range r.Header { 15 | headers[strings.ToLower(key)] = r.Header.Get(key) 16 | } 17 | 18 | for key := range r.URL.Query() { 19 | headers["query."+strings.ToLower(key)] = r.URL.Query().Get(key) 20 | } 21 | 22 | params := httprouter.ParamsFromContext(r.Context()) 23 | for _, param := range params { 24 | headers["params."+strings.ToLower(param.Key)] = param.Value 25 | } 26 | 27 | stream, err := node.Push(services, headers, r.Body) 28 | if err != nil { 29 | w.Write([]byte(err.Error())) 30 | return 31 | } 32 | 33 | for name, val := range stream.Header.Headers { 34 | w.Header().Set(name, val) 35 | } 36 | 37 | _, _ = io.Copy(w, stream.Reader) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lithdew/flatend 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/VictoriaMetrics/metrics v1.11.3 // indirect 8 | github.com/caddyserver/certmagic v0.11.2 9 | github.com/davecgh/go-spew v1.1.1 10 | github.com/jpillora/backoff v1.0.0 11 | github.com/julienschmidt/httprouter v1.3.0 12 | github.com/lithdew/bytesutil v0.0.0-20200409052507-d98389230a59 13 | github.com/lithdew/kademlia v0.0.0-20200622165832-45f8d02aa528 14 | github.com/lithdew/monte v0.0.0-20200622090619-273283c7e8b7 15 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 16 | github.com/spf13/pflag v1.0.5 17 | github.com/stretchr/testify v1.6.0 18 | golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect 19 | golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /io.go: -------------------------------------------------------------------------------- 1 | package flatend 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | type pipeReader struct { 10 | *io.PipeReader 11 | } 12 | 13 | func (r *pipeReader) Read(buf []byte) (n int, err error) { 14 | n, err = r.PipeReader.Read(buf) 15 | if err != nil && errors.Is(err, io.ErrClosedPipe) { 16 | err = io.EOF 17 | } 18 | return n, err 19 | } 20 | 21 | type pipeWriter struct { 22 | *io.PipeWriter 23 | } 24 | 25 | func (w *pipeWriter) Write(buf []byte) (n int, err error) { 26 | n, err = w.PipeWriter.Write(buf) 27 | if err != nil && errors.Is(err, io.ErrClosedPipe) { 28 | err = fmt.Errorf("%s: %w", err, io.EOF) 29 | } 30 | return n, err 31 | } 32 | 33 | // createWrappedPipe wraps around a reader/writer pair from io.Pipe() such that all 34 | // errors reported by such reader/writer pair that comprise of io.ErrClosedPipe 35 | // will be wrapped with io.EOF. 36 | func createWrappedPipe() (*pipeReader, *pipeWriter) { 37 | r, w := io.Pipe() 38 | pr := &pipeReader{PipeReader: r} 39 | pw := &pipeWriter{PipeWriter: w} 40 | return pr, pw 41 | } 42 | -------------------------------------------------------------------------------- /net.go: -------------------------------------------------------------------------------- 1 | package flatend 2 | 3 | import ( 4 | "github.com/lithdew/kademlia" 5 | "github.com/lithdew/monte" 6 | "io" 7 | "sync" 8 | ) 9 | 10 | const ChunkSize = 2048 11 | 12 | var _ io.Writer = (*Context)(nil) 13 | 14 | type Context struct { 15 | ID kademlia.ID 16 | Headers map[string]string 17 | Body io.ReadCloser 18 | 19 | nonce uint32 // stream id 20 | conn *monte.Conn 21 | headers map[string]string // response headers 22 | written bool // written before? 23 | } 24 | 25 | func (c *Context) WriteHeader(key, val string) { 26 | c.headers[key] = val 27 | } 28 | 29 | func (c *Context) Write(data []byte) (int, error) { 30 | if len(data) == 0 { // disallow writing zero bytes 31 | return 0, nil 32 | } 33 | 34 | if !c.written { 35 | packet := ServiceResponsePacket{ 36 | ID: c.nonce, 37 | Handled: true, 38 | Headers: c.headers, 39 | } 40 | 41 | c.written = true 42 | 43 | err := c.conn.Send(packet.AppendTo([]byte{OpcodeServiceResponse})) 44 | if err != nil { 45 | return 0, err 46 | } 47 | } 48 | 49 | for i := 0; i < len(data); i += ChunkSize { 50 | start := i 51 | end := i + ChunkSize 52 | if end > len(data) { 53 | end = len(data) 54 | } 55 | 56 | packet := DataPacket{ 57 | ID: c.nonce, 58 | Data: data[start:end], 59 | } 60 | 61 | err := c.conn.Send(packet.AppendTo([]byte{OpcodeData})) 62 | if err != nil { 63 | return 0, err 64 | } 65 | } 66 | 67 | return len(data), nil 68 | } 69 | 70 | var contextPool sync.Pool 71 | 72 | func acquireContext(id kademlia.ID, headers map[string]string, body io.ReadCloser, nonce uint32, conn *monte.Conn) *Context { 73 | v := contextPool.Get() 74 | if v == nil { 75 | v = &Context{headers: make(map[string]string)} 76 | } 77 | ctx := v.(*Context) 78 | 79 | ctx.ID = id 80 | ctx.Headers = headers 81 | ctx.Body = body 82 | 83 | ctx.nonce = nonce 84 | ctx.conn = conn 85 | 86 | return ctx 87 | } 88 | 89 | func releaseContext(ctx *Context) { 90 | ctx.written = false 91 | for key := range ctx.headers { 92 | delete(ctx.headers, key) 93 | } 94 | contextPool.Put(ctx) 95 | } 96 | 97 | type Handler func(ctx *Context) 98 | -------------------------------------------------------------------------------- /node.go: -------------------------------------------------------------------------------- 1 | package flatend 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/jpillora/backoff" 7 | "github.com/lithdew/kademlia" 8 | "github.com/lithdew/monte" 9 | "io" 10 | "log" 11 | "math" 12 | "math/rand" 13 | "net" 14 | "sync" 15 | "time" 16 | ) 17 | 18 | var _ monte.Handler = (*Node)(nil) 19 | var _ monte.ConnStateHandler = (*Node)(nil) 20 | 21 | type Node struct { 22 | // A reachable, public address which peers may reach you on. 23 | // The format of the address must be [host]:[port]. 24 | PublicAddr string 25 | 26 | // A 32-byte Ed25519 private key. A secret key must be provided 27 | // to allow for peers to reach you. A secret key may be generated 28 | // by calling `flatend.GenerateSecretKey()`. 29 | SecretKey kademlia.PrivateKey 30 | 31 | // A list of IPv4/IPv6 addresses and ports assembled as [host]:[port] which 32 | // your Flatend node will listen for other nodes from. 33 | BindAddrs []string 34 | 35 | // A mapping of service names to their respective handlers. 36 | Services map[string]Handler 37 | 38 | start sync.Once 39 | stop sync.Once 40 | 41 | wg sync.WaitGroup 42 | id *kademlia.ID 43 | 44 | providers *Providers 45 | 46 | tableLock sync.Mutex 47 | table *kademlia.Table 48 | 49 | clientsLock sync.Mutex 50 | clients map[string]*monte.Client 51 | 52 | lns []net.Listener 53 | srv *monte.Server 54 | } 55 | 56 | func GenerateSecretKey() kademlia.PrivateKey { 57 | _, secret, err := kademlia.GenerateKeys(nil) 58 | if err != nil { 59 | panic(err) 60 | } 61 | return secret 62 | } 63 | 64 | func (n *Node) Start(addrs ...string) error { 65 | var ( 66 | publicHost net.IP 67 | publicPort uint16 68 | ) 69 | 70 | if n.SecretKey != kademlia.ZeroPrivateKey { 71 | if n.PublicAddr != "" { // resolve the address 72 | addr, err := net.ResolveTCPAddr("tcp", n.PublicAddr) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | publicHost = addr.IP 78 | 79 | if addr.Port <= 0 || addr.Port >= math.MaxUint16 { 80 | return fmt.Errorf("'%d' is an invalid port", addr.Port) 81 | } 82 | 83 | publicPort = uint16(addr.Port) 84 | } else { // get a random public address 85 | ln, err := net.Listen("tcp", ":0") 86 | if err != nil { 87 | return fmt.Errorf("unable to listen on any port: %w", err) 88 | } 89 | bindAddr := ln.Addr().(*net.TCPAddr) 90 | publicHost = bindAddr.IP 91 | publicPort = uint16(bindAddr.Port) 92 | if err := ln.Close(); err != nil { 93 | return fmt.Errorf("failed to close listener for getting available port: %w", err) 94 | } 95 | } 96 | } 97 | 98 | if publicHost == nil { 99 | publicHost = net.ParseIP("0.0.0.0") 100 | } 101 | 102 | start := false 103 | n.start.Do(func() { start = true }) 104 | if !start { 105 | return errors.New("listener already started") 106 | } 107 | 108 | if n.SecretKey != kademlia.ZeroPrivateKey { 109 | n.id = &kademlia.ID{ 110 | Pub: n.SecretKey.Public(), 111 | Host: publicHost, 112 | Port: publicPort, 113 | } 114 | 115 | n.table = kademlia.NewTable(n.id.Pub) 116 | } else { 117 | n.table = kademlia.NewTable(kademlia.ZeroPublicKey) 118 | } 119 | 120 | n.providers = NewProviders() 121 | n.clients = make(map[string]*monte.Client) 122 | 123 | n.srv = &monte.Server{ 124 | Handler: n, 125 | ConnState: n, 126 | } 127 | 128 | if n.id != nil && len(n.BindAddrs) == 0 { 129 | ln, err := net.Listen("tcp", Addr(n.id.Host, n.id.Port)) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | log.Printf("Listening for Flatend nodes on '%s'.", ln.Addr().String()) 135 | 136 | n.wg.Add(1) 137 | go func() { 138 | defer n.wg.Done() 139 | n.srv.Serve(ln) 140 | }() 141 | 142 | n.lns = append(n.lns, ln) 143 | } 144 | 145 | for _, addr := range n.BindAddrs { 146 | ln, err := net.Listen("tcp", addr) 147 | if err != nil { 148 | for _, ln := range n.lns { 149 | ln.Close() 150 | } 151 | return err 152 | } 153 | 154 | log.Printf("Listening for Flatend nodes on '%s'.", ln.Addr().String()) 155 | 156 | n.wg.Add(1) 157 | go func() { 158 | defer n.wg.Done() 159 | n.srv.Serve(ln) 160 | }() 161 | 162 | n.lns = append(n.lns, ln) 163 | } 164 | 165 | for _, addr := range addrs { 166 | err := n.Probe(addr) 167 | if err != nil { 168 | return fmt.Errorf("failed to probe '%s': %w", addr, err) 169 | } 170 | } 171 | 172 | n.Bootstrap() 173 | 174 | return nil 175 | } 176 | 177 | func (n *Node) Bootstrap() { 178 | var mu sync.Mutex 179 | 180 | var pub kademlia.PublicKey 181 | if n.id != nil { 182 | pub = n.id.Pub 183 | } 184 | 185 | busy := make(chan struct{}, kademlia.DefaultBucketSize) 186 | queue := make(chan kademlia.ID, kademlia.DefaultBucketSize) 187 | 188 | visited := make(map[kademlia.PublicKey]struct{}) 189 | 190 | n.tableLock.Lock() 191 | ids := n.table.ClosestTo(pub, kademlia.DefaultBucketSize) 192 | n.tableLock.Unlock() 193 | 194 | if len(ids) == 0 { 195 | return 196 | } 197 | 198 | visited[pub] = struct{}{} 199 | for _, id := range ids { 200 | queue <- id 201 | visited[id.Pub] = struct{}{} 202 | } 203 | 204 | var results []kademlia.ID 205 | 206 | for len(queue) > 0 || len(busy) > 0 { 207 | select { 208 | case id := <-queue: 209 | busy <- struct{}{} 210 | go func() { 211 | defer func() { <-busy }() 212 | 213 | client := n.getClient(Addr(id.Host, id.Port)) 214 | 215 | conn, err := client.Get() 216 | if err != nil { 217 | return 218 | } 219 | 220 | err = n.probe(conn) 221 | if err != nil { 222 | return 223 | } 224 | 225 | req := n.createFindNodeRequest().AppendTo([]byte{OpcodeFindNodeRequest}) 226 | 227 | buf, err := conn.Request(req[:0], req) 228 | if err != nil { 229 | return 230 | } 231 | 232 | res, _, err := kademlia.UnmarshalFindNodeResponse(buf) 233 | if err != nil { 234 | return 235 | } 236 | 237 | mu.Lock() 238 | for _, id := range res.Closest { 239 | if _, seen := visited[id.Pub]; !seen { 240 | visited[id.Pub] = struct{}{} 241 | results = append(results, id) 242 | queue <- id 243 | } 244 | } 245 | mu.Unlock() 246 | }() 247 | default: 248 | time.Sleep(16 * time.Millisecond) 249 | } 250 | } 251 | 252 | log.Printf("Discovered %d peer(s).", len(results)) 253 | } 254 | 255 | func (n *Node) Shutdown() { 256 | once := false 257 | n.start.Do(func() { once = true }) 258 | if once { 259 | return 260 | } 261 | 262 | stop := false 263 | n.stop.Do(func() { stop = true }) 264 | if !stop { 265 | return 266 | } 267 | 268 | n.srv.Shutdown() 269 | for _, ln := range n.lns { 270 | ln.Close() 271 | } 272 | n.wg.Wait() 273 | } 274 | 275 | func (n *Node) ProvidersFor(services ...string) []*Provider { 276 | set := make(map[kademlia.PublicKey]*Provider) 277 | for _, provider := range n.providers.getProviders(services...) { 278 | set[provider.id.Pub] = provider 279 | } 280 | 281 | providers := make([]*Provider, 0, len(set)) 282 | for _, provider := range set { 283 | providers = append(providers, provider) 284 | } 285 | 286 | return providers 287 | } 288 | 289 | func (n *Node) HandleConnState(conn *monte.Conn, state monte.ConnState) { 290 | if state != monte.StateClosed { 291 | return 292 | } 293 | 294 | provider := n.providers.deregister(conn) 295 | if provider == nil { 296 | return 297 | } 298 | 299 | n.tableLock.Lock() 300 | if provider.id != nil { 301 | n.table.Delete(provider.id.Pub) 302 | } 303 | n.tableLock.Unlock() 304 | 305 | addr := provider.Addr() 306 | 307 | log.Printf("%s has disconnected from you. Services: %s", addr, provider.Services()) 308 | 309 | n.clientsLock.Lock() 310 | _, exists := n.clients[addr] 311 | n.clientsLock.Unlock() 312 | 313 | if !exists { 314 | return 315 | } 316 | 317 | go func() { 318 | b := &backoff.Backoff{ 319 | Factor: 1.25, 320 | Jitter: true, 321 | Min: 500 * time.Millisecond, 322 | Max: 1 * time.Second, 323 | } 324 | 325 | for i := 0; i < 8; i++ { // 8 attempts max 326 | err := n.Probe(addr) 327 | if err == nil { 328 | return 329 | } 330 | 331 | duration := b.Duration() 332 | 333 | log.Printf("Trying to reconnect to %s. Sleeping for %s.", addr, duration) 334 | time.Sleep(duration) 335 | } 336 | 337 | log.Printf("Tried 8 times reconnecting to %s. Giving up.", addr) 338 | }() 339 | } 340 | 341 | func (n *Node) HandleMessage(ctx *monte.Context) error { 342 | var opcode uint8 343 | 344 | body := ctx.Body() 345 | if len(body) < 1 { 346 | return errors.New("no opcode recorded in packet") 347 | } 348 | opcode, body = body[0], body[1:] 349 | 350 | switch opcode { 351 | case OpcodeHandshake: 352 | packet, err := UnmarshalHandshakePacket(body) 353 | if err != nil { 354 | return err 355 | } 356 | 357 | err = packet.Validate(body[:0]) 358 | if err != nil { 359 | return err 360 | } 361 | 362 | // register the microservice if it hasn't been registered before 363 | 364 | provider, exists := n.providers.register(ctx.Conn(), packet.ID, packet.Services, false) 365 | if !exists { 366 | log.Printf("%s has connected. Services: %s", provider.Addr(), provider.Services()) 367 | } 368 | 369 | // register the peer to the routing table 370 | 371 | if packet.ID != nil { 372 | n.tableLock.Lock() 373 | n.table.Update(*packet.ID) 374 | n.tableLock.Unlock() 375 | } 376 | 377 | // always reply back with what services we provide, and our 378 | // id if we want to publicly advertise our microservice 379 | 380 | return ctx.Reply(n.createHandshakePacket(body[:0]).AppendTo(body[:0])) 381 | case OpcodeServiceRequest: 382 | provider := n.providers.findProvider(ctx.Conn()) 383 | if provider == nil { 384 | return errors.New("conn is not a provider") 385 | } 386 | 387 | packet, err := UnmarshalServiceRequestPacket(body) 388 | if err != nil { 389 | return fmt.Errorf("failed to decode service request packet: %w", err) 390 | } 391 | 392 | // if stream does not exist, the stream is a request to process some payload! register 393 | // a new stream, and handle it. 394 | 395 | // if the stream exists, it's a stream we made! proceed to start receiving its payload, 396 | // b/c the payload will basically be our peer giving us a response. 397 | 398 | // i guess we need to separate streams from the server-side, and streams from the client-side. 399 | // client-side starts with stream ids that are odd (1, 3, 5, 7, 9, ...) and server-side 400 | // starts with stream ids that are even (0, 2, 4, 6, 8, 10). 401 | 402 | stream, created := provider.RegisterStream(packet) 403 | if !created { 404 | return fmt.Errorf("got service request with stream id %d, but node is making service request"+ 405 | "with the given id already", packet.ID) 406 | } 407 | 408 | for _, service := range packet.Services { 409 | handler, exists := n.Services[service] 410 | if !exists { 411 | continue 412 | } 413 | 414 | go func() { 415 | ctx := acquireContext(*provider.id, packet.Headers, stream.Reader, stream.ID, ctx.Conn()) 416 | defer releaseContext(ctx) 417 | defer ctx.Body.Close() 418 | 419 | handler(ctx) 420 | 421 | if !ctx.written { 422 | packet := ServiceResponsePacket{ 423 | ID: ctx.nonce, 424 | Handled: true, 425 | Headers: ctx.headers, 426 | } 427 | 428 | ctx.written = true 429 | 430 | err := ctx.conn.Send(packet.AppendTo([]byte{OpcodeServiceResponse})) 431 | if err != nil { 432 | provider.CloseStreamWithError(stream, err) 433 | return 434 | } 435 | 436 | return 437 | } 438 | 439 | err := ctx.conn.Send(DataPacket{ID: stream.ID}.AppendTo([]byte{OpcodeData})) 440 | if err != nil { 441 | provider.CloseStreamWithError(stream, err) 442 | return 443 | } 444 | }() 445 | 446 | return nil 447 | } 448 | 449 | response := ServiceResponsePacket{ 450 | ID: packet.ID, 451 | Handled: false, 452 | } 453 | 454 | return ctx.Conn().SendNoWait(response.AppendTo([]byte{OpcodeServiceResponse})) 455 | case OpcodeServiceResponse: 456 | provider := n.providers.findProvider(ctx.Conn()) 457 | if provider == nil { 458 | return errors.New("conn is not a provider") 459 | } 460 | 461 | packet, err := UnmarshalServiceResponsePacket(body) 462 | if err != nil { 463 | return fmt.Errorf("failed to decode services response packet: %w", err) 464 | } 465 | 466 | stream, exists := provider.GetStream(packet.ID) 467 | if !exists { 468 | return fmt.Errorf("stream with id %d got a service response although no service request was sent", 469 | packet.ID) 470 | } 471 | 472 | stream.Header = &packet 473 | stream.once.Do(stream.wg.Done) 474 | 475 | return nil 476 | case OpcodeData: 477 | provider := n.providers.findProvider(ctx.Conn()) 478 | if provider == nil { 479 | return errors.New("conn is not a provider") 480 | } 481 | 482 | packet, err := UnmarshalDataPacket(body) 483 | if err != nil { 484 | return fmt.Errorf("failed to decode stream payload packet: %w", err) 485 | } 486 | 487 | // the stream must always exist 488 | 489 | stream, exists := provider.GetStream(packet.ID) 490 | if !exists { 491 | return fmt.Errorf("stream with id %d does not exist", packet.ID) 492 | } 493 | 494 | // there should never be any empty payload packets 495 | 496 | if len(packet.Data) > ChunkSize { 497 | err = fmt.Errorf("stream with id %d got packet over max limit of ChunkSize bytes: got %d bytes", 498 | packet.ID, len(packet.Data)) 499 | provider.CloseStreamWithError(stream, err) 500 | return err 501 | } 502 | 503 | // FIXME(kenta): is this check necessary? 504 | //if stream.ID%2 == 1 && stream { 505 | // err = fmt.Errorf("outgoing stream with id %d received a payload packet but has not received a header yet", 506 | // packet.ID) 507 | // provider.CloseStreamWithError(stream, err) 508 | // return err 509 | //} 510 | 511 | // if the chunk is zero-length, the stream has been closed 512 | 513 | if len(packet.Data) == 0 { 514 | provider.CloseStreamWithError(stream, io.EOF) 515 | } else { 516 | _, err = stream.Writer.Write(packet.Data) 517 | if err != nil { 518 | err = fmt.Errorf("failed to write payload: %w", err) 519 | provider.CloseStreamWithError(stream, err) 520 | return err 521 | } 522 | } 523 | 524 | return nil 525 | case OpcodeFindNodeRequest: 526 | packet, _, err := kademlia.UnmarshalFindNodeRequest(body) 527 | if err != nil { 528 | return err 529 | } 530 | 531 | n.tableLock.Lock() 532 | res := kademlia.FindNodeResponse{Closest: n.table.ClosestTo(packet.Target, kademlia.DefaultBucketSize)} 533 | n.tableLock.Unlock() 534 | 535 | return ctx.Reply(res.AppendTo(nil)) 536 | } 537 | 538 | return fmt.Errorf("unknown opcode %d", opcode) 539 | } 540 | 541 | func (n *Node) getClient(addr string) *monte.Client { 542 | n.clientsLock.Lock() 543 | defer n.clientsLock.Unlock() 544 | 545 | client, exists := n.clients[addr] 546 | if !exists { 547 | client = &monte.Client{ 548 | Addr: addr, 549 | Handler: n, 550 | ConnState: n, 551 | } 552 | n.clients[addr] = client 553 | } 554 | 555 | return client 556 | } 557 | 558 | func (n *Node) probe(conn *monte.Conn) error { 559 | provider, exists := n.providers.register(conn, nil, nil, true) 560 | 561 | req := n.createHandshakePacket(nil).AppendTo([]byte{OpcodeHandshake}) 562 | res, err := conn.Request(req[:0], req) 563 | if err != nil { 564 | return err 565 | } 566 | packet, err := UnmarshalHandshakePacket(res) 567 | if err != nil { 568 | return err 569 | } 570 | err = packet.Validate(req[:0]) 571 | if err != nil { 572 | return err 573 | } 574 | 575 | if packet.ID != nil { 576 | //addr = Addr(packet.ID.Host, packet.ID.Port) 577 | //if !packet.ID.Host.Equal(resolved.IP) || packet.ID.Port != uint16(resolved.Port) { 578 | // return provider, fmt.Errorf("dialed '%s' which advertised '%s'", resolved, addr) 579 | //} 580 | 581 | // update the provider with id and services info 582 | provider, _ = n.providers.register(conn, packet.ID, packet.Services, true) 583 | if !exists { 584 | log.Printf("You are now connected to %s. Services: %s", provider.Addr(), provider.Services()) 585 | } else { 586 | log.Printf("Re-probed %s. Services: %s", provider.Addr(), provider.Services()) 587 | } 588 | 589 | // update the routing table 590 | n.tableLock.Lock() 591 | n.table.Update(*packet.ID) 592 | n.tableLock.Unlock() 593 | } 594 | 595 | return nil 596 | } 597 | 598 | func (n *Node) Probe(addr string) error { 599 | resolved, err := net.ResolveTCPAddr("tcp", addr) 600 | if err != nil { 601 | return err 602 | } 603 | 604 | if resolved.IP == nil { 605 | resolved.IP = net.ParseIP("0.0.0.0") 606 | } 607 | 608 | conn, err := n.getClient(resolved.String()).Get() 609 | if err != nil { 610 | return err 611 | } 612 | 613 | err = n.probe(conn) 614 | if err != nil { 615 | return err 616 | } 617 | 618 | return nil 619 | } 620 | 621 | func (n *Node) Push(services []string, headers map[string]string, body io.ReadCloser) (*Stream, error) { 622 | providers := n.providers.getProviders(services...) 623 | 624 | // TODO(kenta): add additional strategies for selecting providers 625 | rand.Shuffle(len(providers), func(i, j int) { 626 | providers[i], providers[j] = providers[j], providers[i] 627 | }) 628 | 629 | for _, provider := range providers { 630 | stream, err := provider.Push(services, headers, body) 631 | if err != nil { 632 | if errors.Is(err, ErrProviderNotAvailable) { 633 | continue 634 | } 635 | return nil, err 636 | } 637 | return stream, nil 638 | } 639 | 640 | return nil, fmt.Errorf("no nodes were able to process your request for service(s): %s", services) 641 | } 642 | 643 | func (n *Node) createHandshakePacket(buf []byte) HandshakePacket { 644 | packet := HandshakePacket{Services: n.getServiceNames()} 645 | if n.id != nil { 646 | packet.ID = n.id 647 | packet.Signature = n.SecretKey.Sign(packet.AppendPayloadTo(buf)) 648 | } 649 | return packet 650 | } 651 | 652 | func (n *Node) createFindNodeRequest() kademlia.FindNodeRequest { 653 | var req kademlia.FindNodeRequest 654 | if n.id != nil { 655 | req.Target = n.id.Pub 656 | } 657 | return req 658 | } 659 | 660 | func (n *Node) getServiceNames() []string { 661 | if n.Services == nil { 662 | return nil 663 | } 664 | services := make([]string, 0, len(n.Services)) 665 | for service := range n.Services { 666 | services = append(services, service) 667 | } 668 | return services 669 | } 670 | -------------------------------------------------------------------------------- /nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flatend", 3 | "description": "Production-ready microservice mesh networks with just a few lines of code.", 4 | "author": "Kenta Iwasaki", 5 | "license": "MIT", 6 | "version": "0.0.8", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "prepare": "yarn tsc", 11 | "test": "TS_NODE_PROJECT=tests/tsconfig.json mocha -r ts-node/register tests/**/*.spec.ts" 12 | }, 13 | "dependencies": { 14 | "blake2b": "^2.1.3", 15 | "debug": "^4.1.1", 16 | "ipaddr.js": "^1.9.1", 17 | "object-hash": "^2.0.3", 18 | "tweetnacl": "^1.0.3" 19 | }, 20 | "devDependencies": { 21 | "@types/chai": "^4.2.11", 22 | "@types/debug": "^4.1.5", 23 | "@types/ip": "^1.1.0", 24 | "@types/mocha": "^7.0.2", 25 | "@types/node": "^14.0.13", 26 | "@types/object-hash": "^1.3.3", 27 | "chai": "^4.2.0", 28 | "chai-bytes": "^0.1.2", 29 | "core-js": "^3.6.5", 30 | "mocha": "^8.0.1", 31 | "prettier": "^2.0.5", 32 | "ts-node": "^8.10.2", 33 | "typescript": "^3.9.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /nodejs/src/context.ts: -------------------------------------------------------------------------------- 1 | import { Duplex, finished } from "stream"; 2 | import { Stream, STREAM_CHUNK_SIZE } from "./stream"; 3 | import { ID } from "./kademlia"; 4 | import util from "util"; 5 | import { DataPacket, Opcode, ServiceResponsePacket } from "./packet"; 6 | import { Provider } from "./provider"; 7 | import { chunkBuffer } from "./node"; 8 | 9 | export type Handler = (ctx: Context) => void; 10 | 11 | export class Context extends Duplex { 12 | _provider: Provider; 13 | _stream: Stream; 14 | _headersWritten = false; 15 | _headers: { [key: string]: string } = {}; 16 | 17 | headers: { [key: string]: string }; 18 | 19 | get id(): ID { 20 | return this._provider.id!; 21 | } 22 | 23 | constructor( 24 | provider: Provider, 25 | stream: Stream, 26 | headers: { [key: string]: string } 27 | ) { 28 | super(); 29 | 30 | this._provider = provider; 31 | this._stream = stream; 32 | this.headers = headers; 33 | 34 | // pipe stream body to context 35 | 36 | setImmediate(async () => { 37 | for await (const frame of this._stream.body) { 38 | this.push(frame); 39 | } 40 | this.push(null); 41 | }); 42 | 43 | // write stream eof when stream writable is closed 44 | 45 | setImmediate(async () => { 46 | await util.promisify(finished)(this, { readable: false }); 47 | 48 | await this._writeHeader(); 49 | 50 | const payload = new DataPacket(this._stream.id, Buffer.of()).encode(); 51 | await this._provider.write( 52 | this._provider.rpc.message( 53 | 0, 54 | Buffer.concat([Buffer.of(Opcode.Data), payload]) 55 | ) 56 | ); 57 | }); 58 | } 59 | 60 | header(key: string, val: string): Context { 61 | this._headers[key] = val; 62 | return this; 63 | } 64 | 65 | send(data: string | Buffer | Uint8Array) { 66 | this.write(data); 67 | if (!this.writableEnded) this.end(); 68 | } 69 | 70 | json(data: any) { 71 | this.header("content-type", "application/json"); 72 | this.send(JSON.stringify(data)); 73 | } 74 | 75 | _read(size: number) { 76 | this._stream.body._read(size); 77 | } 78 | 79 | async body(opts?: { limit?: number }): Promise { 80 | const limit = opts?.limit ?? 2 ** 16; 81 | 82 | let buf = Buffer.of(); 83 | for await (const chunk of this) { 84 | buf = Buffer.concat([buf, chunk]); 85 | if (buf.byteLength > limit) { 86 | throw new Error( 87 | `Exceeded max allowed body size limit of ${limit} byte(s).` 88 | ); 89 | } 90 | } 91 | 92 | return buf; 93 | } 94 | 95 | async _writeHeader() { 96 | if (!this._headersWritten) { 97 | this._headersWritten = true; 98 | 99 | const payload = new ServiceResponsePacket( 100 | this._stream.id, 101 | true, 102 | this._headers 103 | ).encode(); 104 | await this._provider.write( 105 | this._provider.rpc.message( 106 | 0, 107 | Buffer.concat([Buffer.of(Opcode.ServiceResponse), payload]) 108 | ) 109 | ); 110 | } 111 | } 112 | 113 | _write( 114 | chunk: any, 115 | encoding: BufferEncoding, 116 | callback: (error?: Error | null) => void 117 | ) { 118 | const write = async () => { 119 | await this._writeHeader(); 120 | 121 | const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); 122 | 123 | for (const chunk of chunkBuffer(buf, STREAM_CHUNK_SIZE)) { 124 | const payload = new DataPacket(this._stream.id, chunk).encode(); 125 | await this._provider.write( 126 | this._provider.rpc.message( 127 | 0, 128 | Buffer.concat([Buffer.of(Opcode.Data), payload]) 129 | ) 130 | ); 131 | } 132 | }; 133 | 134 | write() 135 | .then(() => callback()) 136 | .catch((error) => callback(error)); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /nodejs/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Node, generateSecretKey } from "./node"; 2 | export { Context } from "./context"; 3 | export { ID, Table, UpdateResult } from "./kademlia"; 4 | export { getAvailableAddress, splitHostPort } from "./net"; 5 | export { Provider } from "./provider"; 6 | export { x25519, serverHandshake, clientHandshake, Session } from "./session"; 7 | export { 8 | drain, 9 | lengthPrefixed, 10 | prefixLength, 11 | RPC, 12 | Stream, 13 | Streams, 14 | } from "./stream"; 15 | -------------------------------------------------------------------------------- /nodejs/src/kademlia.ts: -------------------------------------------------------------------------------- 1 | import nacl from "tweetnacl"; 2 | import ipaddr, { IPv4, IPv6 } from "ipaddr.js"; 3 | import assert from "assert"; 4 | 5 | export enum UpdateResult { 6 | New, 7 | Ok, 8 | Full, 9 | Fail, 10 | } 11 | 12 | const leadingZeros = (buf: Uint8Array): number => { 13 | const i = buf.findIndex((b) => b != 0); 14 | if (i === -1) return buf.byteLength * 8; 15 | 16 | let b = buf[i] >>> 0; 17 | if (b === 0) return i * 8 + 8; 18 | return i * 8 + ((7 - ((Math.log(b) / Math.LN2) | 0)) | 0); 19 | }; 20 | 21 | const xor = (a: Uint8Array, b: Uint8Array): Uint8Array => { 22 | const c = Buffer.alloc(Math.min(a.byteLength, b.byteLength)); 23 | for (let i = 0; i < c.byteLength; i++) c[i] = a[i] ^ b[i]; 24 | return c; 25 | }; 26 | 27 | export class ID { 28 | publicKey: Uint8Array = Buffer.alloc(nacl.sign.publicKeyLength); 29 | host: IPv4 | IPv6; 30 | port: number = 0; 31 | 32 | constructor(publicKey: Uint8Array, host: IPv4 | IPv6, port: number) { 33 | this.publicKey = publicKey; 34 | this.host = host; 35 | this.port = port; 36 | } 37 | 38 | get addr(): string { 39 | let host = this.host; 40 | if (host.kind() === "ipv6" && (host).isIPv4MappedAddress()) { 41 | host = (host).toIPv4Address(); 42 | } 43 | return host.toString() + ":" + this.port; 44 | } 45 | 46 | public encode(): Buffer { 47 | let host = Buffer.of(...this.host.toByteArray()); 48 | host = Buffer.concat([Buffer.of(host.byteLength === 4 ? 0 : 1), host]); 49 | 50 | const port = Buffer.alloc(2); 51 | port.writeUInt16BE(this.port); 52 | 53 | return Buffer.concat([this.publicKey, host, port]); 54 | } 55 | 56 | public static decode(buf: Buffer): [ID, Buffer] { 57 | const publicKey = Uint8Array.from(buf.slice(0, nacl.sign.publicKeyLength)); 58 | buf = buf.slice(nacl.sign.publicKeyLength); 59 | 60 | const hostHeader = buf.readUInt8(); 61 | buf = buf.slice(1); 62 | 63 | assert(hostHeader === 0 || hostHeader === 1); 64 | 65 | const hostLen = hostHeader === 0 ? 4 : 16; 66 | const host = ipaddr.fromByteArray([...buf.slice(0, hostLen)]); 67 | buf = buf.slice(hostLen); 68 | 69 | const port = buf.readUInt16BE(); 70 | buf = buf.slice(2); 71 | 72 | return [new ID(publicKey, host, port), buf]; 73 | } 74 | } 75 | 76 | export class Table { 77 | buckets: Array> = [ 78 | ...Array(nacl.sign.publicKeyLength * 8), 79 | ].map(() => []); 80 | 81 | pub: Uint8Array; 82 | cap: number = 16; 83 | length: number = 0; 84 | 85 | public constructor( 86 | pub: Uint8Array = Buffer.alloc(nacl.sign.publicKeyLength) 87 | ) { 88 | this.pub = pub; 89 | } 90 | 91 | private bucketIndex(pub: Uint8Array): number { 92 | if (Buffer.compare(pub, this.pub) === 0) return 0; 93 | return leadingZeros(xor(pub, this.pub)); 94 | } 95 | 96 | public update(id: ID): UpdateResult { 97 | if (Buffer.compare(id.publicKey, this.pub) === 0) return UpdateResult.Fail; 98 | 99 | const bucket = this.buckets[this.bucketIndex(id.publicKey)]; 100 | 101 | const i = bucket.findIndex( 102 | (item) => Buffer.compare(item.publicKey, id.publicKey) === 0 103 | ); 104 | if (i >= 0) { 105 | bucket.unshift(...bucket.splice(i, 1)); 106 | return UpdateResult.Ok; 107 | } 108 | 109 | if (bucket.length < this.cap) { 110 | bucket.unshift(id); 111 | this.length++; 112 | return UpdateResult.New; 113 | } 114 | return UpdateResult.Full; 115 | } 116 | 117 | public delete(pub: Uint8Array): boolean { 118 | const bucket = this.buckets[this.bucketIndex(pub)]; 119 | const i = bucket.findIndex((id) => Buffer.compare(id.publicKey, pub) === 0); 120 | if (i >= 0) { 121 | bucket.splice(i, 1); 122 | this.length--; 123 | return true; 124 | } 125 | return false; 126 | } 127 | 128 | public has(pub: Uint8Array): boolean { 129 | const bucket = this.buckets[this.bucketIndex(pub)]; 130 | return !!bucket.find((id) => Buffer.compare(id.publicKey, pub) === 0); 131 | } 132 | 133 | public closestTo(pub: Uint8Array, k = this.cap): ID[] { 134 | const closest: ID[] = []; 135 | 136 | const fill = (i: number) => { 137 | const bucket = this.buckets[i]; 138 | for (let i = 0; closest.length < k && i < bucket.length; i++) { 139 | if (Buffer.compare(bucket[i].publicKey, pub) != 0) 140 | closest.push(bucket[i]); 141 | } 142 | }; 143 | 144 | const m = this.bucketIndex(pub); 145 | 146 | fill(m); 147 | 148 | for ( 149 | let i = 1; 150 | closest.length < k && (m - i >= 0 || m + i < this.buckets.length); 151 | i++ 152 | ) { 153 | if (m - i >= 0) fill(m - i); 154 | if (m + i < this.buckets.length) fill(m + i); 155 | } 156 | 157 | closest.sort((a: ID, b: ID) => 158 | Buffer.compare(xor(a.publicKey, pub), xor(b.publicKey, pub)) 159 | ); 160 | 161 | return closest.length > k ? closest.slice(0, k) : closest; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /nodejs/src/net.ts: -------------------------------------------------------------------------------- 1 | import { IPv4, IPv6 } from "ipaddr.js"; 2 | import net from "net"; 3 | import events from "events"; 4 | import ipaddr from "ipaddr.js"; 5 | 6 | /** 7 | * Returns an available TCP host/port that may be listened to. 8 | */ 9 | export async function getAvailableAddress(): Promise<{ 10 | family: string; 11 | host: IPv4 | IPv6; 12 | port: number; 13 | }> { 14 | const server = net.createServer(); 15 | server.unref(); 16 | server.listen(); 17 | 18 | await events.once(server, "listening"); 19 | 20 | const info = (server.address())!; 21 | 22 | let host = ipaddr.parse(info.address.length === 0 ? "0.0.0.0" : info.address); 23 | if (host.kind() === "ipv6" && (host).isIPv4MappedAddress()) { 24 | host = (host).toIPv4Address(); 25 | } 26 | 27 | server.close(); 28 | 29 | await events.once(server, "close"); 30 | 31 | return { family: info.family, host, port: info.port }; 32 | } 33 | 34 | export function splitHostPort( 35 | addr: string 36 | ): { host: IPv4 | IPv6; port: number } { 37 | const fields = addr.split(":").filter((field) => field.length > 0); 38 | if (fields.length === 0) 39 | throw new Error("Unable to split host:port from address."); 40 | 41 | const port = parseInt(fields.pop()!); 42 | if (port < 0 || port > 2 ** 16) throw new Error(`Port ${port} is invalid.`); 43 | 44 | let host = ipaddr.parse(fields.length === 0 ? "0.0.0.0" : fields.join(":")); 45 | if (host.kind() === "ipv6" && (host).isIPv4MappedAddress()) { 46 | host = (host).toIPv4Address(); 47 | } 48 | 49 | return { host, port }; 50 | } 51 | -------------------------------------------------------------------------------- /nodejs/src/node.ts: -------------------------------------------------------------------------------- 1 | import { Context, Handler } from "./context"; 2 | import net from "net"; 3 | import { ID, Table } from "./kademlia"; 4 | import nacl from "tweetnacl"; 5 | import { getAvailableAddress, splitHostPort } from "./net"; 6 | import { IPv4, IPv6 } from "ipaddr.js"; 7 | import { 8 | DataPacket, 9 | FindNodeRequest, 10 | FindNodeResponse, 11 | HandshakePacket, 12 | Opcode, 13 | ServiceRequestPacket, 14 | ServiceResponsePacket, 15 | } from "./packet"; 16 | import events from "events"; 17 | import { clientHandshake, serverHandshake, Session } from "./session"; 18 | import hash from "object-hash"; 19 | import { Provider } from "./provider"; 20 | 21 | const debug = require("debug")("flatend"); 22 | 23 | export interface NodeOptions { 24 | // A reachable, public address which peers may reach you on. 25 | // The format of the address must be [host]:[port]. 26 | publicAddr?: string; 27 | 28 | // A list of [host]:[port] addresses which this node will bind a listener 29 | // against to accept new Flatend nodes. 30 | bindAddrs?: string[]; 31 | 32 | // A list of addresses to nodes to initially reach out 33 | // for/bootstrap from first. 34 | addrs?: string[]; 35 | 36 | // An Ed25519 secret key. A secret key must be provided to allow for 37 | // peers to reach you. A secret key may be generated by calling 38 | // 'flatend.generateSecretKey()'. 39 | secretKey?: Uint8Array; 40 | 41 | // A mapping of service names to their respective handlers. 42 | services?: { [key: string]: Handler }; 43 | } 44 | 45 | export class Node { 46 | services = new Map>(); 47 | clients = new Map(); 48 | servers = new Set(); 49 | conns = new Set(); 50 | table = new Table(); 51 | 52 | id?: ID; 53 | keys?: nacl.SignKeyPair; 54 | handlers: { [key: string]: Handler } = {}; 55 | _shutdown = false; 56 | 57 | public static async start(opts: NodeOptions): Promise { 58 | const node = new Node(); 59 | 60 | if (opts.services) node.handlers = opts.services; 61 | 62 | if (opts.secretKey) { 63 | node.keys = nacl.sign.keyPair.fromSecretKey(opts.secretKey); 64 | 65 | debug(`Public Key: ${Buffer.from(node.keys.publicKey).toString("hex")}`); 66 | 67 | const bindAddrs = opts.bindAddrs ?? []; 68 | if (bindAddrs.length === 0) { 69 | if (opts.publicAddr) { 70 | bindAddrs.push(opts.publicAddr); 71 | } else { 72 | const { host, port } = await getAvailableAddress(); 73 | bindAddrs.push(host + ":" + port); 74 | } 75 | } 76 | 77 | let publicHost: IPv4 | IPv6; 78 | let publicPort: number; 79 | 80 | if (opts.publicAddr) { 81 | const { host, port } = splitHostPort(opts.publicAddr); 82 | publicHost = host; 83 | publicPort = port; 84 | } else { 85 | const { host, port } = splitHostPort(bindAddrs[0]); 86 | publicHost = host; 87 | publicPort = port; 88 | } 89 | 90 | node.id = new ID(node.keys.publicKey, publicHost, publicPort); 91 | node.table = new Table(node.id.publicKey); 92 | 93 | const promises = []; 94 | 95 | for (const bindAddr of bindAddrs) { 96 | const { host, port } = splitHostPort(bindAddr); 97 | promises.push(node.listen({ host: host.toString(), port })); 98 | } 99 | 100 | await Promise.all(promises); 101 | } 102 | 103 | if (opts.addrs) { 104 | const promises = []; 105 | 106 | for (const addr of opts.addrs) { 107 | const { host, port } = splitHostPort(addr); 108 | promises.push(node.connect({ host: host.toString(), port: port })); 109 | } 110 | 111 | await Promise.all(promises); 112 | await node.bootstrap(); 113 | } 114 | 115 | return node; 116 | } 117 | 118 | async bootstrap() { 119 | const pub = this.id?.publicKey ?? Buffer.alloc(nacl.sign.publicKeyLength); 120 | const visited = new Set(); 121 | 122 | let queue: ID[] = this.table.closestTo(pub, this.table.cap); 123 | if (queue.length === 0) return; 124 | 125 | for (const id of queue) { 126 | visited.add(Buffer.from(id.publicKey).toString("hex")); 127 | } 128 | 129 | const closest: ID[] = []; 130 | 131 | while (queue.length > 0) { 132 | const next: ID[] = []; 133 | 134 | await Promise.all( 135 | queue.map(async (id) => { 136 | const { host, port } = splitHostPort(id.addr); 137 | 138 | try { 139 | const client = await this.connect({ host: host.toString(), port }); 140 | 141 | const res = FindNodeResponse.decode( 142 | await client.request( 143 | Buffer.concat([ 144 | Buffer.of(Opcode.FindNodeRequest), 145 | new FindNodeRequest(pub).encode(), 146 | ]) 147 | ) 148 | )[0]; 149 | 150 | res.closest = res.closest.filter((id) => { 151 | return !visited.has(Buffer.from(id.publicKey).toString("hex")); 152 | }); 153 | 154 | closest.push(...res.closest); 155 | next.push(...res.closest); 156 | } catch (err) { 157 | // ignore 158 | } 159 | }) 160 | ); 161 | 162 | queue = next; 163 | } 164 | 165 | debug(`Discovered ${closest.length} peer(s).`); 166 | } 167 | 168 | /** 169 | * Shuts down all active connections and listeners on this node. After shutting 170 | * down a node, it may not be reused. \ 171 | */ 172 | async shutdown() { 173 | if (this._shutdown) throw new Error("Node is shut down."); 174 | 175 | this._shutdown = true; 176 | 177 | const promises = []; 178 | 179 | for (const conn of this.conns) { 180 | promises.push(events.once(conn, "close")); 181 | conn.end(); 182 | } 183 | 184 | for (const server of this.servers) { 185 | promises.push(events.once(server, "close")); 186 | server.close(); 187 | } 188 | 189 | await Promise.all(promises); 190 | } 191 | 192 | /** 193 | * Provides a list of nodes that provide either one of the many specified services. 194 | * 195 | * @param services List of services. 196 | */ 197 | providersFor(services: string[]): Provider[] { 198 | const map = this._providers(services).reduce( 199 | (map: Map, provider: Provider) => 200 | provider.id?.publicKey 201 | ? map.set(hash(provider.id.publicKey), provider) 202 | : map, 203 | new Map() 204 | ); 205 | 206 | return [...map.values()]; 207 | } 208 | 209 | _providers(services: string[]): Provider[] { 210 | const providers: Provider[] = []; 211 | for (const service of services) { 212 | const entries = this.services.get(service); 213 | if (!entries) continue; 214 | providers.push(...entries); 215 | } 216 | return providers; 217 | } 218 | 219 | /** 220 | * Request one of any available nodes to provide one of the many specified services. A request header 221 | * may be attached to the request sent out to a designated node, along with a body. 222 | * 223 | * @param services List of services. 224 | * @param headers Request headers. 225 | * @param body The request body. Must not be null/undefined. 226 | */ 227 | async push( 228 | services: string[], 229 | headers: { [key: string]: string }, 230 | body: AsyncIterable 231 | ) { 232 | if (this._shutdown) throw new Error("Node is shut down."); 233 | 234 | const providers = this._providers(services); 235 | 236 | for (const provider of providers) { 237 | return await provider.push(services, headers, body); 238 | } 239 | 240 | throw new Error( 241 | `No nodes were able to process your request for service(s): [${services.join( 242 | ", " 243 | )}]` 244 | ); 245 | } 246 | 247 | /** 248 | * Start listening for Flatend nodes at a specified IP family/host/port. 249 | * 250 | * @param opts IP family/host/port. 251 | */ 252 | async listen(opts: net.ListenOptions) { 253 | if (this._shutdown) throw new Error("Node is shut down."); 254 | 255 | const server = net.createServer(async (conn) => { 256 | this.conns.add(conn); 257 | 258 | setImmediate(async () => { 259 | await events.once(conn, "close"); 260 | this.conns.delete(conn); 261 | }); 262 | 263 | try { 264 | const secret = await serverHandshake(conn); 265 | const session = new Session(secret); 266 | 267 | const provider = new Provider(conn, session, false); 268 | setImmediate(() => this.read(provider)); 269 | } catch (err) { 270 | debug("Error from incoming node:", err); 271 | conn.end(); 272 | } 273 | }); 274 | 275 | server.listen(opts); 276 | 277 | await events.once(server, "listening"); 278 | 279 | this.servers.add(server); 280 | 281 | setImmediate(async () => { 282 | await events.once(server, "close"); 283 | this.servers.delete(server); 284 | }); 285 | 286 | const info = (server.address())!; 287 | 288 | debug(`Listening for Flatend nodes on '${info.address}:${info.port}'.`); 289 | } 290 | 291 | /** 292 | * Connect to a Flatend node and ask and keep track of the services it provides. 293 | * 294 | * @param opts Flatend node IP family/host/port. 295 | */ 296 | async connect(opts: net.NetConnectOpts) { 297 | if (this._shutdown) throw new Error("Node is shut down."); 298 | 299 | let provider = this.clients.get(hash(opts)); 300 | 301 | if (!provider) { 302 | const conn = net.connect(opts); 303 | await events.once(conn, "connect"); 304 | 305 | this.conns.add(conn); 306 | 307 | setImmediate(async () => { 308 | await events.once(conn, "close"); 309 | this.clients.delete(hash(opts)); 310 | this.conns.delete(conn); 311 | }); 312 | 313 | try { 314 | const secret = await clientHandshake(conn); 315 | const session = new Session(secret); 316 | 317 | provider = new Provider(conn, session, true); 318 | this.clients.set(hash(opts), provider); 319 | 320 | setImmediate(() => this.read(provider!)); 321 | 322 | const handshake = new HandshakePacket( 323 | this.id, 324 | [...Object.keys(this.handlers)], 325 | undefined 326 | ); 327 | if (this.keys) 328 | handshake.signature = nacl.sign.detached( 329 | handshake.payload, 330 | this.keys.secretKey 331 | ); 332 | 333 | const response = await provider.request( 334 | Buffer.concat([Buffer.of(Opcode.Handshake), handshake.encode()]) 335 | ); 336 | const packet = HandshakePacket.decode(response)[0]; 337 | 338 | provider.handshaked = true; 339 | 340 | if (packet.id && packet.signature) { 341 | if ( 342 | !nacl.sign.detached.verify( 343 | packet.payload, 344 | packet.signature, 345 | packet.id.publicKey 346 | ) 347 | ) { 348 | throw new Error(`Handshake packet signature is malformed.`); 349 | } 350 | provider.id = packet.id; 351 | this.table.update(provider.id); 352 | } 353 | 354 | debug( 355 | `You have connected to '${ 356 | provider.addr 357 | }'. Services: [${packet.services.join(", ")}]` 358 | ); 359 | 360 | for (const service of packet.services) { 361 | provider.services.add(service); 362 | 363 | let providers = this.services.get(service); 364 | if (!providers) { 365 | providers = new Set(); 366 | this.services.set(service, providers); 367 | } 368 | providers.add(provider); 369 | } 370 | 371 | setImmediate(async () => { 372 | await events.once(provider!.sock, "end"); 373 | 374 | debug( 375 | `'${ 376 | provider!.addr 377 | }' has disconnected from you. Services: [${packet.services.join( 378 | ", " 379 | )}]` 380 | ); 381 | 382 | if (provider!.id) { 383 | this.table.delete(provider!.id.publicKey); 384 | } 385 | 386 | for (const service of packet.services) { 387 | let providers = this.services.get(service)!; 388 | if (!providers) continue; 389 | 390 | providers.delete(provider!); 391 | if (providers.size === 0) this.services.delete(service); 392 | } 393 | }); 394 | 395 | setImmediate(async () => { 396 | await events.once(provider!.sock, "end"); 397 | 398 | if (this._shutdown) return; 399 | 400 | let count = 8; 401 | 402 | const reconnect = async () => { 403 | if (this._shutdown) return; 404 | 405 | if (count-- === 0) { 406 | debug( 407 | `Tried 8 times reconnecting to ${provider!.addr}. Giving up.` 408 | ); 409 | return; 410 | } 411 | 412 | debug( 413 | `Trying to reconnect to '${provider!.addr}'. Sleeping for 500ms.` 414 | ); 415 | 416 | try { 417 | await this.connect(opts); 418 | } catch (err) { 419 | setTimeout(reconnect, 500); 420 | } 421 | }; 422 | 423 | setTimeout(reconnect, 500); 424 | }); 425 | } catch (err) { 426 | conn.end(); 427 | throw err; 428 | } 429 | } 430 | 431 | return provider; 432 | } 433 | 434 | async read(provider: Provider) { 435 | try { 436 | await this._read(provider); 437 | } catch (err) { 438 | debug("Provider had shut down with an error:", err); 439 | } 440 | 441 | provider.sock.end(); 442 | } 443 | 444 | async _read(provider: Provider) { 445 | for await (const { seq, opcode, frame } of provider.read()) { 446 | await this._handle(provider, seq, opcode, frame); 447 | } 448 | } 449 | 450 | async _handle( 451 | provider: Provider, 452 | seq: number, 453 | opcode: number, 454 | frame: Buffer 455 | ) { 456 | switch (opcode) { 457 | case Opcode.Handshake: { 458 | if (provider.handshaked) { 459 | throw new Error("Provider attempted to handshake twice."); 460 | } 461 | provider.handshaked = true; 462 | 463 | const packet = HandshakePacket.decode(frame)[0]; 464 | if (packet.id && packet.signature) { 465 | if ( 466 | !nacl.sign.detached.verify( 467 | packet.payload, 468 | packet.signature, 469 | packet.id.publicKey 470 | ) 471 | ) { 472 | throw new Error(`Handshake packet signature is malformed.`); 473 | } 474 | provider.id = packet.id; 475 | this.table.update(provider.id); 476 | } 477 | 478 | debug( 479 | `'${ 480 | provider.addr 481 | }' has connected to you. Services: [${packet.services.join(", ")}]` 482 | ); 483 | 484 | for (const service of packet.services) { 485 | provider.services.add(service); 486 | 487 | let providers = this.services.get(service); 488 | if (!providers) { 489 | providers = new Set(); 490 | this.services.set(service, providers); 491 | } 492 | providers.add(provider); 493 | } 494 | 495 | setImmediate(async () => { 496 | await events.once(provider.sock, "end"); 497 | 498 | debug( 499 | `'${ 500 | provider.addr 501 | }' has disconnected from you. Services: [${packet.services.join( 502 | ", " 503 | )}]` 504 | ); 505 | 506 | if (provider.id) { 507 | this.table.delete(provider.id.publicKey); 508 | } 509 | 510 | for (const service of packet.services) { 511 | let providers = this.services.get(service)!; 512 | if (!providers) continue; 513 | 514 | providers.delete(provider); 515 | if (providers.size === 0) this.services.delete(service); 516 | } 517 | }); 518 | 519 | const response = new HandshakePacket( 520 | this.id, 521 | [...Object.keys(this.handlers)], 522 | undefined 523 | ); 524 | if (this.keys) 525 | response.signature = nacl.sign.detached( 526 | response.payload, 527 | this.keys.secretKey 528 | ); 529 | 530 | await provider.write(provider.rpc.message(seq, response.encode())); 531 | 532 | return; 533 | } 534 | case Opcode.ServiceRequest: { 535 | const packet = ServiceRequestPacket.decode(frame)[0]; 536 | const stream = provider.streams.register(packet.id); 537 | 538 | const service = packet.services.find( 539 | (service) => service in this.handlers 540 | ); 541 | if (!service) { 542 | const payload = new ServiceResponsePacket( 543 | packet.id, 544 | false, 545 | {} 546 | ).encode(); 547 | await provider.write( 548 | provider.rpc.message( 549 | 0, 550 | Buffer.concat([Buffer.of(Opcode.ServiceResponse), payload]) 551 | ) 552 | ); 553 | } else { 554 | const ctx = new Context(provider, stream, packet.headers); 555 | const handler = this.handlers[service]; 556 | 557 | setImmediate(async () => { 558 | try { 559 | await handler(ctx); 560 | } catch (err) { 561 | if (!ctx.writableEnded) { 562 | ctx.json({ error: err?.message ?? "Internal server error." }); 563 | } 564 | } 565 | }); 566 | } 567 | 568 | return; 569 | } 570 | case Opcode.ServiceResponse: { 571 | const packet = ServiceResponsePacket.decode(frame)[0]; 572 | const stream = provider.streams.get(packet.id); 573 | if (!stream) { 574 | throw new Error( 575 | `Got response headers for stream ID ${packet.id} which is not registered.` 576 | ); 577 | } 578 | provider.streams.pull(stream, packet.handled, packet.headers); 579 | return; 580 | } 581 | case Opcode.Data: { 582 | const packet = DataPacket.decode(frame)[0]; 583 | const stream = provider.streams.get(packet.id); 584 | if (!stream) { 585 | throw new Error( 586 | `Got data for stream ID ${packet.id} which is not registered, or has ended.` 587 | ); 588 | } 589 | provider.streams.recv(stream, packet.data); 590 | return; 591 | } 592 | case Opcode.FindNodeRequest: { 593 | const packet = FindNodeRequest.decode(frame)[0]; 594 | const response = new FindNodeResponse( 595 | this.table.closestTo(packet.target, this.table.cap) 596 | ); 597 | await provider.write(provider.rpc.message(seq, response.encode())); 598 | } 599 | } 600 | } 601 | } 602 | 603 | /** 604 | * Generates an Ed25519 secret key for a node. 605 | */ 606 | export function generateSecretKey(): Buffer { 607 | return Buffer.from(nacl.sign.keyPair().secretKey); 608 | } 609 | 610 | export function* chunkBuffer(buf: Buffer, size: number) { 611 | while (buf.byteLength > 0) { 612 | size = size > buf.byteLength ? buf.byteLength : size; 613 | yield buf.slice(0, size); 614 | buf = buf.slice(size); 615 | } 616 | } 617 | -------------------------------------------------------------------------------- /nodejs/src/packet.ts: -------------------------------------------------------------------------------- 1 | import nacl from "tweetnacl"; 2 | import assert from "assert"; 3 | import { ID } from "./kademlia"; 4 | 5 | export enum Opcode { 6 | Handshake, 7 | ServiceRequest, 8 | ServiceResponse, 9 | Data, 10 | FindNodeRequest, 11 | FindNodeResponse, 12 | } 13 | 14 | export class HandshakePacket { 15 | id?: ID; 16 | services: string[] = []; 17 | signature?: Uint8Array; 18 | 19 | constructor( 20 | id: ID | undefined, 21 | services: string[], 22 | signature: Uint8Array | undefined 23 | ) { 24 | this.id = id; 25 | this.services = services; 26 | this.signature = signature; 27 | } 28 | 29 | get payload() { 30 | return Buffer.concat([ 31 | this.id!.encode(), 32 | ...this.services.map((service) => Buffer.from(service, "utf8")), 33 | ]); 34 | } 35 | 36 | public encode(): Buffer { 37 | const id = this.id 38 | ? Buffer.concat([Buffer.of(1), this.id.encode()]) 39 | : Buffer.of(0); 40 | const services = this.services.reduce( 41 | (result, service) => 42 | Buffer.concat([ 43 | result, 44 | Buffer.of(service.length), 45 | Buffer.from(service, "utf8"), 46 | ]), 47 | Buffer.of(this.services.length) 48 | ); 49 | const signature = this.id && this.signature ? this.signature : Buffer.of(); 50 | 51 | return Buffer.concat([id, services, signature]); 52 | } 53 | 54 | public static decode(buf: Buffer): [HandshakePacket, Buffer] { 55 | const header = buf.readUInt8(); 56 | buf = buf.slice(1); 57 | 58 | assert(header === 0 || header === 1); 59 | 60 | const result: [ID | undefined, Buffer] = 61 | header === 1 ? ID.decode(buf) : [undefined, buf]; 62 | 63 | const id = result[0]; 64 | buf = result[1]; 65 | 66 | const size = buf.readUInt8(); 67 | buf = buf.slice(1); 68 | 69 | const services: string[] = [...Array(size)].map(() => { 70 | const size = buf.readUInt8(); 71 | buf = buf.slice(1); 72 | 73 | const service = buf.slice(0, size); 74 | buf = buf.slice(size); 75 | 76 | return service.toString("utf8"); 77 | }); 78 | 79 | let signature: Buffer | undefined; 80 | if (id) { 81 | signature = buf.slice(0, nacl.sign.signatureLength); 82 | buf = buf.slice(nacl.sign.signatureLength); 83 | } 84 | 85 | return [new HandshakePacket(id, services, signature), buf]; 86 | } 87 | } 88 | 89 | export class ServiceRequestPacket { 90 | id: number; 91 | services: string[] = []; 92 | headers: { [key: string]: string }; 93 | 94 | public constructor( 95 | id: number, 96 | services: string[], 97 | headers: { [key: string]: string } 98 | ) { 99 | this.id = id; 100 | this.services = services; 101 | this.headers = headers; 102 | } 103 | 104 | public encode(): Buffer { 105 | const id = Buffer.alloc(4); 106 | id.writeUInt32BE(this.id); 107 | 108 | const services = this.services.reduce( 109 | (result, service) => 110 | Buffer.concat([ 111 | result, 112 | Buffer.of(service.length), 113 | Buffer.from(service, "utf8"), 114 | ]), 115 | Buffer.of(this.services.length) 116 | ); 117 | 118 | const headersLen = Buffer.alloc(2); 119 | headersLen.writeUInt16BE(Object.keys(this.headers).length); 120 | 121 | const headers = Object.keys(this.headers).reduce((result, key) => { 122 | const value = this.headers[key]; 123 | 124 | const keyBuf = Buffer.concat([ 125 | Buffer.of(key.length), 126 | Buffer.from(key, "utf8"), 127 | ]); 128 | const valueBuf = Buffer.concat([ 129 | Buffer.alloc(2), 130 | Buffer.from(value, "utf8"), 131 | ]); 132 | valueBuf.writeUInt16BE(value.length); 133 | 134 | return Buffer.concat([result, keyBuf, valueBuf]); 135 | }, headersLen); 136 | 137 | return Buffer.concat([id, services, headers]); 138 | } 139 | 140 | public static decode(buf: Buffer): [ServiceRequestPacket, Buffer] { 141 | const id = buf.readUInt32BE(); 142 | buf = buf.slice(4); 143 | 144 | const servicesLen = buf.readUInt8(); 145 | buf = buf.slice(1); 146 | 147 | const services: string[] = [...Array(servicesLen)].map(() => { 148 | const serviceLen = buf.readUInt8(); 149 | buf = buf.slice(1); 150 | 151 | const service = buf.slice(0, serviceLen); 152 | buf = buf.slice(serviceLen); 153 | 154 | return service.toString("utf8"); 155 | }); 156 | 157 | const headersLen = buf.readUInt16BE(); 158 | buf = buf.slice(2); 159 | 160 | const headers = [...Array(headersLen)].reduce((map, _) => { 161 | const keyLen = buf.readUInt8(); 162 | buf = buf.slice(1); 163 | 164 | const key = buf.slice(0, keyLen).toString("utf8"); 165 | buf = buf.slice(keyLen); 166 | 167 | const valueLen = buf.readUInt16BE(); 168 | buf = buf.slice(2); 169 | 170 | const value = buf.slice(0, valueLen).toString("utf8"); 171 | buf = buf.slice(valueLen); 172 | 173 | map[key] = value; 174 | return map; 175 | }, {}); 176 | 177 | return [new ServiceRequestPacket(id, services, headers), buf]; 178 | } 179 | } 180 | 181 | export class ServiceResponsePacket { 182 | id: number; 183 | handled: boolean = false; 184 | headers: { [key: string]: string }; 185 | 186 | public constructor( 187 | id: number, 188 | handled: boolean, 189 | headers: { [key: string]: string } 190 | ) { 191 | this.id = id; 192 | this.handled = handled; 193 | this.headers = headers; 194 | } 195 | 196 | public encode(): Buffer { 197 | const id = Buffer.alloc(4); 198 | id.writeUInt32BE(this.id); 199 | 200 | const handled = Buffer.of(this.handled ? 1 : 0); 201 | 202 | const headersLen = Buffer.alloc(2); 203 | headersLen.writeUInt16BE(Object.keys(this.headers).length); 204 | 205 | const headers = Object.keys(this.headers).reduce((result, key) => { 206 | const value = this.headers[key]; 207 | 208 | const keyBuf = Buffer.concat([ 209 | Buffer.of(key.length), 210 | Buffer.from(key, "utf8"), 211 | ]); 212 | const valueBuf = Buffer.concat([ 213 | Buffer.alloc(2), 214 | Buffer.from(value, "utf8"), 215 | ]); 216 | valueBuf.writeUInt16BE(value.length); 217 | 218 | return Buffer.concat([result, keyBuf, valueBuf]); 219 | }, headersLen); 220 | 221 | return Buffer.concat([id, handled, headers]); 222 | } 223 | 224 | public static decode(buf: Buffer): [ServiceResponsePacket, Buffer] { 225 | const id = buf.readUInt32BE(); 226 | buf = buf.slice(4); 227 | 228 | const handled = buf.readUInt8() === 1; 229 | buf = buf.slice(1); 230 | 231 | const headersLen = buf.readUInt16BE(); 232 | buf = buf.slice(2); 233 | 234 | const headers = [...Array(headersLen)].reduce((map, _) => { 235 | const keyLen = buf.readUInt8(); 236 | buf = buf.slice(1); 237 | 238 | const key = buf.slice(0, keyLen).toString("utf8"); 239 | buf = buf.slice(keyLen); 240 | 241 | const valueLen = buf.readUInt16BE(); 242 | buf = buf.slice(2); 243 | 244 | const value = buf.slice(0, valueLen).toString("utf8"); 245 | buf = buf.slice(valueLen); 246 | 247 | map[key] = value; 248 | return map; 249 | }, {}); 250 | 251 | return [new ServiceResponsePacket(id, handled, headers), buf]; 252 | } 253 | } 254 | 255 | export class DataPacket { 256 | id: number; 257 | data: Buffer; 258 | 259 | public constructor(id: number, data: Buffer) { 260 | this.id = id; 261 | this.data = data; 262 | } 263 | 264 | public encode(): Buffer { 265 | const id = Buffer.alloc(4); 266 | id.writeUInt32BE(this.id); 267 | 268 | const dataLen = Buffer.alloc(2); 269 | dataLen.writeUInt16BE(this.data.byteLength); 270 | 271 | return Buffer.concat([id, dataLen, this.data]); 272 | } 273 | 274 | public static decode(buf: Buffer): [DataPacket, Buffer] { 275 | const id = buf.readUInt32BE(); 276 | buf = buf.slice(4); 277 | 278 | const dataLen = buf.readUInt16BE(); 279 | buf = buf.slice(2); 280 | 281 | const data = buf.slice(0, dataLen); 282 | buf = buf.slice(dataLen); 283 | 284 | return [new DataPacket(id, data), buf]; 285 | } 286 | } 287 | 288 | export class FindNodeRequest { 289 | target: Uint8Array; 290 | 291 | public constructor(target: Uint8Array) { 292 | this.target = target; 293 | } 294 | 295 | public encode(): Buffer { 296 | return Buffer.from(this.target); 297 | } 298 | 299 | public static decode(buf: Buffer): [FindNodeRequest, Buffer] { 300 | const target = buf.slice(0, nacl.sign.publicKeyLength); 301 | buf = buf.slice(nacl.sign.publicKeyLength); 302 | return [new FindNodeRequest(target), buf]; 303 | } 304 | } 305 | 306 | export class FindNodeResponse { 307 | closest: ID[]; 308 | 309 | public constructor(closest: ID[]) { 310 | this.closest = closest; 311 | } 312 | 313 | public encode(): Buffer { 314 | return Buffer.concat([ 315 | Buffer.of(this.closest.length), 316 | ...this.closest.map((id) => id.encode()), 317 | ]); 318 | } 319 | 320 | public static decode(buf: Buffer): [FindNodeResponse, Buffer] { 321 | const closestLen = buf.readUInt8(); 322 | buf = buf.slice(1); 323 | 324 | const closest = [...Array(closestLen)].map(() => { 325 | const [id, leftover] = ID.decode(buf); 326 | buf = leftover; 327 | return id; 328 | }); 329 | 330 | return [new FindNodeResponse(closest), buf]; 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /nodejs/src/provider.ts: -------------------------------------------------------------------------------- 1 | import { ID } from "./kademlia"; 2 | import net from "net"; 3 | import { Session } from "./session"; 4 | import { 5 | drain, 6 | lengthPrefixed, 7 | prefixLength, 8 | RPC, 9 | Stream, 10 | Streams, 11 | } from "./stream"; 12 | import { Opcode } from "./packet"; 13 | 14 | export class Provider { 15 | id?: ID; 16 | handshaked = false; 17 | services = new Set(); 18 | 19 | sock: net.Socket; 20 | session: Session; 21 | rpc: RPC; 22 | streams: Streams; 23 | 24 | get addr(): string { 25 | if (this.id) return this.id.addr; 26 | return ""; 27 | } 28 | 29 | constructor(sock: net.Socket, session: Session, client: boolean) { 30 | this.sock = sock; 31 | this.session = session; 32 | this.rpc = new RPC(client); 33 | this.streams = new Streams(client); 34 | } 35 | 36 | async write(buf: Buffer) { 37 | buf = prefixLength(this.session.encrypt(buf)); 38 | if (!this.sock.write(buf)) await drain(this.sock); 39 | } 40 | 41 | async request(data: Buffer): Promise { 42 | const [req, res] = this.rpc.request(data); 43 | await this.write(req); 44 | return await res; 45 | } 46 | 47 | async *read() { 48 | const stream = this.rpc.parse( 49 | this.session.decrypted(lengthPrefixed(this.sock)) 50 | ); 51 | for await (let { seq, frame } of stream) { 52 | if (frame.byteLength < 1) 53 | throw new Error(`Frame must be prefixed with an opcode byte.`); 54 | 55 | const opcode = frame.readUInt8(); 56 | frame = frame.slice(1); 57 | 58 | yield { seq, opcode, frame }; 59 | } 60 | } 61 | 62 | async push( 63 | services: string[], 64 | headers: { [key: string]: string }, 65 | body: AsyncIterable 66 | ): Promise { 67 | const err = new Error( 68 | `No nodes were able to process your request for service(s): [${services.join( 69 | ", " 70 | )}]` 71 | ); 72 | 73 | const stream = this.streams.register(); 74 | const [header, handled] = this.streams.push(stream, services, headers); 75 | 76 | await this.write( 77 | this.rpc.message( 78 | 0, 79 | Buffer.concat([Buffer.of(Opcode.ServiceRequest), header]) 80 | ) 81 | ); 82 | 83 | for await (const chunk of this.streams.encoded(stream.id, body)) { 84 | await this.write( 85 | this.rpc.message(0, Buffer.concat([Buffer.of(Opcode.Data), chunk])) 86 | ); 87 | } 88 | 89 | if (!(await handled)) { 90 | throw err; 91 | } 92 | 93 | return stream; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /nodejs/src/session.ts: -------------------------------------------------------------------------------- 1 | import net from "net"; 2 | import nacl from "tweetnacl"; 3 | import events from "events"; 4 | import crypto from "crypto"; 5 | 6 | const blake2b = require("blake2b"); 7 | 8 | export function x25519(privateKey: Uint8Array, publicKey: Uint8Array): Buffer { 9 | return blake2b(32).update(nacl.scalarMult(privateKey, publicKey)).digest(); 10 | } 11 | 12 | export async function serverHandshake(conn: net.Socket): Promise { 13 | const serverKeys = nacl.box.keyPair(); 14 | 15 | await events.once(conn, "readable"); 16 | const clientPublicKey = conn.read(nacl.box.publicKeyLength); 17 | 18 | conn.write(serverKeys.publicKey); 19 | 20 | return x25519(serverKeys.secretKey, clientPublicKey); 21 | } 22 | 23 | export async function clientHandshake(client: net.Socket): Promise { 24 | const clientKeys = nacl.box.keyPair(); 25 | 26 | client.write(clientKeys.publicKey); 27 | 28 | await events.once(client, "readable"); 29 | const serverPublicKey = client.read(nacl.box.publicKeyLength); 30 | 31 | return x25519(clientKeys.secretKey, serverPublicKey); 32 | } 33 | 34 | export class Session { 35 | secret: Uint8Array; 36 | readNonce: bigint = BigInt(0); 37 | writeNonce: bigint = BigInt(0); 38 | 39 | constructor(secret: Uint8Array) { 40 | this.secret = secret; 41 | } 42 | 43 | public async *decrypted(stream: AsyncIterable) { 44 | for await (const frame of stream) { 45 | yield this.decrypt(frame); 46 | } 47 | } 48 | 49 | public encrypt(src: string | ArrayBufferLike): Buffer { 50 | const buf = Buffer.isBuffer(src) ? src : Buffer.from(src); 51 | 52 | const nonce = Buffer.alloc(12); 53 | nonce.writeBigUInt64BE(this.writeNonce); 54 | this.writeNonce = this.writeNonce + BigInt(1); 55 | 56 | const cipher = crypto.createCipheriv("aes-256-gcm", this.secret, nonce, { 57 | authTagLength: 16, 58 | }); 59 | const ciphered = cipher.update(buf); 60 | cipher.final(); 61 | 62 | return Buffer.concat([ciphered, cipher.getAuthTag()]); 63 | } 64 | 65 | public decrypt(buf: Buffer): Buffer { 66 | if (buf.byteLength < 16) throw new Error("Missing authentication tag."); 67 | 68 | const nonce = Buffer.alloc(12); 69 | nonce.writeBigUInt64BE(this.readNonce); 70 | this.readNonce = this.readNonce + BigInt(1); 71 | 72 | const decipher = crypto.createDecipheriv( 73 | "aes-256-gcm", 74 | this.secret, 75 | nonce, 76 | { authTagLength: 16 } 77 | ); 78 | decipher.setAuthTag(buf.slice(buf.byteLength - 16, buf.byteLength)); 79 | 80 | const deciphered = decipher.update(buf.slice(0, buf.byteLength - 16)); 81 | decipher.final(); 82 | 83 | return deciphered; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /nodejs/src/stream.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Writable } from "stream"; 2 | import events, { EventEmitter } from "events"; 3 | import { DataPacket, ServiceRequestPacket } from "./packet"; 4 | 5 | export async function drain(writable: Writable) { 6 | if (writable.destroyed) throw new Error(`premature close`); 7 | 8 | await Promise.race([ 9 | events.once(writable, "drain"), 10 | events.once(writable, "close").then(() => { 11 | throw new Error(`premature close`); 12 | }), 13 | ]); 14 | } 15 | 16 | export async function* lengthPrefixed(stream: AsyncIterable) { 17 | let buf: Buffer = Buffer.of(); 18 | let size: number | undefined; 19 | 20 | for await (const data of stream) { 21 | buf = Buffer.concat([buf, data]); 22 | 23 | while (true) { 24 | if (!size) { 25 | if (buf.byteLength < 4) break; 26 | size = buf.readUInt32BE(0); 27 | buf = buf.slice(4); 28 | } 29 | 30 | if (buf.byteLength < size) break; 31 | 32 | const frame = buf.slice(0, size); 33 | buf = buf.slice(size); 34 | size = undefined; 35 | 36 | yield frame; 37 | } 38 | } 39 | } 40 | 41 | export function prefixLength(src: string | ArrayBufferLike): Buffer { 42 | const data = Buffer.isBuffer(src) ? src : Buffer.from(src); 43 | 44 | const header = Buffer.alloc(4); 45 | header.writeUInt32BE(data.byteLength); 46 | return Buffer.concat([header, data]); 47 | } 48 | 49 | export class RPC { 50 | pending: EventEmitter = new EventEmitter(); 51 | initial: number; 52 | counter: number; 53 | 54 | constructor(client: boolean) { 55 | this.counter = this.initial = client ? 1 : 2; 56 | } 57 | 58 | next(): number { 59 | const seq = this.counter; 60 | if ((this.counter += 2) === 0) { 61 | this.counter = this.initial; 62 | } 63 | return seq; 64 | } 65 | 66 | message(seq: number, src: string | ArrayBufferLike): Buffer { 67 | const buf = Buffer.isBuffer(src) ? src : Buffer.from(src); 68 | const header = Buffer.alloc(4); 69 | header.writeUInt32BE(seq); 70 | return Buffer.concat([header, buf]); 71 | } 72 | 73 | request(src: string | ArrayBufferLike): [Buffer, Promise] { 74 | const seq = this.next(); 75 | const buf = this.message(seq, src); 76 | return [buf, this.wait(seq)]; 77 | } 78 | 79 | async wait(seq: number): Promise { 80 | return (<[Buffer]>await events.once(this.pending, `${seq}`))[0]; 81 | } 82 | 83 | async *parse(stream: AsyncIterable) { 84 | for await (let frame of stream) { 85 | if (frame.byteLength < 4) 86 | throw new Error( 87 | `Frame must be prefixed with an unsigned 32-bit sequence number.` 88 | ); 89 | 90 | const seq = frame.readUInt32BE(); 91 | frame = frame.slice(4); 92 | 93 | if (seq !== 0 && seq % 2 === this.initial % 2) { 94 | this.pending.emit(`${seq}`, frame); 95 | continue; 96 | } 97 | 98 | yield { seq, frame }; 99 | } 100 | } 101 | } 102 | 103 | export const STREAM_CHUNK_SIZE = 2048; 104 | 105 | export class Stream extends EventEmitter { 106 | id: number; 107 | body: Readable; 108 | headers?: { [key: string]: string }; 109 | 110 | constructor(id: number) { 111 | super(); 112 | 113 | this.id = id; 114 | 115 | this.body = new Readable(); 116 | this.body._read = () => { 117 | return; 118 | }; 119 | } 120 | } 121 | 122 | export class Streams { 123 | active = new Map(); 124 | initial: number; 125 | counter: number; 126 | 127 | constructor(client: boolean) { 128 | this.counter = this.initial = client ? 0 : 1; 129 | } 130 | 131 | register(id?: number): Stream { 132 | if (id === undefined) { 133 | id = this.counter; 134 | if ((this.counter += 2) === 0) { 135 | this.counter = this.initial; 136 | } 137 | } 138 | 139 | if (this.active.has(id)) { 140 | throw new Error( 141 | `Attempted to register stream with ID ${id} which already exists.` 142 | ); 143 | } 144 | 145 | const stream = new Stream(id); 146 | this.active.set(id, stream); 147 | 148 | return stream; 149 | } 150 | 151 | get(id: number): Stream | undefined { 152 | return this.active.get(id); 153 | } 154 | 155 | push( 156 | stream: Stream, 157 | services: string[], 158 | headers: { [key: string]: string } 159 | ): [Buffer, Promise] { 160 | return [ 161 | new ServiceRequestPacket(stream.id, services, headers).encode(), 162 | this.wait(stream), 163 | ]; 164 | } 165 | 166 | pull(stream: Stream, handled: boolean, headers: { [key: string]: string }) { 167 | stream.headers = headers; 168 | stream.emit("ready", handled); 169 | } 170 | 171 | recv(stream: Stream, data: Buffer) { 172 | if (data.byteLength === 0) { 173 | this.active.delete(stream.id); 174 | stream.body.push(null); 175 | } else { 176 | stream.body.push(data); 177 | } 178 | } 179 | 180 | async *encoded(id: number, body: AsyncIterable) { 181 | let buf: Buffer = Buffer.of(); 182 | for await (const chunk of body) { 183 | buf = Buffer.concat([buf, Buffer.from(chunk)]); 184 | while (buf.byteLength >= STREAM_CHUNK_SIZE) { 185 | yield new DataPacket(id, buf.slice(0, STREAM_CHUNK_SIZE)).encode(); 186 | buf = buf.slice(STREAM_CHUNK_SIZE); 187 | } 188 | } 189 | if (buf.byteLength > 0) { 190 | yield new DataPacket(id, buf).encode(); 191 | } 192 | yield new DataPacket(id, Buffer.of()).encode(); 193 | } 194 | 195 | async wait(stream: EventEmitter): Promise { 196 | return (<[Boolean]>await events.once(stream, "ready"))[0]; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /nodejs/tests/globals.d.spec.ts: -------------------------------------------------------------------------------- 1 | declare module Chai { 2 | interface Assertion { 3 | equalBytes(expected: ArrayBufferLike): Chai.Equal; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /nodejs/tests/sock.spec.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import * as net from "net"; 3 | import * as events from "events"; 4 | import { Node } from "../src"; 5 | import chai, { expect } from "chai"; 6 | import { Readable } from "stream"; 7 | import { clientHandshake, serverHandshake, Session } from "../src/session"; 8 | import nacl from "tweetnacl"; 9 | import { ID } from "../src/kademlia"; 10 | import { lengthPrefixed, prefixLength, RPC } from "../src/stream"; 11 | import { Context } from "../src/context"; 12 | import { getAvailableAddress } from "../src/net"; 13 | 14 | chai.use(require("chai-bytes")); 15 | 16 | const createEndToEnd = async (): Promise< 17 | [net.Server, net.Socket, net.Socket] 18 | > => { 19 | const server = net.createServer(); 20 | server.listen(); 21 | 22 | await events.once(server, "listening"); 23 | 24 | const info = (server.address())!; 25 | const client = net.createConnection(info.port, info.address); 26 | 27 | const [[conn]] = <[[net.Socket], any[]]>( 28 | await Promise.all([ 29 | events.once(server, "connection"), 30 | events.once(client, "connect"), 31 | ]) 32 | ); 33 | 34 | return [server, client, conn]; 35 | }; 36 | 37 | describe("length prefix", function () { 38 | it("should work end-to-end", async () => { 39 | const expected = [...Array(100)].map(() => Math.random().toString(16)); 40 | 41 | const [server, client, conn] = await createEndToEnd(); 42 | 43 | setImmediate(async () => { 44 | for (const data of expected) { 45 | expect(client.write(prefixLength(data))).to.equal(true); 46 | } 47 | client.end(); 48 | await events.once(client, "close"); 49 | }); 50 | 51 | const stream = lengthPrefixed(conn); 52 | for (const data of expected) { 53 | expect((await stream.next()).value).to.equalBytes(Buffer.from(data)); 54 | } 55 | await events.once(conn, "close"); 56 | 57 | server.close(); 58 | await events.once(server, "close"); 59 | }); 60 | }); 61 | 62 | const endToEndHandshake = async ( 63 | client: net.Socket, 64 | conn: net.Socket 65 | ): Promise<[Uint8Array, Uint8Array]> => { 66 | return await Promise.all([clientHandshake(client), serverHandshake(conn)]); 67 | }; 68 | 69 | describe("session", function () { 70 | it("should work end-to-end", async () => { 71 | const expected = [...Array(100)].map(() => Math.random().toString(16)); 72 | 73 | const [server, client, conn] = await createEndToEnd(); 74 | const [clientSecret, serverSecret] = await endToEndHandshake(client, conn); 75 | 76 | expect(clientSecret).to.equalBytes(serverSecret); 77 | 78 | setImmediate(async () => { 79 | const session = new Session(clientSecret); 80 | const stream = session.decrypted(lengthPrefixed(client)); 81 | 82 | for (const data of expected) { 83 | client.write(prefixLength(session.encrypt(data))); 84 | } 85 | 86 | for (const data of expected) { 87 | expect((await stream.next()).value).to.equalBytes(Buffer.from(data)); 88 | } 89 | 90 | client.end(); 91 | await events.once(client, "close"); 92 | }); 93 | 94 | const session = new Session(serverSecret); 95 | const stream = session.decrypted(lengthPrefixed(conn)); 96 | 97 | for (const data of expected) { 98 | expect((await stream.next()).value).to.equalBytes(Buffer.from(data)); 99 | } 100 | 101 | for (const data of expected) { 102 | conn.write(prefixLength(session.encrypt(data))); 103 | } 104 | 105 | await events.once(conn, "close"); 106 | 107 | server.close(); 108 | await events.once(server, "close"); 109 | }); 110 | }); 111 | 112 | describe("encrypted rpc", function () { 113 | it("should work end-to-end", async () => { 114 | const expected = [...Array(100)].map(() => Math.random().toString(16)); 115 | 116 | const [server, client, conn] = await createEndToEnd(); 117 | const [clientSecret, serverSecret] = await endToEndHandshake(client, conn); 118 | 119 | expect(clientSecret).to.equalBytes(serverSecret); 120 | 121 | setImmediate(async () => { 122 | const rpc = new RPC(true); 123 | const session = new Session(clientSecret); 124 | 125 | const stream = rpc.parse(session.decrypted(lengthPrefixed(client))); 126 | 127 | setImmediate(async () => { 128 | while (true) { 129 | const item = await stream.next(); 130 | if (item.done) { 131 | break; 132 | } 133 | 134 | const { seq, frame } = item.value; 135 | 136 | client.write( 137 | prefixLength( 138 | session.encrypt( 139 | rpc.message( 140 | seq, 141 | Buffer.concat([Buffer.from("FROM CLIENT: "), frame]) 142 | ) 143 | ) 144 | ) 145 | ); 146 | } 147 | }); 148 | 149 | for (const data of expected) { 150 | const [req, res] = rpc.request(data); 151 | client.write(prefixLength(session.encrypt(req))); 152 | expect(await res).to.equalBytes(Buffer.from("FROM SERVER: " + data)); 153 | } 154 | 155 | client.end(); 156 | await events.once(client, "close"); 157 | }); 158 | 159 | const rpc = new RPC(false); 160 | const session = new Session(serverSecret); 161 | 162 | const stream = rpc.parse(session.decrypted(lengthPrefixed(conn))); 163 | 164 | setImmediate(async () => { 165 | while (true) { 166 | const item = await stream.next(); 167 | if (item.done) { 168 | break; 169 | } 170 | 171 | const { seq, frame } = item.value; 172 | 173 | conn.write( 174 | prefixLength( 175 | session.encrypt( 176 | rpc.message( 177 | seq, 178 | Buffer.concat([Buffer.from("FROM SERVER: "), frame]) 179 | ) 180 | ) 181 | ) 182 | ); 183 | } 184 | }); 185 | 186 | for (const data of expected) { 187 | const [req, res] = rpc.request(data); 188 | conn.write(prefixLength(session.encrypt(req))); 189 | expect(await res).to.equalBytes(Buffer.from("FROM CLIENT: " + data)); 190 | } 191 | 192 | await events.once(conn, "close"); 193 | 194 | server.close(); 195 | await events.once(server, "close"); 196 | }); 197 | }); 198 | 199 | describe("node", function () { 200 | it("should work end-to-end", async () => { 201 | const aliceAddr = await getAvailableAddress(); 202 | const bobAddr = await getAvailableAddress(); 203 | 204 | const alice = new Node(); 205 | const bob = new Node(); 206 | 207 | alice.keys = nacl.sign.keyPair(); 208 | bob.keys = nacl.sign.keyPair(); 209 | 210 | alice.id = new ID(alice.keys.publicKey, aliceAddr.host, aliceAddr.port); 211 | bob.id = new ID(bob.keys.publicKey, bobAddr.host, bobAddr.port); 212 | 213 | alice.handlers["hello_world"] = async (ctx: Context) => { 214 | expect(ctx.id.publicKey).to.equalBytes(bob.id!.publicKey!); 215 | expect(await ctx.body()).to.equalBytes(Buffer.from("Bob says hi!")); 216 | ctx.send("Alice says hi!"); 217 | }; 218 | 219 | bob.handlers["hello_world"] = async (ctx: Context) => { 220 | expect(ctx.id.publicKey).to.equalBytes(alice.id!.publicKey!); 221 | expect(await ctx.body()).to.equalBytes(Buffer.from("Alice says hi!")); 222 | ctx.send("Bob says hi!"); 223 | }; 224 | 225 | await bob.listen({ port: bobAddr.port }); 226 | await alice.connect({ host: bobAddr.host.toString(), port: bobAddr.port }); 227 | 228 | const aliceToBob = async () => { 229 | for (let i = 0; i < 10; i++) { 230 | const res = await alice.push( 231 | ["hello_world"], 232 | {}, 233 | Readable.from("Alice says hi!") 234 | ); 235 | for await (const chunk of res.body) { 236 | expect(chunk).to.equalBytes(Buffer.from("Bob says hi!")); 237 | } 238 | } 239 | }; 240 | 241 | const bobToAlice = async () => { 242 | for (let i = 0; i < 10; i++) { 243 | const res = await bob.push( 244 | ["hello_world"], 245 | {}, 246 | Readable.from("Bob says hi!") 247 | ); 248 | for await (const chunk of res.body) { 249 | expect(chunk).to.equalBytes(Buffer.from("Alice says hi!")); 250 | } 251 | } 252 | }; 253 | 254 | await Promise.all([aliceToBob(), bobToAlice()]); 255 | await Promise.all([alice.shutdown(), bob.shutdown()]); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /nodejs/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2015", 8 | /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 9 | "module": "commonjs", 10 | /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 11 | // "lib": [], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | "declaration": true, 16 | /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, 18 | /* Generates a sourcemap for each corresponding '.d.ts' file. */ 19 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 20 | // "outFile": "./", /* Concatenate and emit output to single file. */ 21 | // "outDir": "dist", 22 | /* Redirect output structure to the directory. */ 23 | "rootDirs": [ 24 | "src", 25 | "tests" 26 | ] /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 27 | // "composite": true, /* Enable project compilation */ 28 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 29 | // "removeComments": true, /* Do not emit comments to output. */ 30 | // "noEmit": true, /* Do not emit outputs. */ 31 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 32 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 33 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 34 | 35 | /* Strict Type-Checking Options */ 36 | "strict": true, 37 | /* Enable all strict type-checking options. */ 38 | "noImplicitAny": true, 39 | /* Raise error on expressions and declarations with an implied 'any' type. */ 40 | "strictNullChecks": true, 41 | /* Enable strict null checks. */ 42 | "strictFunctionTypes": true, 43 | /* Enable strict checking of function types. */ 44 | "strictBindCallApply": true, 45 | /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 46 | "strictPropertyInitialization": true, 47 | /* Enable strict checking of property initialization in classes. */ 48 | "noImplicitThis": true, 49 | /* Raise error on 'this' expressions with an implied 'any' type. */ 50 | "alwaysStrict": true, 51 | /* Parse in strict mode and emit "use strict" for each source file. */ 52 | 53 | /* Additional Checks */ 54 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 55 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 56 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 57 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 58 | 59 | /* Module Resolution Options */ 60 | "moduleResolution": "node", 61 | /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 62 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 63 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 64 | /* List of root folders whose combined content represents the structure of the project at runtime. */ 65 | // "typeRoots": [], /* List of folders to include type definitions from. */ 66 | // "types": [], /* Type declaration files to be included in compilation. */ 67 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 68 | "esModuleInterop": true, 69 | 70 | /* Advanced Options */ 71 | "skipLibCheck": true, 72 | /* Skip type checking of declaration files. */ 73 | "forceConsistentCasingInFileNames": true 74 | /* Disallow inconsistently-cased references to the same file. */ 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /nodejs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2015", 8 | /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 9 | "module": "commonjs", 10 | /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 11 | // "lib": [], /* Specify library files to be included in the compilation. */ 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 15 | "declaration": true, 16 | /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, 18 | /* Generates a sourcemap for each corresponding '.d.ts' file. */ 19 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 20 | // "outFile": "./", /* Concatenate and emit output to single file. */ 21 | "outDir": "dist", 22 | /* Redirect output structure to the directory. */ 23 | // "rootDir": ".", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 24 | // "composite": true, /* Enable project compilation */ 25 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 26 | // "removeComments": true, /* Do not emit comments to output. */ 27 | // "noEmit": true, /* Do not emit outputs. */ 28 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 29 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 30 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 31 | 32 | /* Strict Type-Checking Options */ 33 | "strict": true, 34 | /* Enable all strict type-checking options. */ 35 | "noImplicitAny": true, 36 | /* Raise error on expressions and declarations with an implied 'any' type. */ 37 | "strictNullChecks": true, 38 | /* Enable strict null checks. */ 39 | "strictFunctionTypes": true, 40 | /* Enable strict checking of function types. */ 41 | "strictBindCallApply": true, 42 | /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 43 | "strictPropertyInitialization": true, 44 | /* Enable strict checking of property initialization in classes. */ 45 | "noImplicitThis": true, 46 | /* Raise error on 'this' expressions with an implied 'any' type. */ 47 | "alwaysStrict": true, 48 | /* Parse in strict mode and emit "use strict" for each source file. */ 49 | 50 | /* Additional Checks */ 51 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 52 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 53 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 54 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 55 | 56 | /* Module Resolution Options */ 57 | "moduleResolution": "node", 58 | /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 59 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 60 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 61 | "rootDirs": ["src", "tests"], 62 | /* List of root folders whose combined content represents the structure of the project at runtime. */ 63 | // "typeRoots": [], /* List of folders to include type definitions from. */ 64 | // "types": [], /* Type declaration files to be included in compilation. */ 65 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 66 | "esModuleInterop": true, 67 | /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 68 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 69 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 70 | 71 | /* Source Map Options */ 72 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 73 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 74 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 75 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 76 | 77 | /* Experimental Options */ 78 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 79 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 80 | 81 | /* Advanced Options */ 82 | "skipLibCheck": true, 83 | /* Skip type checking of declaration files. */ 84 | "forceConsistentCasingInFileNames": true 85 | /* Disallow inconsistently-cased references to the same file. */ 86 | }, 87 | "exclude": ["tests", "dist", "examples", "node_modules"] 88 | } 89 | -------------------------------------------------------------------------------- /packet.go: -------------------------------------------------------------------------------- 1 | package flatend 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/lithdew/bytesutil" 7 | "github.com/lithdew/kademlia" 8 | "io" 9 | "math" 10 | "net" 11 | "strconv" 12 | "unicode/utf8" 13 | "unsafe" 14 | ) 15 | 16 | type Opcode = uint8 17 | 18 | const ( 19 | OpcodeHandshake Opcode = iota 20 | OpcodeServiceRequest 21 | OpcodeServiceResponse 22 | OpcodeData 23 | OpcodeFindNodeRequest 24 | OpcodeFindNodeResponse 25 | ) 26 | 27 | type ServiceRequestPacket struct { 28 | ID uint32 // stream id 29 | Services []string // services this packet may be processed through 30 | Headers map[string]string // headers for this packet 31 | } 32 | 33 | func (p ServiceRequestPacket) AppendTo(dst []byte) []byte { 34 | dst = bytesutil.AppendUint32BE(dst, p.ID) 35 | 36 | dst = append(dst, uint8(len(p.Services))) 37 | for _, service := range p.Services { 38 | dst = append(dst, uint8(len(service))) 39 | dst = append(dst, service...) 40 | } 41 | 42 | if p.Headers != nil { 43 | dst = bytesutil.AppendUint16BE(dst, uint16(len(p.Headers))) 44 | for name, value := range p.Headers { 45 | dst = append(dst, byte(len(name))) 46 | dst = append(dst, name...) 47 | dst = bytesutil.AppendUint16BE(dst, uint16(len(value))) 48 | dst = append(dst, value...) 49 | } 50 | } else { 51 | dst = bytesutil.AppendUint16BE(dst, 0) 52 | } 53 | 54 | return dst 55 | } 56 | 57 | func UnmarshalServiceRequestPacket(buf []byte) (ServiceRequestPacket, error) { 58 | var packet ServiceRequestPacket 59 | 60 | { 61 | if len(buf) < 4 { 62 | return packet, io.ErrUnexpectedEOF 63 | } 64 | 65 | packet.ID, buf = bytesutil.Uint32BE(buf[:4]), buf[4:] 66 | } 67 | 68 | { 69 | var size uint8 70 | size, buf = buf[0], buf[1:] 71 | 72 | packet.Services = make([]string, size) 73 | 74 | for i := 0; i < len(packet.Services); i++ { 75 | if len(buf) < 1 { 76 | return packet, io.ErrUnexpectedEOF 77 | } 78 | size, buf = buf[0], buf[1:] 79 | if len(buf) < int(size) { 80 | return packet, io.ErrUnexpectedEOF 81 | } 82 | packet.Services[i] = string(buf[:size]) 83 | buf = buf[size:] 84 | } 85 | } 86 | 87 | { 88 | if len(buf) < 2 { 89 | return packet, io.ErrUnexpectedEOF 90 | } 91 | 92 | var size uint16 93 | size, buf = bytesutil.Uint16BE(buf[:2]), buf[2:] 94 | 95 | packet.Headers = make(map[string]string, size) 96 | for i := uint16(0); i < size; i++ { 97 | { 98 | if len(buf) < 1 { 99 | return packet, io.ErrUnexpectedEOF 100 | } 101 | var nameSize uint8 102 | nameSize, buf = buf[0], buf[1:] 103 | if len(buf) < int(nameSize) { 104 | return packet, io.ErrUnexpectedEOF 105 | } 106 | var name string 107 | name, buf = string(buf[:nameSize]), buf[nameSize:] 108 | 109 | if len(buf) < 2 { 110 | return packet, io.ErrUnexpectedEOF 111 | } 112 | var valueSize uint16 113 | valueSize, buf = bytesutil.Uint16BE(buf[:2]), buf[2:] 114 | if len(buf) < int(valueSize) { 115 | return packet, io.ErrUnexpectedEOF 116 | } 117 | var value string 118 | value, buf = string(buf[:valueSize]), buf[valueSize:] 119 | packet.Headers[name] = value 120 | } 121 | } 122 | } 123 | 124 | return packet, nil 125 | } 126 | 127 | type ServiceResponsePacket struct { 128 | ID uint32 // stream id 129 | Handled bool // whether or not the service was handled 130 | Headers map[string]string // headers for this packet 131 | } 132 | 133 | func (p ServiceResponsePacket) AppendTo(dst []byte) []byte { 134 | dst = bytesutil.AppendUint32BE(dst, p.ID) 135 | if p.Handled { 136 | dst = append(dst, 1) 137 | } else { 138 | dst = append(dst, 0) 139 | } 140 | if p.Headers != nil { 141 | dst = bytesutil.AppendUint16BE(dst, uint16(len(p.Headers))) 142 | for name, value := range p.Headers { 143 | dst = append(dst, byte(len(name))) 144 | dst = append(dst, name...) 145 | dst = bytesutil.AppendUint16BE(dst, uint16(len(value))) 146 | dst = append(dst, value...) 147 | } 148 | } else { 149 | dst = bytesutil.AppendUint16BE(dst, 0) 150 | } 151 | return dst 152 | } 153 | 154 | func UnmarshalServiceResponsePacket(buf []byte) (ServiceResponsePacket, error) { 155 | var packet ServiceResponsePacket 156 | 157 | { 158 | if len(buf) < 5 { 159 | return packet, io.ErrUnexpectedEOF 160 | } 161 | 162 | packet.ID, buf = bytesutil.Uint32BE(buf[:4]), buf[4:] 163 | packet.Handled, buf = buf[0] == 1, buf[1:] 164 | } 165 | 166 | { 167 | if len(buf) < 2 { 168 | return packet, io.ErrUnexpectedEOF 169 | } 170 | 171 | var size uint16 172 | size, buf = bytesutil.Uint16BE(buf[:2]), buf[2:] 173 | 174 | packet.Headers = make(map[string]string, size) 175 | for i := uint16(0); i < size; i++ { 176 | { 177 | if len(buf) < 1 { 178 | return packet, io.ErrUnexpectedEOF 179 | } 180 | var nameSize uint8 181 | nameSize, buf = buf[0], buf[1:] 182 | if len(buf) < int(nameSize) { 183 | return packet, io.ErrUnexpectedEOF 184 | } 185 | var name string 186 | name, buf = string(buf[:nameSize]), buf[nameSize:] 187 | 188 | if len(buf) < 2 { 189 | return packet, io.ErrUnexpectedEOF 190 | } 191 | var valueSize uint16 192 | valueSize, buf = bytesutil.Uint16BE(buf[:2]), buf[2:] 193 | if len(buf) < int(valueSize) { 194 | return packet, io.ErrUnexpectedEOF 195 | } 196 | var value string 197 | value, buf = string(buf[:valueSize]), buf[valueSize:] 198 | packet.Headers[name] = value 199 | } 200 | } 201 | } 202 | 203 | return packet, nil 204 | } 205 | 206 | type DataPacket struct { 207 | ID uint32 208 | Data []byte 209 | } 210 | 211 | func (p DataPacket) AppendTo(dst []byte) []byte { 212 | dst = bytesutil.AppendUint32BE(dst, p.ID) 213 | dst = bytesutil.AppendUint16BE(dst, uint16(len(p.Data))) 214 | dst = append(dst, p.Data...) 215 | return dst 216 | } 217 | 218 | func UnmarshalDataPacket(buf []byte) (DataPacket, error) { 219 | var packet DataPacket 220 | if len(buf) < 4+2 { 221 | return packet, io.ErrUnexpectedEOF 222 | } 223 | packet.ID, buf = bytesutil.Uint32BE(buf[:4]), buf[4:] 224 | var size uint16 225 | size, buf = bytesutil.Uint16BE(buf[:2]), buf[2:] 226 | if uint16(len(buf)) < size { 227 | return packet, io.ErrUnexpectedEOF 228 | } 229 | packet.Data, buf = buf[:size], buf[size:] 230 | return packet, nil 231 | } 232 | 233 | type HandshakePacket struct { 234 | ID *kademlia.ID 235 | Services []string 236 | Signature kademlia.Signature 237 | } 238 | 239 | func (h HandshakePacket) AppendPayloadTo(dst []byte) []byte { 240 | dst = h.ID.AppendTo(dst) 241 | for _, service := range h.Services { 242 | dst = append(dst, service...) 243 | } 244 | return dst 245 | } 246 | 247 | func (h HandshakePacket) AppendTo(dst []byte) []byte { 248 | if h.ID != nil { 249 | dst = append(dst, 1) 250 | dst = h.ID.AppendTo(dst) 251 | } else { 252 | dst = append(dst, 0) 253 | } 254 | dst = append(dst, uint8(len(h.Services))) 255 | for _, service := range h.Services { 256 | dst = append(dst, uint8(len(service))) 257 | dst = append(dst, service...) 258 | } 259 | if h.ID != nil { 260 | dst = append(dst, h.Signature[:]...) 261 | } 262 | return dst 263 | } 264 | 265 | func UnmarshalHandshakePacket(buf []byte) (HandshakePacket, error) { 266 | var pkt HandshakePacket 267 | 268 | if len(buf) < 1 { 269 | return pkt, io.ErrUnexpectedEOF 270 | } 271 | 272 | hasID := buf[0] == 1 273 | buf = buf[1:] 274 | 275 | if hasID { 276 | id, leftover, err := kademlia.UnmarshalID(buf) 277 | if err != nil { 278 | return pkt, err 279 | } 280 | pkt.ID = &id 281 | 282 | buf = leftover 283 | } 284 | 285 | if len(buf) < 1 { 286 | return pkt, io.ErrUnexpectedEOF 287 | } 288 | 289 | var size uint8 290 | size, buf = buf[0], buf[1:] 291 | 292 | if len(buf) < int(size) { 293 | return pkt, io.ErrUnexpectedEOF 294 | } 295 | 296 | pkt.Services = make([]string, size) 297 | for i := 0; i < len(pkt.Services); i++ { 298 | if len(buf) < 1 { 299 | return pkt, io.ErrUnexpectedEOF 300 | } 301 | size, buf = buf[0], buf[1:] 302 | if len(buf) < int(size) { 303 | return pkt, io.ErrUnexpectedEOF 304 | } 305 | pkt.Services[i] = string(buf[:size]) 306 | buf = buf[size:] 307 | } 308 | 309 | if hasID { 310 | if len(buf) < kademlia.SizeSignature { 311 | return pkt, io.ErrUnexpectedEOF 312 | } 313 | 314 | pkt.Signature, buf = *(*kademlia.Signature)(unsafe.Pointer(&((buf[:kademlia.SizeSignature])[0]))), 315 | buf[kademlia.SizeSignature:] 316 | } 317 | 318 | return pkt, nil 319 | } 320 | 321 | func (h HandshakePacket) Validate(dst []byte) error { 322 | if h.ID != nil { 323 | err := h.ID.Validate() 324 | if err != nil { 325 | return err 326 | } 327 | } 328 | 329 | for _, service := range h.Services { 330 | if !utf8.ValidString(service) { 331 | return fmt.Errorf("service '%s' in hello packet is not valid utf8", service) 332 | } 333 | if len(service) > math.MaxUint8 { 334 | return fmt.Errorf("service '%s' in hello packet is too large - must <= %d bytes", 335 | service, math.MaxUint8) 336 | } 337 | } 338 | 339 | if h.ID != nil && !h.Signature.Verify(h.ID.Pub, h.AppendPayloadTo(dst)) { 340 | return errors.New("signature is malformed") 341 | } 342 | 343 | return nil 344 | } 345 | 346 | func Addr(host net.IP, port uint16) string { 347 | h := "" 348 | if len(host) > 0 { 349 | h = host.String() 350 | } 351 | p := strconv.FormatUint(uint64(port), 10) 352 | return net.JoinHostPort(h, p) 353 | } 354 | -------------------------------------------------------------------------------- /packet_test.go: -------------------------------------------------------------------------------- 1 | package flatend 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | "testing/quick" 8 | ) 9 | 10 | func TestServiceRequestPacket(t *testing.T) { 11 | var dst []byte 12 | f := func(expected ServiceRequestPacket) bool { 13 | actual, err := UnmarshalServiceRequestPacket(expected.AppendTo(dst[:0])) 14 | return assert.NoError(t, err) && assert.EqualValues(t, expected, actual) 15 | } 16 | require.NoError(t, quick.Check(f, nil)) 17 | } 18 | 19 | func TestServiceResponsePacket(t *testing.T) { 20 | var dst []byte 21 | f := func(expected ServiceResponsePacket) bool { 22 | actual, err := UnmarshalServiceResponsePacket(expected.AppendTo(dst[:0])) 23 | return assert.NoError(t, err) && assert.EqualValues(t, expected, actual) 24 | } 25 | require.NoError(t, quick.Check(f, nil)) 26 | } 27 | 28 | func TestDataPacket(t *testing.T) { 29 | var dst []byte 30 | f := func(expected DataPacket) bool { 31 | actual, err := UnmarshalDataPacket(expected.AppendTo(dst[:0])) 32 | return assert.NoError(t, err) && assert.EqualValues(t, expected, actual) 33 | } 34 | require.NoError(t, quick.Check(f, nil)) 35 | } 36 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | package flatend 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/lithdew/kademlia" 7 | "github.com/lithdew/monte" 8 | "io" 9 | "sync" 10 | ) 11 | 12 | type Stream struct { 13 | ID uint32 14 | Header *ServiceResponsePacket 15 | Reader *pipeReader 16 | Writer *pipeWriter 17 | 18 | received uint64 19 | wg sync.WaitGroup 20 | once sync.Once 21 | } 22 | 23 | type Provider struct { 24 | id *kademlia.ID 25 | conn *monte.Conn 26 | services map[string]struct{} 27 | 28 | lock sync.Mutex // protects all stream-related structures 29 | counter uint32 // total number of outgoing streams 30 | streams map[uint32]*Stream // maps stream ids to stream instances 31 | } 32 | 33 | func (p *Provider) NextStream() *Stream { 34 | reader, writer := createWrappedPipe() 35 | 36 | p.lock.Lock() 37 | defer p.lock.Unlock() 38 | 39 | id := p.counter 40 | p.counter += 2 41 | 42 | stream := &Stream{ 43 | ID: id, 44 | Reader: reader, 45 | Writer: writer, 46 | } 47 | stream.wg.Add(1) 48 | p.streams[id] = stream 49 | 50 | return stream 51 | } 52 | 53 | func (p *Provider) GetStream(id uint32) (*Stream, bool) { 54 | p.lock.Lock() 55 | defer p.lock.Unlock() 56 | stream, exists := p.streams[id] 57 | return stream, exists 58 | } 59 | 60 | func (p *Provider) RegisterStream(header ServiceRequestPacket) (*Stream, bool) { 61 | reader, writer := createWrappedPipe() 62 | 63 | p.lock.Lock() 64 | defer p.lock.Unlock() 65 | 66 | stream, exists := p.streams[header.ID] 67 | if exists { 68 | return stream, false 69 | } 70 | 71 | stream = &Stream{ 72 | ID: header.ID, 73 | Reader: reader, 74 | Writer: writer, 75 | } 76 | stream.wg.Add(1) 77 | p.streams[header.ID] = stream 78 | 79 | return stream, true 80 | } 81 | 82 | func (p *Provider) CloseStreamWithError(stream *Stream, err error) { 83 | p.lock.Lock() 84 | defer p.lock.Unlock() 85 | 86 | _ = stream.Reader.CloseWithError(err) 87 | _ = stream.Writer.CloseWithError(err) 88 | 89 | stream.once.Do(stream.wg.Done) 90 | 91 | delete(p.streams, stream.ID) 92 | } 93 | 94 | func (p *Provider) Close() { 95 | p.lock.Lock() 96 | defer p.lock.Unlock() 97 | 98 | for id, stream := range p.streams { 99 | err := fmt.Errorf("provider connection closed: %w", io.EOF) 100 | 101 | _ = stream.Reader.CloseWithError(err) 102 | _ = stream.Writer.CloseWithError(err) 103 | 104 | stream.once.Do(stream.wg.Done) 105 | 106 | delete(p.streams, id) 107 | } 108 | } 109 | 110 | var ErrProviderNotAvailable = errors.New("provider unable to provide service") 111 | 112 | func (p *Provider) Push(services []string, headers map[string]string, body io.ReadCloser) (*Stream, error) { 113 | buf := make([]byte, ChunkSize) 114 | 115 | stream := p.NextStream() 116 | 117 | header := ServiceRequestPacket{ 118 | ID: stream.ID, 119 | Headers: headers, 120 | Services: services, 121 | } 122 | 123 | err := p.conn.Send(header.AppendTo([]byte{OpcodeServiceRequest})) 124 | if err != nil { 125 | err = fmt.Errorf("failed to send stream header: %s: %w", err, ErrProviderNotAvailable) 126 | p.CloseStreamWithError(stream, err) 127 | return nil, err 128 | } 129 | 130 | for { 131 | nn, err := body.Read(buf[:ChunkSize]) 132 | if err != nil && err != io.EOF { 133 | err = fmt.Errorf("failed reading body: %w", err) 134 | p.CloseStreamWithError(stream, err) 135 | return nil, err 136 | } 137 | 138 | payload := DataPacket{ 139 | ID: stream.ID, 140 | Data: buf[:nn], 141 | } 142 | 143 | if err := p.conn.Send(payload.AppendTo([]byte{OpcodeData})); err != nil { 144 | err = fmt.Errorf("failed writing body chunk as a data packet to peer: %w", err) 145 | p.CloseStreamWithError(stream, err) 146 | return nil, err 147 | } 148 | 149 | if err == io.EOF && nn == 0 { 150 | break 151 | } 152 | } 153 | 154 | stream.wg.Wait() 155 | 156 | if stream.Header == nil { 157 | return nil, fmt.Errorf("no response headers were returned: %w", io.EOF) 158 | } 159 | 160 | if !stream.Header.Handled { 161 | return nil, fmt.Errorf("provider unable to service: %s", services) 162 | } 163 | 164 | return stream, nil 165 | } 166 | 167 | func (p *Provider) Addr() string { 168 | if p.id != nil { 169 | return Addr(p.id.Host, p.id.Port) 170 | } else { 171 | return "" 172 | } 173 | } 174 | 175 | func (p *Provider) Services() []string { 176 | services := make([]string, 0, len(p.services)) 177 | for service := range p.services { 178 | services = append(services, service) 179 | } 180 | return services 181 | } 182 | 183 | type Providers struct { 184 | sync.Mutex 185 | 186 | services map[string]map[*monte.Conn]struct{} 187 | providers map[*monte.Conn]*Provider 188 | } 189 | 190 | func NewProviders() *Providers { 191 | return &Providers{ 192 | services: make(map[string]map[*monte.Conn]struct{}), 193 | providers: make(map[*monte.Conn]*Provider), 194 | } 195 | } 196 | 197 | func (p *Providers) findProvider(conn *monte.Conn) *Provider { 198 | p.Lock() 199 | defer p.Unlock() 200 | return p.providers[conn] 201 | } 202 | 203 | func (p *Providers) getProviders(services ...string) []*Provider { 204 | p.Lock() 205 | defer p.Unlock() 206 | 207 | var conns []*monte.Conn 208 | 209 | for _, service := range services { 210 | for conn := range p.services[service] { 211 | conns = append(conns, conn) 212 | } 213 | } 214 | 215 | if conns == nil { 216 | return nil 217 | } 218 | 219 | providers := make([]*Provider, 0, len(conns)) 220 | for _, conn := range conns { 221 | providers = append(providers, p.providers[conn]) 222 | } 223 | 224 | return providers 225 | } 226 | 227 | func (p *Providers) register(conn *monte.Conn, id *kademlia.ID, services []string, outgoing bool) (*Provider, bool) { 228 | p.Lock() 229 | defer p.Unlock() 230 | 231 | provider, exists := p.providers[conn] 232 | if !exists { 233 | provider = &Provider{ 234 | services: make(map[string]struct{}), 235 | streams: make(map[uint32]*Stream), 236 | } 237 | if outgoing { 238 | provider.counter = 1 239 | } else { 240 | provider.counter = 0 241 | } 242 | p.providers[conn] = provider 243 | } 244 | 245 | provider.id = id 246 | provider.conn = conn 247 | 248 | for _, service := range services { 249 | provider.services[service] = struct{}{} 250 | if _, exists := p.services[service]; !exists { 251 | p.services[service] = make(map[*monte.Conn]struct{}) 252 | } 253 | p.services[service][conn] = struct{}{} 254 | } 255 | 256 | return provider, exists 257 | } 258 | 259 | func (p *Providers) deregister(conn *monte.Conn) *Provider { 260 | p.Lock() 261 | defer p.Unlock() 262 | 263 | provider, exists := p.providers[conn] 264 | if !exists { 265 | return nil 266 | } 267 | 268 | delete(p.providers, conn) 269 | 270 | for service := range provider.services { 271 | delete(p.services[service], conn) 272 | if len(p.services[service]) == 0 { 273 | delete(p.services, service) 274 | } 275 | } 276 | 277 | provider.Close() 278 | 279 | return provider 280 | } 281 | --------------------------------------------------------------------------------