docs directory.
27 | >
28 | ),
29 | },
30 | {
31 | title: 'Powered by React',
32 | imageUrl: 'img/undraw_docusaurus_react.svg',
33 | description: (
34 | <>
35 | Extend or customize your website layout by reusing React. Docusaurus can
36 | be extended while reusing the same header and footer.
37 | >
38 | ),
39 | },
40 | ];
41 |
42 | function Feature({imageUrl, title, description}) {
43 | const imgUrl = useBaseUrl(imageUrl);
44 | return (
45 | {description}
53 |{siteConfig.tagline}
68 |This is a test of the easy-utils HTML response type.
') 74 | }) 75 | 76 | router.get('/cookies', async (req) => { 77 | // Example on how to use Cookies with CookieJar 78 | var cookieJar = new cookies.jar(req) 79 | 80 | var token = secrets.uuidv4() 81 | 82 | cookieJar.set('authToken', token) 83 | cookieJar.set('data', { 'hello': 'world' }) 84 | 85 | return response.json({ token, oldToken: cookieJar.get('authToken') }, { cookies: cookieJar }) 86 | }) 87 | 88 | router.get('/hash', async (req) => { 89 | // A password hash generation service example 90 | var password = new URL(req.url).searchParams.get('password') || 'heck' 91 | 92 | var pass = await secrets.hashPassword(password) 93 | 94 | var match = await secrets.validatePassword(password, pass) 95 | 96 | return response.json({ password: pass, isValid: match }) 97 | }) 98 | 99 | addEventListener('fetch', (event) => { 100 | if (event.request.method == 'OPTIONS') { 101 | return event.respondWith(response.cors()) 102 | } 103 | 104 | return event.respondWith(router.handle(event.request)) 105 | }) -------------------------------------------------------------------------------- /src/secrets.js: -------------------------------------------------------------------------------- 1 | // I wanted to call this "crypto", but that would overwrite the real crypto library in JS :( 2 | 3 | function bufferToHexString(buffer) { 4 | var s = '', h = '0123456789abcdef'; 5 | (new Uint8Array(buffer)).forEach((v) => { s += h[v >> 4] + h[v & 15]; }); 6 | return s; 7 | } 8 | 9 | function hexStringToArrayBuffer(hexString) { 10 | // remove the leading 0x 11 | hexString = hexString.replace(/^0x/, ''); 12 | 13 | // ensure even number of characters 14 | if (hexString.length % 2 != 0) { 15 | console.log('WARNING: expecting an even number of characters in the hexString'); 16 | } 17 | 18 | // check for some non-hex characters 19 | var bad = hexString.match(/[G-Z\s]/i); 20 | if (bad) { 21 | console.log('WARNING: found non-hex characters', bad); 22 | } 23 | 24 | // split the string into pairs of octets 25 | var pairs = hexString.match(/[\dA-F]{2}/gi); 26 | 27 | // convert the octets to integers 28 | var integers = pairs.map(function(s) { 29 | return parseInt(s, 16); 30 | }); 31 | 32 | var array = new Uint8Array(integers); 33 | 34 | return array.buffer; 35 | } 36 | 37 | export const secrets = { 38 | uuidv4() { 39 | return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => 40 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 41 | ) 42 | }, 43 | async hashPassword(password, options) { 44 | var options = options || {} 45 | var salt = options.salt || crypto.getRandomValues(new Uint8Array(8)) 46 | var iterations = options.iterations || 45000 47 | 48 | if (typeof salt == 'string') { 49 | // this means we are validating a password 50 | // or the user is using a String salt 51 | salt = hexStringToArrayBuffer(salt) 52 | } 53 | 54 | const encoder = new TextEncoder('utf-8') 55 | 56 | const passphraseKey = encoder.encode(password) 57 | 58 | const key = await crypto.subtle.importKey( 59 | 'raw', 60 | passphraseKey, 61 | {name: 'PBKDF2'}, 62 | false, 63 | ['deriveBits', 'deriveKey'] 64 | ) 65 | 66 | const webKey = await crypto.subtle.deriveKey( 67 | { 68 | name: 'PBKDF2', 69 | salt, 70 | iterations, 71 | hash: 'SHA-256' 72 | }, 73 | key, 74 | // Don't actually need a cipher suite, 75 | // but api requires it is specified. 76 | { name: 'AES-CBC', length: 256 }, 77 | true, 78 | [ "encrypt", "decrypt" ] 79 | ) 80 | 81 | const hash = await crypto.subtle.exportKey("raw", webKey) 82 | 83 | // return a easy-utils password hash that can be stored in KV 84 | // contains all of the info we need to check later. 85 | return `$PBKDF2;h=${bufferToHexString(hash)};s=${bufferToHexString(salt)};i=${iterations};` 86 | }, 87 | async validatePassword(password, existingPassword) { 88 | var options = { 89 | salt: existingPassword.split(';s=')[1].split(';')[0], 90 | iterations: parseInt(existingPassword.split(';i=')[1].split(';')[0]) 91 | } 92 | 93 | var hashedPassword = await this.hashPassword(password, options) 94 | 95 | var isValid = true 96 | 97 | // we don't return right away to stop key based timing attacks 98 | for (var i = 0; i < hashedPassword.length; i++) { 99 | if (hashedPassword.charAt(i) != existingPassword.charAt(i)) { 100 | isValid = false // this can only be set to false. 101 | } 102 | } 103 | 104 | return isValid 105 | } 106 | } -------------------------------------------------------------------------------- /src/websockets.js: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from "nanoevents" 2 | 3 | export class Websocket { 4 | constructor (url, options) { 5 | this.url = url 6 | this.options = options 7 | this.emitter = createNanoEvents() 8 | this.sendQueue = [] 9 | 10 | if (this.url.includes('wss:')) { 11 | this.url = this.url.replace('wss:', 'https:') 12 | } 13 | 14 | if (this.url.includes('ws:')) { 15 | this.url = this.url.replace('ws:', 'http:') 16 | } 17 | 18 | this.connect() 19 | } 20 | 21 | log(msg) { 22 | if (this.options.logger) {this.options.logger.send(msg)} 23 | } 24 | 25 | async connect() { 26 | // Undocumented API at time of writing. 27 | var resp = await fetch(this.url, { headers: {'upgrade': 'websocket'} }) 28 | this.socket = resp.webSocket 29 | this.socket.accept() 30 | 31 | this.socket.addEventListener('message', (msg) => { 32 | this.emitter.emit('rawmessage', msg) 33 | 34 | var data = msg.data 35 | try { 36 | data = JSON.parse(data) 37 | if (typeof data == 'string') { 38 | // JSON convert returned a String, that means the original was a string. 39 | // we want to return the original frame as we dont want to add extra quotes to the string. 40 | data = msg.data 41 | } 42 | } catch (e) { 43 | // ignore parser errors 44 | } 45 | 46 | this.emitter.emit('message', data) 47 | }) 48 | 49 | this.socket.addEventListener('close', () => { 50 | this.emitter.emit('close') 51 | }) 52 | 53 | this.sendQueue.forEach(queued => { 54 | this.socket.send(queued) 55 | }) 56 | 57 | this.sendQueue = [] 58 | } 59 | 60 | on(event, callback) { 61 | return this.emitter.on(event, callback) 62 | } 63 | 64 | addEventListener(event, callback) { 65 | if (event == 'message') { 66 | event = 'rawmessage' 67 | } 68 | return this.emitter.on(event, callback) 69 | } 70 | 71 | send(data, options) { 72 | var toSend = data 73 | 74 | if (data.constructor == Object || data.constructor == Array) { 75 | toSend = JSON.stringify(toSend) 76 | } 77 | 78 | if (!this.socket) { 79 | this.sendQueue.push(toSend) 80 | } else { 81 | this.socket.send(toSend) 82 | } 83 | } 84 | } 85 | 86 | export class WebsocketResponse { 87 | constructor () { 88 | let pair = new WebSocketPair() 89 | this.socket = pair[1] 90 | this.client = pair[0] 91 | 92 | // accept our end of the socket 93 | this.socket.accept() 94 | 95 | this.emitter = createNanoEvents() 96 | 97 | this.session = { 98 | history: [], 99 | startTime: new Date(), 100 | lastMessageTime: null, 101 | } 102 | 103 | this.socket.addEventListener('message', (msg) => { 104 | var data = msg.data 105 | 106 | try { 107 | data = JSON.stringify(data) 108 | 109 | if (typeof data == 'string') { 110 | // dont JSON wrap data that is already a string. 111 | data = msg.data 112 | } 113 | } catch (e) { 114 | // couldn't parse incoming data 115 | } 116 | 117 | this.session.lastMessageTime = new Date() 118 | 119 | this.emitter.emit('message', data) 120 | }) 121 | 122 | this.socket.addEventListener('close', () => this.emitter.emit('close')) 123 | } 124 | 125 | on(event, callback) { 126 | return this.emitter.on(event, callback) 127 | } 128 | 129 | send(data, options) { 130 | var toSend = data 131 | 132 | if (data.constructor == Object || data.constructor == Array) { 133 | toSend = JSON.stringify(toSend) 134 | } 135 | 136 | this.socket.send(toSend) 137 | } 138 | } -------------------------------------------------------------------------------- /docs/docs/response.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: response 3 | title: 📦 Response utilities 4 | --- 5 | 6 | This is a collection of helpers to make responding to requests easier. The core idea is to reduce errors, and make your code easier to read by moving a lot of the repeated code into its own package. 7 | 8 | ```js title="Before - ❌ Messy, hard to change if theres an issue." 9 | return new Response( 10 | JSON.stringify({ 'hello': 'world' }), 11 | { headers: { 'content-type': 'application/json' } }, 12 | ) 13 | ``` 14 | 15 | ```js title="After - 🦄 Much cleaner." 16 | return response.json({ 'hello': 'world!' }) 17 | ``` 18 | 19 | --- 20 | 21 | ## response.static 22 | ***response.static(request: Request, options: Object)*** 23 | 24 | Returns your static assets to the Worker while adding asset to Cloudflare's cache. You must specify `baseUrl` as the root url you want all of the asset requests to be sent too. This can be any asset serving url such as S3 or DigitalOcean: Spaces. 25 | 26 | #### Options 27 | **baseUrl: String** 28 | The base URL of the resource you want to fetch. For example: 'https://example.com'. Make sure you allow public reads from this bucket otherwise the Worker wont be able to fetch the resource. 29 | 30 | **ttl: Integer** 31 | The time in seconds for how long you wish to keep this asset cached on Cloudflare's servers. Defaults to `600` seconds (10 minutes). 32 | 33 | **routePrefix: String** 34 | If you are running this behind a route, you might want to use this to remove the route's prefix. For example, if you had this running behind `/cdn` you might want to remove `/cdn` from the asset path otherwise it might not find the asset you want. This will remove the string you pass from the Request path. 35 | 36 | ```js title="Example" 37 | return response.static(request, { baseUrl: 'https://example.com', ttl: 1600, routePrefix: '/cdn' }) 38 | ``` 39 | 40 | ## response.json 41 | ***response.json(body: StringOrObject, options: OptionsObject)*** 42 | 43 | Returns a response object ready to be returned to the client with correct headers set. Accepts either a plain JS object or an already serialized JSON string. If the body is anything other than an Object, easy-utils will just send that as the response and will not convert it for you. 44 | 45 | ```js 46 | return response.json({ 'hello': 'world' }) 47 | ``` 48 | 49 | ## response.html 50 | ***response.html(body: String, options: OptionsObject)*** 51 | 52 | Turns the body param into a valid HTML response. Does not sanitize HTML and will not validate for structure. 53 | 54 | ```js 55 | return response.html('hello, world!
') 56 | ``` 57 | 58 | ## response.cors 59 | ***response.cors()*** 60 | 61 | Returns `null` but with CORS headers set as if it were an `OPTIONS` request. 62 | You might be interested in this snippet for handling CORS requests: 63 | 64 | ```js 65 | addEventListener('fetch', (event) => { 66 | if (event.request.method == 'OPTIONS') { 67 | return event.respondWith(response.cors()) 68 | } 69 | 70 | return event.respondWith(handleRequest(event.request)); 71 | }) 72 | ``` 73 | 74 | --- 75 | #### OptionsObject 76 | An object describing what extras to modify the response with. Almost all of the response functions accept this as the second argument except where stated. 77 | 78 | ##### headers, default: `{}` 79 | An object for setting the response headers. Adding headers this way will overwrite any existing headers that easy-utils sets. 80 | 81 | ##### status, default: `200` 82 | The status of the response. Defaults to 200, status message will be ignored by Cloudflare's CDN but we support setting it for other environments. 83 | 84 | ##### autoCors, default: `true` 85 | Adds standard CORS headers to the response so you don't have to deal with CORS errors. To set the default values, set the values on the response module you imported: 86 | 87 | ```js title="Set origin globally" 88 | import { response } from 'cfw-easy-utils' 89 | response.accessControl.allowOrigin = 'https://example.com' 90 | ``` 91 | 92 | ```js title="Make easy-utils 'Origin-Aware'" 93 | import { response } from 'cfw-easy-utils' 94 | // setting .request means easy-utils will try to use the 95 | // origin of the inbound request for its CORS data. 96 | response.request = request 97 | return response.json() 98 | ``` 99 | 100 | ##### cookies, default: `null` 101 | If present, must be an instance of easy-utils's CookieJar. Please read documentation on CookieJar for more information. 102 | 103 | ##### stopwatch, default: `null` 104 | If present, must be an instance of easy-utils's Stopwatch. Please read documentation on Stopwatch for more information. 105 | 106 | --- 107 | 108 | ## 💎 Header utilities 109 | ## response.setHeaders 110 | ***response.setHeaders(response: Response, name: String, value: String)*** 111 | 112 | Returns a **new** Response object with the headers set. Due to how JS handles responses, its safer to create a new Response object instead of fail to modify an immutable Response. 113 | 114 | ## response.headersToObject 115 | ***response.headersToObject(headers: Headers)*** 116 | 117 | Takes an Headers object and turns it into a plain JS object. Repeated headers 118 | 119 | ```js title="Example" 120 | var headers = response.headersToObject(request.headers) 121 | ``` 122 | -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './cookies.js' 2 | export * from './secrets.js' 3 | export * from './stopwatch.js' 4 | export * from './websockets.js' 5 | 6 | var version = '{{ packageVersion }}' 7 | 8 | export const response = { 9 | version, 10 | 11 | config: { 12 | // For future HMAC generation 13 | secretKey: 'password', 14 | debugHeaders: false 15 | }, 16 | 17 | accessControl: { 18 | allowOrigin: '*', 19 | allowMethods: 'GET, POST, PUT', 20 | allowHeaders: 'Content-Type', 21 | }, 22 | 23 | request: null, // if this is set, we need to use the origin of the request for our CORS headers. 24 | 25 | _corsHeaders() { 26 | var origin = this.accessControl.allowOrigin 27 | 28 | if (this.request) { 29 | // we have a request object, lets use its origin as our CORS origin. 30 | origin = new URL(this.request.url).origin 31 | } 32 | 33 | return { 34 | 'Access-Control-Allow-Origin': origin, 35 | 'Access-Control-Allow-Methods': this.accessControl.allowMethods, 36 | 'Access-Control-Max-Age': '1728000' 37 | } 38 | }, 39 | 40 | injectCors(response, options) { 41 | // modify a response object to have CORS headers. 42 | var headers = this._corsHeaders() 43 | }, 44 | 45 | _genericResponse(mimetype, body, options) { 46 | // helper function to make developing this easier. 47 | if (typeof options === 'undefined') { var options = {} } 48 | var extraHeaders = options.headers || {} 49 | var status = options.status || 200 50 | var statusText = options.statusText || 'OK' 51 | var autoCors = options.autoCors 52 | // use different method since we want a bool which can be false. 53 | if (typeof options.autoCors === 'undefined') { autoCors = true } 54 | 55 | var cookies = options.cookies || null 56 | var stopwatch = options.stopwatch || null 57 | 58 | var headers = { 59 | 'Content-Type': mimetype, 60 | ...extraHeaders 61 | } 62 | 63 | if (autoCors) { 64 | headers = { 65 | ...headers, 66 | ...this._corsHeaders() 67 | } 68 | } 69 | 70 | var resp = new Response( 71 | body, 72 | { 73 | status, 74 | statusText, 75 | headers 76 | } 77 | ) 78 | 79 | if (cookies) { 80 | var val = cookies.values() 81 | 82 | val.forEach(header => { 83 | resp.headers.append('Set-Cookie', header) 84 | }) 85 | } 86 | 87 | if (stopwatch) { 88 | resp.headers.set('Server-Timing', stopwatch.getHeader()) 89 | } 90 | 91 | if (this.config.debugHeaders) { 92 | resp.headers.set('x-cfw-eu-version', this.version) 93 | } 94 | 95 | 96 | return new Promise(res => res(resp)) 97 | }, 98 | 99 | cors(request) { 100 | if (request) { 101 | // set request so we can make our headers origin-aware. 102 | this.request = request 103 | } 104 | 105 | return new Response(null, { headers: this._corsHeaders() }) 106 | }, 107 | 108 | json(stringOrObject, options) { 109 | // turns a JSON body into a response object with valid headers. 110 | var body = stringOrObject 111 | 112 | if (typeof body != 'string') { 113 | // presume that this is an not already encoded JSON 114 | // string so we need to force it to JSON. 115 | body = JSON.stringify(stringOrObject) 116 | } 117 | 118 | return this._genericResponse( 119 | 'application/json', 120 | body, 121 | options 122 | ) 123 | }, 124 | 125 | html(body, options) { 126 | return this._genericResponse( 127 | 'text/html', 128 | body, 129 | options 130 | ) 131 | }, 132 | 133 | text(body, options) { 134 | return this._genericResponse( 135 | 'plain/text', 136 | body, 137 | options 138 | ) 139 | }, 140 | 141 | fromResponse(resp, options) { 142 | var oldHeaders = this.headersToObject(resp.headers) 143 | 144 | var headers = { 145 | ...oldHeaders, 146 | ...options.headers || {} 147 | } 148 | 149 | if ('headers' in options) { 150 | delete options.headers 151 | } 152 | 153 | var contentType = JSON.parse(JSON.stringify(headers['content-type'])) // force new String 154 | 155 | if ('content-type' in headers) { 156 | delete headers['content-type'] // to stop multiple Content-Type headers. 157 | } 158 | 159 | return this._genericResponse( 160 | contentType, 161 | resp.body, 162 | { 163 | headers, 164 | ...options 165 | } 166 | ) 167 | }, 168 | 169 | async static(request, options) { 170 | var baseUrl = options.baseUrl 171 | if (!baseUrl) { 172 | throw 'You need to specify a baseUrl for response.static to work.' 173 | } 174 | 175 | let url = new URL(request.url) 176 | 177 | // wrap this fetch in our custom Response formatter 178 | var resp = await fetch( 179 | `${baseUrl}${url.pathname.replace(options.routePrefix || '<>', '')}`, 180 | { 181 | cf: { 182 | cacheTtl: options.ttl || 600, 183 | } 184 | } 185 | ) 186 | 187 | return this.fromResponse(resp, options) 188 | }, 189 | 190 | async websocket(socket) { 191 | return new Response(null, { status: 101, webSocket: socket.client }) 192 | }, 193 | 194 | setHeader(response, key, value) { 195 | var resp = new Response(response.body, response) 196 | var val = value 197 | if (typeof value.values == 'function') { 198 | // our values function returns an array 199 | val = value.values() 200 | 201 | val.forEach(header => { 202 | resp.headers.append(key, header) 203 | }) 204 | } else { 205 | resp.headers.append(key, val) 206 | } 207 | 208 | return resp 209 | }, 210 | 211 | headersToObject(headers) { 212 | var object = Object.fromEntries(headers.entries()) 213 | return object 214 | } 215 | } -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_tree.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_mountain.svg: -------------------------------------------------------------------------------- 1 | 171 | -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_react.svg: -------------------------------------------------------------------------------- 1 | 170 | --------------------------------------------------------------------------------