├── selene.toml ├── .gitignore ├── default.project.json ├── lib ├── init.lua ├── DependencySource │ ├── wally.lua │ ├── local.lua │ └── ts.lua ├── HttpRequestPriority.lua ├── HttpResponse.lua ├── DataUtils.lua ├── TypeGuards.lua ├── HttpRequest.lua └── HttpQueue.lua ├── wally.toml ├── tsconfig.json ├── runtime-tests ├── request.server.lua └── bigqueue.server.lua ├── local.project.json ├── LICENSE.md ├── package.json ├── .eslintrc ├── README-TS.md ├── README.md └── types.d.ts /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | roblox.toml 3 | Packages 4 | wally.lock 5 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-queue", 3 | "tree": { 4 | "$path": "lib" 5 | } 6 | } -------------------------------------------------------------------------------- /lib/init.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/init.lua 3 | Description: Front-end for the http-queue library 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | local exports = { 9 | HttpRequestPriority = require(script.HttpRequestPriority), 10 | HttpRequest = require(script.HttpRequest), 11 | HttpQueue = require(script.HttpQueue) 12 | } 13 | 14 | for name, guard in pairs(require(script.TypeGuards)) do 15 | exports[name] = guard 16 | end 17 | 18 | return exports 19 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ddavness/http-queue" 3 | description = "A library that lets you send HTTP requests to external services while respecting their rate limits." 4 | version = "1.1.6" 5 | registry = "https://github.com/UpliftGames/wally-index" 6 | realm = "server" 7 | authors = ["David Duque "] 8 | license = "MIT" 9 | exclude = ["*"] 10 | include = ["lib*", "LICENSE.md", "README.md", "wally.toml", "default.project.json"] 11 | 12 | [dependencies] 13 | t = "osyrisrblx/t@3.0.0" 14 | Promise = "evaera/promise@4.0.0" 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // required 4 | "allowSyntheticDefaultImports": true, 5 | "isolatedModules": false, 6 | "downlevelIteration": true, 7 | "module": "commonjs", 8 | "noLib": true, 9 | "strict": true, 10 | "target": "es6", 11 | "typeRoots": [ "node_modules/@rbxts" ], 12 | 13 | // required, configurable 14 | "rootDir": "src", 15 | "outDir": "out", 16 | 17 | // optional 18 | "declaration": true, 19 | 20 | // optional, non-configurable 21 | "jsx": "react", 22 | "jsxFactory": "Roact.createElement" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /runtime-tests/request.server.lua: -------------------------------------------------------------------------------- 1 | local Http = require(game:GetService("ServerScriptService").Packages.HttpQueue) 2 | 3 | wait(2) 4 | 5 | for i = 1, 100 do 6 | local request = Http.HttpRequest.new("https://davness.dev/", "GET") 7 | request:Send():andThen(function(response) 8 | print("REQUEST " .. i .. " successful!") 9 | print(response.StatusMessage) 10 | end) 11 | :catch(function(err) 12 | warn("REQUEST " .. i .. " FAILED!") 13 | print(err) 14 | end) 15 | print("Sent reqeust " .. i) 16 | end 17 | -------------------------------------------------------------------------------- /lib/DependencySource/wally.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/DependencySource/wally.lua 3 | Description: Loads runtime dependencies. This file is bundled on the Wally package only 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | --[[ 9 | EXTERNAL DEPENDENCIES: 10 | - evaera/promise 11 | - osyrisrblx/t 12 | ]] 13 | 14 | local dependencies = {} 15 | 16 | dependencies.Promise = require(script.Parent.Parent.Promise) 17 | dependencies.t = require(script.Parent.Parent.t) 18 | 19 | dependencies.HttpService = game:GetService("HttpService") 20 | 21 | return dependencies 22 | -------------------------------------------------------------------------------- /local.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-queue", 3 | "globIgnorePaths": ["**/*.spec.lua", "lib/DependencySource/*.lua", "!lib/DependencySource/init.lua"], 4 | "tree": { 5 | "$className": "DataModel", 6 | "ServerScriptService": { 7 | "$className": "ServerScriptService", 8 | "HttpQueue": { 9 | "$path": "lib" 10 | }, 11 | "Packages": { 12 | "$path": "Packages" 13 | }, 14 | "tests": { 15 | "$path": "runtime-tests" 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /lib/DependencySource/local.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/DependencySource/local.lua 3 | Description: Loads runtime dependencies. This file is bundled on GitHub releases and on the Roblox library. 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | --[[ 9 | EXTERNAL DEPENDENCIES: 10 | - evaera/promise 11 | - osyrisrblx/t 12 | ]] 13 | 14 | local dependencies = {} 15 | 16 | -- The dependencies are bundled 17 | dependencies.Promise = require(script.Promise) 18 | dependencies.t = require(script.t) 19 | 20 | dependencies.HttpService = game:GetService("HttpService") 21 | 22 | return dependencies 23 | -------------------------------------------------------------------------------- /lib/DependencySource/ts.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/DependencySource/ts.lua 3 | Description: Loads runtime dependencies. This file is bundled on the NPM distribution only 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | --[[ 9 | EXTERNAL DEPENDENCIES: 10 | - evaera/promise (Built in the roblox-ts runtime) 11 | - osyrisrblx/t (@rbxts/t) 12 | ]] 13 | 14 | local dependencies = {} 15 | local TS = _G[script.Parent] 16 | 17 | dependencies.Promise = TS.Promise 18 | dependencies.t = TS.import(script.Parent, TS.getModule(script.Parent, "t").lib.ts).t 19 | 20 | dependencies.HttpService = game:GetService("HttpService") 21 | 22 | return dependencies 23 | -------------------------------------------------------------------------------- /lib/HttpRequestPriority.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/HttpRequestPriority.lua 3 | Description: Enum to determine how a request should be queued. 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | local PriorityMeta = { 9 | __metatable = "HttpRequestPriority", 10 | __index = function(_, index) 11 | error("Attempt to index non-existant value HttpRequestPriority." .. tostring(index)) 12 | end, 13 | __eq = function(me, other) 14 | return getmetatable(me) == getmetatable(other) and me.Value == other.Value 15 | end 16 | } 17 | 18 | return setmetatable({ 19 | First = setmetatable({ Value = 1 }, PriorityMeta), 20 | Prioritary = setmetatable({ Value = 2 }, PriorityMeta), 21 | Normal = setmetatable({ Value = 3 }, PriorityMeta) 22 | }, { 23 | __index = function(_, index) 24 | error(tostring(index) .. " is not a valid HttpRequestPriority!") 25 | end 26 | }) 27 | -------------------------------------------------------------------------------- /lib/HttpResponse.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/HttpResponse.lua 3 | Description: Private wrapper for an HttpService response 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | local function newHttpResponse(success, result) 9 | local HttpResponse = {} 10 | 11 | HttpResponse.ConnectionSuccessful = success 12 | HttpResponse.RequestSuccessful = success and result.Success 13 | HttpResponse.StatusCode = success and result.StatusCode or 0 14 | HttpResponse.StatusMessage = success and (result.StatusCode .. " " .. result.StatusMessage) or result 15 | HttpResponse.Headers = success and result.Headers or {} 16 | HttpResponse.Body = success and (result.Body or "") or nil 17 | 18 | return setmetatable(HttpResponse, { 19 | __metatable = "HttpResponse", 20 | __index = function(_, index) 21 | error("Attempt to index non-existant value HttpResponse." .. tostring(index)) 22 | end 23 | }) 24 | end 25 | 26 | return newHttpResponse 27 | -------------------------------------------------------------------------------- /lib/DataUtils.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/DataUtils.lua 3 | Description: Data structures and basic synchronization utilities 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | local dataUtils = {} 9 | 10 | -- Small linked list implementation 11 | function dataUtils.newLLNode(item) 12 | return {Data = item, Prev = nil, Next = nil} 13 | end 14 | 15 | function dataUtils.addNodeToFirst(node, root) 16 | if not root.First then 17 | root.First = node 18 | root.Last = node 19 | else 20 | root.First.Prev = node 21 | node.Next = root.First 22 | node.Prev = nil 23 | root.First = node 24 | end 25 | end 26 | 27 | function dataUtils.addNodeToLast(node, root) 28 | if not root.Last then 29 | root.First = node 30 | root.Last = node 31 | else 32 | root.Last.Next = node 33 | node.Prev = root.Last 34 | node.Next = nil 35 | root.Last = node 36 | end 37 | end 38 | 39 | return dataUtils 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David Duque 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rbxts/http-queue", 3 | "version": "1.1.6", 4 | "description": "A small library to queue requests for your different external services", 5 | "main": "lib/init.lua", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/ddavness/roblox-http-queue.git" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "bugs": { 14 | "url": "https://github.com/ddavness/roblox-http-queue/issues" 15 | }, 16 | "homepage": "https://github.com/ddavness/roblox-http-queue#readme", 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "types": "types.d.ts", 21 | "dependencies": { 22 | "@rbxts/t": "^3.0.0" 23 | }, 24 | "devDependencies": { 25 | "@rbxts/types": "^1.0.573", 26 | "@rbxts/compiler-types": "1.3.3-types.1", 27 | "@typescript-eslint/eslint-plugin": "^5.17.0", 28 | "@typescript-eslint/parser": "^5.17.0", 29 | "eslint": "^8.12.0", 30 | "eslint-config-prettier": "^8.5.0", 31 | "eslint-plugin-prettier": "^4.0.0", 32 | "eslint-plugin-roblox-ts": "^0.0.32", 33 | "prettier": "^2.6.2" 34 | }, 35 | "files": [ 36 | "lib", 37 | "types.d.ts", 38 | "README.md", 39 | "LICENSE.md" 40 | ] 41 | } -------------------------------------------------------------------------------- /runtime-tests/bigqueue.server.lua: -------------------------------------------------------------------------------- 1 | local Http = require(game:GetService("ServerScriptService").Packages.HttpQueue) 2 | 3 | wait(5) 4 | 5 | local queue = Http.HttpQueue.new({ 6 | retryAfter = { 7 | cooldown = 10 8 | }, 9 | maxSimultaneousSendOperations = 10 10 | }) 11 | 12 | local query = { 13 | key = "YOUR KEY HERE", 14 | token = "YOUR TOKEN HERE" 15 | } 16 | 17 | for i = 1, 300 do 18 | print("Pushing request " .. i) 19 | query.name = "Name change " .. tostring(i) 20 | 21 | local request = Http.HttpRequest.new("https://api.trello.com/1/boards/5d6f8ec6764c2112a27e3d12", "PUT", nil, query) 22 | local promise 23 | if i == 200 then 24 | promise = queue:Push(request, Http.HttpRequestPriority.First) 25 | elseif i >= 100 then 26 | promise = queue:Push(request, Http.HttpRequestPriority.Prioritary) 27 | else 28 | promise = queue:Push(request) 29 | end 30 | 31 | promise 32 | :andThen(function(response) 33 | print("REQUEST " .. i .. " successful!") 34 | print(response.StatusMessage) 35 | end) 36 | :catch(function(err) 37 | warn("REQUEST " .. i .. " FAILED!") 38 | print(err) 39 | end) 40 | end 41 | 42 | wait(30) 43 | 44 | warn(queue:QueueSize()) 45 | -------------------------------------------------------------------------------- /lib/TypeGuards.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: https-queue/TypeGuards.lua 3 | Description: Type guards to validate http-queue interfaces 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | local t = require(script.Parent.DependencySource).t 9 | 10 | local guards = {} 11 | 12 | guards.isHttpRequest = t.intersection(t.strictInterface({ 13 | Url = t.string, 14 | Send = t.callback, 15 | AwaitSend = t.callback 16 | }), function(o) 17 | return getmetatable(o) == "HttpRequest" 18 | end) 19 | 20 | guards.isHttpRequestPriority = t.intersection(t.strictInterface({ 21 | Value = t.number, 22 | }), function(o) 23 | return getmetatable(o) == "HttpRequestPriority" 24 | end) 25 | 26 | guards.isHttpResponse = t.intersection(t.strictInterface({ 27 | ConnectionSuccessful = t.boolean, 28 | RequestSuccessful = t.boolean, 29 | StatusCode = t.number, 30 | StatusMessage = t.string, 31 | Headers = t.map(t.string, t.string), 32 | Body = t.string 33 | }), function(o) 34 | return getmetatable(o) == "HttpResponse" 35 | end) 36 | 37 | guards.isHttpQueue = t.intersection(t.strictInterface({ 38 | __metatable = t.literal("HttpQueue"), 39 | QueueSize = t.callback, 40 | Push = t.callback, 41 | AwaitPush = t.callback 42 | }), function(o) 43 | return getmetatable(o) == "HttpQueue" 44 | end) 45 | 46 | return guards 47 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "jsx": true, 5 | "useJSXTextNode": true, 6 | "ecmaVersion": 2018, 7 | "sourceType": "module" 8 | //"project": "./tsconfig.json" 9 | }, 10 | "plugins": [ 11 | "@typescript-eslint", 12 | "@typescript-eslint/eslint-plugin", 13 | "prettier" 14 | ], 15 | "extends": [ 16 | "plugin:@typescript-eslint/recommended", 17 | "prettier/@typescript-eslint", 18 | "plugin:prettier/recommended" 19 | ], 20 | "rules": { 21 | "prettier/prettier": [ 22 | "warn", 23 | { 24 | "semi": true, 25 | "trailingComma": "none", 26 | "singleQuote": false, 27 | "printWidth": 120, 28 | "tabWidth": 4, 29 | "useTabs": false, 30 | "arrowParens": "always", 31 | "endOfLine": "crlf" 32 | } 33 | ], 34 | "@typescript-eslint/array-type": [ 35 | "warn", 36 | { 37 | "default": "generic", 38 | "readonly": "generic" 39 | } 40 | ], 41 | "@typescript-eslint/no-unused-vars": "warn", 42 | "@typescript-eslint/explicit-function-return-type": "warn", 43 | "@typescript-eslint/no-explicit-any": "off", 44 | "@typescript-eslint/no-namespace": "off", 45 | "@typescript-eslint/no-non-null-assertion": "off", 46 | "@typescript-eslint/no-empty-function": "warn", 47 | "prefer-const": "warn", 48 | "no-undef-init": "error" 49 | } 50 | } -------------------------------------------------------------------------------- /lib/HttpRequest.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/HttpRequest.lua 3 | Description: Wrapper for an HttpService request 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | local deps = require(script.Parent.DependencySource) 9 | local newHttpResponse = require(script.Parent.HttpResponse) 10 | local HttpService, Promise, t = deps.HttpService, deps.Promise, deps.t 11 | 12 | local HttpRequest = {} 13 | 14 | local requestCheck = t.strict( 15 | t.tuple(t.string, t.string, t.optional(t.string), 16 | t.optional(t.map(t.string, t.union(t.string, t.number, t.boolean))), 17 | t.optional(t.map(t.string, t.string)) 18 | ) 19 | ) 20 | 21 | --[[** 22 | Creates an HttpRequest. 23 | 24 | @param [t:String] Url The url endpoint the request is being sent to. 25 | @param [t:String] Method A string containing the method/verb being used in the request. 26 | @param [t:String|nil] Body The body of the request. Only applicable if you're going to send data (POST, PUT, etc.) 27 | @param [t:Dictionary|nil] Query Url query options (which are then appended to the url) 28 | @param [t:Dictionary|nil] Headers Additional headers to be included in the request 29 | **--]] 30 | function HttpRequest.new(Url, Method, Body, Query, Headers) 31 | requestCheck(Url, Method, Body, Query, Headers) 32 | 33 | -- Now we can assume type-safety! 34 | 35 | if Method == "GET" or Method == "HEAD" then 36 | Body = nil 37 | end 38 | 39 | local endpoint = Url 40 | 41 | local url = Url:split("://") 42 | if url[1] == Url then 43 | error("\"" .. Url .. "\" doesn't look like a valid Url!") 44 | end 45 | 46 | -- Never hurts to check for this and correct 47 | -- https://example.org?query1=a is invalid 48 | -- https://example.org/?query1=a is not! 49 | -- We also need to check if there's already a path in the URL 50 | -- e.g https://example.com/file is different from https://example.com/file/ 51 | if not url[2]:find("/") then 52 | endpoint = endpoint .. "/" 53 | end 54 | 55 | if t.table(Query) then 56 | local queryString = "?" 57 | for i, v in pairs (Query) do 58 | queryString = queryString .. HttpService:UrlEncode(i) .. "=" .. HttpService:UrlEncode(tostring(v)) .. "&" 59 | end 60 | endpoint = endpoint .. queryString:sub(1, -2) 61 | end 62 | 63 | local httpRequest = {} 64 | 65 | httpRequest.Url = endpoint 66 | 67 | --[[** 68 | Sends the request to the specified Url. 69 | 70 | @returns [t:HttpResponse] The server's response to the request. 71 | **--]] 72 | function httpRequest:AwaitSend() 73 | -- Placeholder 74 | local success, result = pcall(function() 75 | return HttpService:RequestAsync({ 76 | Url = endpoint, 77 | Method = Method, 78 | Headers = Headers, 79 | Body = Body 80 | }) 81 | end) 82 | 83 | return newHttpResponse(success, result) 84 | end 85 | 86 | --[[** 87 | Sends the request to the specified Url. 88 | 89 | @returns [t:Promise] A promise to a HttpResponse that is resolved when it is available. 90 | **--]] 91 | function httpRequest:Send() 92 | return Promise.async(function(resolve, reject) 93 | local response = self:AwaitSend() 94 | if response.ConnectionSuccessful then 95 | resolve(response) 96 | else 97 | reject(response.StatusMessage) 98 | end 99 | end) 100 | end 101 | 102 | return setmetatable(httpRequest, { 103 | __metatable = "HttpRequest", 104 | __index = function(_, index) 105 | error("Attempt to index non-existant value HttpRequest." .. tostring(index)) 106 | end 107 | }) 108 | end 109 | 110 | return setmetatable(HttpRequest, { 111 | __metatable = "HttpRequest", 112 | __index = function(_, index) 113 | error("Attempt to index non-existant value HttpRequest." .. tostring(index)) 114 | end 115 | }) 116 | -------------------------------------------------------------------------------- /README-TS.md: -------------------------------------------------------------------------------- 1 | # Roblox Http Queue (for Roblox-TS) 2 | ## `@rbxts/http-queue` 3 | 4 | ## Current version: `v1.1.6` 5 | 6 | Writing code to make requests is simple, and maybe fun. Writing code that gracefully handles everything that can go wrong in a request... Well, that's a boring thing to do. 7 | 8 | This library is intended to help easing this by, in particular, handling servers that impose rate limits. Writing code to handle that and make sure every request we make is accepted* by the server and is not lost. 9 | 10 | This project is powered by [evaera's Promise implementation](https://github.com/evaera/roblox-lua-promise) and [Osyris' **t** typechecking library](https://github.com/osyrisrblx/t). 11 | 12 | You can use this library according to the terms of the MIT license. 13 | 14 | * For *accepted* I mean "not rate-limited". I cannot make guarantees that the service will not refuse to process the request due to, for example, invalid tokens or permissions. 15 | 16 | ## Installation (for Roblox-TS users) 17 | 18 | Use `npm`: 19 | 20 | ``` 21 | npm install @rbxts/http-queue 22 | ``` 23 | 24 | ## Usage 25 | 26 | Require the module: 27 | 28 | ```ts 29 | import {HttpRequest, HttpQueue} from "@rbxts/http-queue" 30 | ``` 31 | 32 | Create a request and send it: 33 | 34 | ```ts 35 | const request = new HttpRequest("https://some.website.com/", "GET", undefined,{ 36 | auth: "im very cool", 37 | cool: true 38 | }) 39 | 40 | // Actual Request URL is https://some.website.com/?auth=im%20very%20cool&cool=true 41 | 42 | // The :Send() method returns a Promise that resolves to a response! 43 | request.Send().then(response => { 44 | print(response.Body) 45 | }).catch(err => { 46 | print("ERROR!", err as unknown) 47 | }) 48 | 49 | // Do some work while we wait for the response to arrive 50 | 51 | // If you want to yield the script until the response arrives 52 | let response = request.AwaitSend() 53 | ``` 54 | 55 | This is cool and all, but we can make this more interesting. Let's say you want to use Trello in your application. Unfortunately, the rate limiting of Trello is very tight (10 requests per 10 seconds per token for Roblox clients). 56 | 57 | Instead of worrying about it yourself, you can delegate the responsability of dealing with the rate limits to a queue. 58 | 59 | ```ts 60 | const trelloQueue = new HttpQueue({ 61 | retryAfter: {cooldown: 10}, // If rate limited, retry in 10 seconds 62 | maxSimultaneousSendOperations: 10 // Don't send more than 10 requests at a time 63 | }) 64 | 65 | // Let's change the name to a Trello board, 1000 times (don't do this at home!) 66 | for (let i = 1; i <= 1000; i++) { 67 | let request = new HttpRequest("https://api.trello.com/1/boards/5d6f8ec6764c2112a27e3d12", "PUT", undefined, { 68 | key: "Your developer key", 69 | token: "Your developer token", 70 | name: `Your board's new name (${tostring(i)})` 71 | }) 72 | 73 | trelloQueue.Push(request).then(response => { 74 | // This will never print "429 Too Many Requests" 75 | print(response.StatusMessage) 76 | }) 77 | } 78 | 79 | // Do some work while we wait for the response to arrive 80 | 81 | // If you want to yield the script until the response comes in: 82 | let yielded_for_response = trelloQueue.AwaitPush(request) 83 | ``` 84 | 85 | Depending on what service you're using, sometimes the cooldown period varies over time: When creating a new Queue, you can specify how to deal with this on the `retryAfter` option: 86 | 87 | - `{cooldown = (number)}` - If you know that the cooldown period is a fixed number of seconds. 88 | - `{header = (string)}` - If the cooldown time is present, in **seconds**, in a response header sent by the service. 89 | - `{callback = (function)}` - For all other cases. Takes the server response and returns the number of seconds that the queue should stall before sending more requests. 90 | 91 | **Examples:** 92 | 93 | ```ts 94 | // Cooldown is fixed to 5 seconds 95 | const staticQueue = new HttpQueue({ 96 | retryAfter: {cooldown: 5} 97 | }) 98 | 99 | // We check the "x-rate-limit-cooldown-s" header to determine how long to stall 100 | const headerQueue = new HttpQueue({ 101 | retryAfter: {header: "x-rate-limit-cooldown-s"} 102 | }) 103 | 104 | // We use a callback to parse the response body and retrieve the cooldown period 105 | const callbackQueue = new HttpQueue({ 106 | retryAfter: {callback: (response: HttpResponse) => { 107 | // Our service returns a JSON body. The cooldown period is noted in milliseconds on the "cooldown" field. 108 | return game.GetService("HttpService").JSONDecode(response.Body).cooldown / 1000 109 | } 110 | } 111 | }) 112 | ``` 113 | 114 | The queue works on a "first come, first serve" basis. This means that requests being pushed first will be dealt with first by the queue. (**HOWEVER, this doesn't mean the responses will arrive in order!**) 115 | 116 | You can override that behavior by passing a `priority` parameter to the `:Push()` or `:AwaitPush()` methods. There are three options available: 117 | 118 | `HttpRequestPriority.Normal` - the default priority. The request is pushed to the back of the regular queue. 119 | 120 | `HttpRequestPriority.Prioritary` - The request is pushed to the back of the prioritary queue, that is done by the queue runner before the regular queue. 121 | 122 | `HttpRequestPriority.First` - The request is pushed to the front of the prioritary queue. 123 | 124 | **NOTE:** The priority features should be used sparingly. 125 | 126 | **Example:** 127 | 128 | ```ts 129 | import {HttpRequestPriority} from "@rbxts/http-queue" 130 | 131 | trelloQueue.Push(request, HttpRequestPriority.Prioritary) 132 | ``` 133 | 134 | ## Type Guards 135 | 136 | This library also comes with type guard functions that allow you to check whether a value is actually what you want: 137 | 138 | `isHttpRequest(value)` 139 | 140 | `isHttpRequestPriority(value)` 141 | 142 | `isHttpResponse(value)` 143 | 144 | `isHttpQueue(value)` 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roblox Http Queue 2 | 3 | ## Current version: `v1.1.6` 4 | 5 | Writing code to make requests is simple, and maybe fun. Writing code that gracefully handles everything that can go wrong in a request... Well, that's a boring thing to do. 6 | 7 | This library is intended to help easing this by, in particular, handling servers that impose rate limits. Writing code to handle that and make sure every request we make is accepted* by the server and is not lost. 8 | 9 | This project is powered by [evaera's Promise implementation](https://github.com/evaera/roblox-lua-promise) and [Osyris' **t** typechecking library](https://github.com/osyrisrblx/t). 10 | 11 | You can use this library according to the terms of the MIT license. 12 | 13 | * For *accepted* I mean "not rate-limited". I cannot make guarantees that the service will not refuse to process the request due to, for example, invalid tokens or permissions. 14 | 15 | ## Installation 16 | 17 | ### GitHub Releases 18 | 19 | Just grab the `.rbxmx` file from the releases page and drop into your project - as simple as that! 20 | 21 | ### Roblox-TS users 22 | 23 | Use `npm`: 24 | 25 | ``` 26 | npm install @rbxts/http-queue 27 | ``` 28 | 29 | > A more comprehensive guide for Roblox-TS users can be read [here](./README-TS.md). 30 | 31 | ## Usage 32 | 33 | Require the module: 34 | 35 | ```lua 36 | local Http = require(game:GetService("ServerScriptService").HttpQueue) 37 | ``` 38 | 39 | Create a request and send it: 40 | 41 | ```lua 42 | local request = Http.HttpRequest.new("https://some.website.com/", "GET", nil, {auth = "im very cool", cool = true}) 43 | -- Actual Request URL is https://some.website.com/?auth=im very cool&cool=true 44 | 45 | -- The :Send() method returns a Promise that resolves to a response! 46 | request:Send():andThen(function(response) 47 | print(response.Body) 48 | end):catch(function(err) 49 | print("ERROR!", err) 50 | end) 51 | 52 | -- Do some work while we wait for the response to arrive 53 | 54 | -- If you want to yield the script until the response arrives 55 | local response = request:AwaitSend() 56 | ``` 57 | 58 | This is cool and all, but we can make this more interesting. Let's say you want to use Trello in your application. Unfortunately, the rate limiting of Trello is very tight (10 requests per 10 seconds per token for Roblox clients). 59 | 60 | Instead of worrying about it yourself, you can delegate the responsability of dealing with the rate limits to a queue. 61 | 62 | ```lua 63 | local TrelloQueue = Http.HttpQueue.new({ 64 | retryAfter = {cooldown = 10} -- If rate limited, retry in 10 seconds 65 | maxSimultaneousSendOperations = 10 -- Don't send more than 10 requests at a time (optional) 66 | }) 67 | 68 | -- Let's change the name to a Trello board, 1000 times (don't do this at home!) 69 | for i = 1, 1000 do 70 | local request = Http.HttpRequest.new("https://api.trello.com/1/boards/5d6f8ec6764c2112a27e3d12", "PUT", nil, { 71 | key = "Your developer key", 72 | token = "Your developer token", 73 | name = "Your board's new name (" .. tostring(i) ..")" 74 | })) 75 | 76 | TrelloQueue:Push(request):andThen(function(response) 77 | -- This will never print "429 Too Many Requests" 78 | print(response.StatusMessage) 79 | end) 80 | end 81 | 82 | -- Do some work while we wait for the response to arrive 83 | 84 | -- If you want to yield the script until the response comes in: 85 | local response = TrelloQueue:AwaitPush(request) 86 | ``` 87 | 88 | Depending on what service you're using, sometimes the cooldown period varies over time: When creating a new Queue, you can specify how to deal with this on the `retryAfter` option: 89 | 90 | - `{cooldown = (number)}` - If you know that the cooldown period is a fixed number of seconds. 91 | - `{header = (string)}` - If the cooldown time is present, in **seconds**, in a response header sent by the service. 92 | - `{callback = (function)}` - For all other cases. Takes the server response and returns the number of seconds that the queue should stall before sending more requests. 93 | 94 | **Examples:** 95 | 96 | ```lua 97 | -- Cooldown is fixed to 5 seconds 98 | local staticQueue = HttpQueue.new({ 99 | retryAfter = {cooldown = 5} 100 | }) 101 | 102 | -- We check the "x-rate-limit-cooldown-s" header to determine how long to stall 103 | local headerQueue = HttpQueue.new({ 104 | retryAfter = {header = "x-rate-limit-cooldown-s"} 105 | }) 106 | 107 | -- We use a callback to parse the response body and retrieve the cooldown period 108 | local callbackQueue = HttpQueue.new({ 109 | retryAfter = {callback = function(response) 110 | -- Our service returns a JSON body. The cooldown period is noted in milliseconds on the "cooldown" field. 111 | return game:GetService("HttpService"):JSONDecode(response.Body).cooldown / 1000 112 | end} 113 | }) 114 | ``` 115 | 116 | The queue works on a "first come, first serve" basis. This means that requests being pushed first will be dealt with first by the queue. (**HOWEVER, this doesn't mean the responses will arrive in order!**) 117 | 118 | You can override that behavior by passing a `priority` parameter to the `:Push()` or `:AwaitPush()` methods. There are three options available: 119 | 120 | `HttpRequestPriority.Normal` - the default priority. The request is pushed to the back of the regular queue. 121 | 122 | `HttpRequestPriority.Prioritary` - The request is pushed to the back of the prioritary queue, that is done by the queue runner before the regular queue. 123 | 124 | `HttpRequestPriority.First` - The request is pushed to the front of the prioritary queue. 125 | 126 | **NOTE:** The priority features should be used sparingly. 127 | 128 | **Example:** 129 | 130 | ```lua 131 | TrelloQueue:Push(request, Http.HttpRequestPriority.Prioritary) 132 | ``` 133 | 134 | ## Type Guards 135 | 136 | This library also comes with type guard functions that allow you to check whether a value is actually what you want: 137 | 138 | `isHttpRequest(value)` 139 | 140 | `isHttpRequestPriority(value)` 141 | 142 | `isHttpResponse(value)` 143 | 144 | `isHttpQueue(value)` 145 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | File: http-queue/types.d.ts 3 | Description: Roblox-TS typings for the http-queue library (package: @rbxts/http-queue) 4 | 5 | SPDX-License-Identifier: MIT 6 | */ 7 | 8 | type StringDictionary = { [k: string]: string }; 9 | 10 | /** 11 | * Defines an Http request. 12 | */ 13 | interface HttpRequest { 14 | /** 15 | * The computed url to send the request to 16 | */ 17 | readonly Url: string; 18 | 19 | /** 20 | * Sends the request to the specified Url. 21 | * 22 | * @returns A promise to a HttpResponse that is resolved when it is available. 23 | */ 24 | Send(): Promise; 25 | 26 | /** 27 | * @yields 28 | * 29 | * @returns The server's response to the request. 30 | */ 31 | AwaitSend(): HttpResponse; 32 | } 33 | 34 | interface HttpRequestConstructor { 35 | /** 36 | * @constructor Creates an HttpRequest 37 | * 38 | * @param Url The url endpoint the request is being sent to. 39 | * @param Method A string containing the method/verb being used in the request. 40 | * @param Body The body of the request. Only applicable if you're going to send data (POST, PUT, etc.) 41 | * @param Query Url query options (which are then appended to the url) 42 | * @param Headers Additional headers to be included in the request 43 | * 44 | * @example 45 | * let request = new HttpRequest("https://example.org", "GET", { 46 | * isCool: true, 47 | * qwerty: "keyboard", 48 | * from: "roblox" 49 | * }, ) 50 | */ 51 | new ( 52 | Url: string, 53 | Method: string, 54 | Body?: string | undefined, 55 | Query?: { [k: string]: string | number | boolean } | undefined, 56 | Headers?: StringDictionary | undefined 57 | ): HttpRequest; 58 | } 59 | 60 | /** 61 | * Defines the server's response to an Http request. 62 | */ 63 | interface HttpResponse { 64 | /** 65 | * Whether the connection to the remote server was successful. This is related to HttpService itself. 66 | * This field can carry a value of false in the following conditions: 67 | * 68 | * - HttpService is disabled; 69 | * - The remote server is down or refusing to connect; 70 | * - Trust issues with the TLS certificates; 71 | * - Other issues not completely related to the protocol itself. 72 | * 73 | * If this value is false, all other values are undefined. 74 | */ 75 | readonly ConnectionSuccessful: boolean; 76 | 77 | /** 78 | * Whether the request to the server was successful. This is directly tied to the request itself. 79 | * It will be true if the status code is within the range of 200-299, false otherwise. 80 | */ 81 | readonly RequestSuccessful: boolean; 82 | 83 | /** 84 | * The status code returned by the remote server. 85 | */ 86 | readonly StatusCode: number; 87 | 88 | /** 89 | * An human-readable string representation of the status code returned by the remote server. 90 | */ 91 | readonly StatusMessage: string; 92 | 93 | /** 94 | * A dictionary containing the response headers returned by the remote server. 95 | */ 96 | readonly Headers: StringDictionary; 97 | 98 | /** 99 | * The data returned by the server. 100 | */ 101 | readonly Body: string; 102 | } 103 | 104 | /** 105 | * A self-regulating queue for REST APIs that impose rate limits. 106 | * When you push a request to the queue, the queue will send the ones added first to the 107 | * remote server (unless you specify a priority). The queue automatically handles the rate limits 108 | * in order to, as humanly as possible, respect the service's rate limits and Terms of Service. 109 | * 110 | * A queue is NOT A SILVER BULLET NEITHER A GUARANTEE of not spamming invalid requests, though. 111 | * Depending on your game's playerbase/number of servers compared to the rate limit of the services, 112 | * it might not scale well. 113 | */ 114 | interface HttpQueue { 115 | /** 116 | * Pushes a request to the queue to be sent whenever possible. 117 | * 118 | * @param request The request to be sent. 119 | * @param priority The priority of the request in relation to other requests in the same queue. 120 | * 121 | * @returns A promise to a HttpResponse that is resolved when it is available. 122 | */ 123 | Push(request: HttpRequest, priority?: HttpRequestPriority): Promise; 124 | 125 | /** 126 | * @yields Pushes a request to the queue to be sent whenever possible. 127 | * 128 | * @param request The request to be sent. 129 | * @param priority The priority of the request in relation to other requests in the same queue. 130 | * 131 | * @returns The server's response to the request. 132 | */ 133 | AwaitPush(request: HttpRequest, priority?: HttpRequestPriority): HttpResponse; 134 | 135 | /** 136 | * Determines how many unsent requests there are in the queue 137 | */ 138 | QueueSize(): number; 139 | } 140 | 141 | interface HttpQueueConstructor { 142 | /** 143 | * @constructor Creates an HttpQueue 144 | * 145 | * @param options The options for the queue. 146 | * @param options.retryAfter.header If the reqeuest is rate limited, look for this header to determine how long to wait (in seconds) 147 | * @param options.retryAfter.cooldown Define a cooldown period directly 148 | * @param options.retryAfter.callback Pass a function that takes a rate-limited response and returns the cooldown period (in seconds) 149 | * @param options.maxSimultaneousSendOperations How many requests should be sent at the same time (maximum). Defaults to 10. 150 | * 151 | * @returns An empty HttpQueue 152 | */ 153 | new (options: { 154 | retryAfter: { header: string } | { cooldown: number } | { callback: (resp: HttpResponse) => number }; 155 | maxSimultaneousSendOperations?: number; 156 | }): HttpQueue; 157 | } 158 | 159 | // Export type guards 160 | declare function isHttpRequest(obj: any): obj is HttpRequest; 161 | declare function isHttpRequestPriority(obj: any): obj is HttpRequestPriority; 162 | declare function isHttpResponse(obj: any): obj is HttpResponse; 163 | declare function isHttpQueue(obj: any): obj is HttpQueue; 164 | 165 | declare const HttpRequest: HttpRequestConstructor; 166 | declare const HttpQueue: HttpQueueConstructor; 167 | 168 | /** 169 | * Defines the priority of a given request in the queue. 170 | * 171 | * @param First The request will be placed at the front of the prioritary queue. 172 | * @param Prioritary The request will be placed at the back of the prioritary queue. 173 | * @param Normal The request will be placed at the back of the regular queue. 174 | */ 175 | declare enum HttpRequestPriority { 176 | /**The request will be placed at the front of the prioritary queue. */ 177 | First, 178 | /**The request will be placed at the back of the prioritary queue. */ 179 | Prioritary, 180 | /**The request will be placed at the back of the regular queue. */ 181 | Normal 182 | } 183 | 184 | export { 185 | HttpRequest, 186 | HttpResponse, 187 | HttpRequestPriority, 188 | HttpQueue, 189 | isHttpRequest, 190 | isHttpRequestPriority, 191 | isHttpResponse, 192 | isHttpQueue 193 | }; 194 | -------------------------------------------------------------------------------- /lib/HttpQueue.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | File: http-queue/HttpQueue.lua 3 | Description: Creates a self-regulating queue for rate-limited services 4 | 5 | SPDX-License-Identifier: MIT 6 | ]] 7 | 8 | local Priority = require(script.Parent.HttpRequestPriority) 9 | local newHttpResponse = require(script.Parent.HttpResponse) 10 | 11 | local datautil = require(script.Parent.DataUtils) 12 | local guards = require(script.Parent.TypeGuards) 13 | local deps = require(script.Parent.DependencySource) 14 | 15 | local Promise, t = deps.Promise, deps.t 16 | 17 | local HttpQueue = {} 18 | 19 | local validInt = t.intersection(t.integer, t.numberPositive) 20 | 21 | local newHttpQueueCheck = t.strict(t.strictInterface({ 22 | retryAfter = t.union( 23 | t.strictInterface({ 24 | header = t.string 25 | }), 26 | t.strictInterface({ 27 | cooldown = validInt 28 | }), 29 | t.strictInterface({ 30 | callback = t.callback 31 | }) 32 | ), 33 | maxSimultaneousSendOperations = t.optional(validInt) 34 | })) 35 | 36 | local pushCheck = t.strict(t.tuple(guards.isHttpRequest, t.optional(guards.isHttpRequestPriority))) 37 | 38 | --[[** 39 | Creates an HttpQueue. It is a self-regulating queue for REST APIs that impose rate limits. When you push a request to the queue, 40 | the queue will send the ones added first to the remote server (unless you specify a priority). The queue automatically handles 41 | the rate limits in order to, as humanly as possible, respect the service's rate limits and Terms of Service. 42 | 43 | A queue is NOT A SILVER BULLET NEITHER A GUARANTEE of not spamming invalid requests, though. Depending on your game's 44 | playerbase/number of servers compared to the rate limit of the services, it might not scale well. 45 | 46 | @param options The options for the queue. 47 | @param [t:string|nil] options.retryAfter.header If the reqeuest is rate limited, look for this header to determine how long to wait (in seconds). If defined, don't provide options.retryAfter.cooldown 48 | @param [t:number|nil] options.retryAfter.cooldown Define a cooldown period directly. If defined, do not define options.retryAfter.header 49 | @param [t:number(HttpResponse)|nil] options.retryAfter.callback Pass a function that takes a rate-limited response and returns the cooldown period (in seconds). If defined, do not define options.retryAfter.header 50 | @param [t:number|nil] options.maxSimultaneousSendOperations How many requests should be sent at the same time (maximum). Defaults to 10. 51 | **--]] 52 | function HttpQueue.new(options) 53 | newHttpQueueCheck(options) 54 | 55 | local prioritaryQueue = {} 56 | local regularQueue = {} 57 | 58 | local queueSize = 0 59 | 60 | local queueExecutor = coroutine.create(function() 61 | local interrupted = false 62 | local restart = false 63 | local main = coroutine.running() 64 | local availableWorkers = options.maxSimultaneousSendOperations or 10 65 | local cooldown 66 | if options.retryAfter.header then 67 | local header = options.retryAfter.header 68 | cooldown = function(response) 69 | wait(response.Headers[header]) 70 | end 71 | elseif options.retryAfter.cooldown then 72 | local cooldownPeriod = options.retryAfter.cooldown 73 | cooldown = function() 74 | wait(cooldownPeriod) 75 | end 76 | else 77 | local callback = options.retryAfter.callback 78 | cooldown = function(response) 79 | wait(callback(response)) 80 | end 81 | end 82 | 83 | local function resolveNode(node) 84 | -- Resolve the request 85 | if node.Next then 86 | node.Next.Prev = nil 87 | end 88 | node.Next = nil 89 | 90 | -- Release resources 91 | queueSize = queueSize - 1 92 | availableWorkers = availableWorkers + 1 93 | if coroutine.status(main) == "suspended" then 94 | task.spawn(main) 95 | end 96 | end 97 | 98 | local function httpStall() 99 | -- HttpService stalled (number of requests exceeded) 100 | wait(30) 101 | end 102 | 103 | local function stall(stallMethod, response) 104 | interrupted = true 105 | restart = true 106 | stallMethod(response) 107 | interrupted = false 108 | end 109 | 110 | local function sendNode(node) 111 | return Promise.async(function(resolve) 112 | node.Data.Request:Send():andThen(function(response) 113 | if response.StatusCode == 429 then 114 | stall(cooldown, response) 115 | sendNode(node) -- try again! 116 | else 117 | task.spawn(node.Data.Callback, response) 118 | end 119 | 120 | resolve(node) 121 | end):catch(function(err) 122 | -- Did we exceed the HttpService limits? 123 | if err:match("Number of requests exceeded limit") then 124 | stall(httpStall) 125 | sendNode(node) -- try again! 126 | else 127 | task.spawn(node.Data.Callback, err) 128 | end 129 | 130 | resolve(node) 131 | end) 132 | end) 133 | end 134 | 135 | local function doQueue(queue) 136 | while queue.First do 137 | while interrupted or availableWorkers == 0 do 138 | coroutine.yield() 139 | end 140 | if restart then 141 | break 142 | end 143 | 144 | local node = queue.First 145 | availableWorkers = availableWorkers - 1 146 | 147 | sendNode(node):andThen(resolveNode) 148 | 149 | queue.First = node.Next 150 | if not queue.First then 151 | queue.Last = nil 152 | end 153 | end 154 | end 155 | 156 | while true do 157 | restart = false 158 | doQueue(prioritaryQueue) 159 | doQueue(regularQueue) 160 | 161 | if not restart then 162 | coroutine.yield() 163 | end 164 | end 165 | end) 166 | 167 | local httpQueue = {} 168 | 169 | --[[** 170 | Pushes a request to the queue to be sent whenever possible. 171 | 172 | @param [t:HttpRequest] request The request to be sent. 173 | @param [t:HttpRequestPriority] priority The priority of the request in relation to other requests in the same queue. 174 | 175 | @returns [t:Promise] A promise to a HttpResponse that is resolved when it is available. 176 | **--]] 177 | function httpQueue:Push(request, priority) 178 | pushCheck(request, priority) 179 | 180 | local requestBody = {Request = request} 181 | local promise = Promise.async(function(resolve, reject) 182 | requestBody.Callback = coroutine.running() 183 | local response = coroutine.yield() 184 | if guards.isHttpResponse(response) then 185 | resolve(response) 186 | else 187 | reject(response) 188 | end 189 | end) 190 | 191 | if not priority or priority == Priority.Normal then 192 | datautil.addNodeToLast(datautil.newLLNode(requestBody), regularQueue) 193 | elseif priority == Priority.Prioritary then 194 | datautil.addNodeToLast(datautil.newLLNode(requestBody), prioritaryQueue) 195 | elseif priority == Priority.First then 196 | datautil.addNodeToFirst(datautil.newLLNode(requestBody), prioritaryQueue) 197 | end 198 | queueSize = queueSize + 1 199 | 200 | task.spawn(queueExecutor) 201 | return promise 202 | end 203 | 204 | --[[** 205 | Pushes a request to the queue to be sent whenever possible. 206 | 207 | @param [t:HttpRequest] request The request to be sent. 208 | @param [t:HttpRequestPriority] priority The priority of the request in relation to other requests in the same queue. 209 | 210 | @returns [t:HttpResponse] The server's response to the request. 211 | **--]] 212 | function httpQueue:AwaitPush(request, priority) 213 | local resolved, response = self:Push(request, priority):await() 214 | return resolved and response or newHttpResponse(false, response) 215 | end 216 | 217 | --[[** 218 | @returns [t:number] The number of unsent requests in the queue. 219 | **--]] 220 | function httpQueue:QueueSize() 221 | return queueSize 222 | end 223 | 224 | return setmetatable(httpQueue, { 225 | __metatable = "HttpQueue", 226 | __index = function(_, index) 227 | error("Attempt to index non-existant value HttpQueue." .. tostring(index)) 228 | end 229 | }) 230 | end 231 | 232 | return setmetatable(HttpQueue, { 233 | __metatable = "HttpQueue", 234 | __index = function(_, index) 235 | error("Attempt to index non-existant value HttpQueue." .. tostring(index)) 236 | end 237 | }) 238 | --------------------------------------------------------------------------------