├── .codecov.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── Makefile ├── README.md ├── package.json ├── src ├── forms.ts ├── index.ts ├── kv_namespace.ts ├── live_fetch.ts ├── models │ ├── Blob.ts │ ├── Body.ts │ ├── FetchEvent.ts │ ├── FormData.ts │ ├── Headers.ts │ ├── ReadableStream.ts │ ├── Request.ts │ ├── RequestCf.ts │ ├── Response.ts │ └── index.ts ├── server.ts ├── stub_fetch.ts └── utils.ts ├── tests ├── blob.test.ts ├── env.test.ts ├── example.test.ts ├── example.ts ├── fetch-event.test.ts ├── fetch.test.ts ├── form-data.test.ts ├── headers.test.ts ├── kv.test.ts ├── readable-stream.test.ts ├── request.test.ts ├── response.test.ts └── utils.test.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: [90, 100] 4 | status: 5 | patch: false 6 | project: false 7 | 8 | comment: 9 | layout: 'header, diff, flags, files, footer' 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version: '14.x' 21 | 22 | - run: yarn 23 | - run: yarn test --coverage 24 | 25 | - uses: codecov/codecov-action@v1.5.0 26 | 27 | lint: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | 33 | - uses: actions/setup-node@v2 34 | with: 35 | node-version: '14.x' 36 | 37 | - run: yarn 38 | - run: yarn lint 39 | - run: yarn prepublishOnly 40 | 41 | release: 42 | needs: 43 | - test 44 | - lint 45 | if: "success() && startsWith(github.ref, 'refs/tags/')" 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v2 50 | 51 | - uses: actions/setup-node@v2 52 | with: 53 | node-version: '14.x' 54 | registry-url: 'https://registry.npmjs.org' 55 | 56 | - run: npm install 57 | - run: npm publish 58 | env: 59 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /node_modules/ 3 | /worker/ 4 | /.idea/ 5 | /scratch/ 6 | /ts-build/ 7 | /coverage/ 8 | /test-build/ 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /scratch/ 2 | /node_modules/ 3 | /.github/ 4 | /coverage/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Samuel Colvin s@muelcolvin.com 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | 3 | .PHONY: format 4 | format: 5 | yarn format 6 | 7 | .PHONY: lint 8 | lint: 9 | yarn lint 10 | 11 | .PHONY: test 12 | test: 13 | yarn test 14 | 15 | .PHONY: testcov 16 | testcov: 17 | yarn test --coverage 18 | @echo "open coverage report with 'chrome coverage/lcov-report/index.html'" 19 | 20 | .PHONY: all 21 | all: lint testcov 22 | 23 | .PHONY: build 24 | build: 25 | yarn prepublishOnly 26 | 27 | .PHONY: build-pack 28 | build-pack: build 29 | npm pack 30 | make clean 31 | 32 | .PHONY: clean 33 | clean: 34 | rm -rf coverage 35 | rm -rf models 36 | rm -f *.js 37 | rm -f *.d.ts 38 | rm -f *.js.map 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # edge-mock 2 | 3 | [![ci](https://github.com/samuelcolvin/edge-mock/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/samuelcolvin/edge-mock/actions?query=branch%3Amain) 4 | [![codecov](https://codecov.io/gh/samuelcolvin/edge-mock/branch/main/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/edge-mock) 5 | 6 | Tools for developing and testing edge service workers, in particular CloudFlare workers. 7 | 8 | _edge-mock_ provides three things: 9 | 1. Implementations for types used in service-workers, e.g. `Request`, `Respones`, `FetchEvent` `ReadableStream` etc. 10 | 2. A function `makeEdgeEnv` for installing these types into the global namespace for use in unit tests 11 | 3. A simple HTTP server based on `express.js` which lets you run your service-worker based app locally for development 12 | 13 | You can consider _edge-mock_ as implementing the most commonly used types declare in the 14 | [`@cloudflare/workers-types`](https://www.npmjs.com/package/@cloudflare/workers-types) typescript types package. 15 | 16 | While _edge-mock_ is designed to be useful when developing 17 | [CloudFlare worker](https://developers.cloudflare.com/workers/) applications, it should be usable while developing 18 | any service-worker app including for (future) alternative edge worker implementations. 19 | 20 | _edge-mock_ is written in TypeScript and while you may be able to use it from vanilla javascript projects, you'd be 21 | better off writing your code in TypeScript! 22 | 23 | ## Install 24 | 25 | [npm/yarn] add edge-mock 26 | 27 | ## Usage 28 | 29 | _edge-mock_ provides the following types (all available to import from `edge-mock`): 30 | 31 | * `EdgeRequest` - implements the [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) interface 32 | of the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), with the addition of the 33 | [`cf`](https://developers.cloudflare.com/workers/runtime-apis/request#incomingrequestcfproperties) attribute 34 | provided in CloudFlare workers. 35 | * `EdgeResponse` - implements the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) interface 36 | * `EdgeFetchEvent` - implements the [`FetchEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent) interface, 37 | with many attributes set to `undefined` to match `FetchEvent`s in CloudFlare workers 38 | * `EdgeBlob` - implements the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) interface 39 | * `EdgeFormData` implements the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) interface 40 | * `EdgeFile` implements the [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) 41 | interface as used by `FormData` 42 | * `EdgeHeaders` - implements the [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) interface 43 | * `EdgeReadableStream` - in memory implementation of the 44 | [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) interface 45 | * `EdgeKVNamespace` - in memory implementation of CloudFlare's 46 | [KVNamespace](https://developers.cloudflare.com/workers/runtime-apis/kv) 47 | * `stub_fetch` - a very simple mock for 48 | [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) which returns `200` 49 | for requests to `https://example.com/` and `404` for all other requests 50 | * `makeEdgeEnv` - which installs all the above types (except `EdgeKVNamespace`) into `global` so they can be 51 | used in worker scripts; types are installed into global by the name of the type they shadow, e.g. `EdgeRequest` 52 | is assigned to `global` as `Request` 53 | 54 | There's also `fetch_live` (import with `import live_fetch from 'edge-mock/live_fetch'`) which is an implementation 55 | of `fetch` which makes actual http requests using `node-fetch`. It is installed by default instead of 56 | `stub_fetch` in the dev server, see below. 57 | 58 | **Please Note**: all the above types are designed for use with node while testing and are vanilla in-memory 59 | only implementations. They are not designed for production use or with large payloads. 60 | 61 | ### Example of Usage for unit testing 62 | 63 | _edge-mock_ works well with [jest](https://jestjs.io/) to make writing unit tests for edge workers delightful. 64 | 65 | Let's say you have the following `handler.ts` with a function `handleRequest` that you want to test: 66 | 67 | ```ts 68 | export async function handleRequest(event: FetchEvent): Promise { 69 | const {request} = event 70 | const method = request.method 71 | let body: string | null = null 72 | if (method == 'POST') { 73 | body = await request.text() 74 | } 75 | const url = new URL(request.url) 76 | const response_info = { 77 | method, 78 | headers: Object.fromEntries(request.headers.entries()), 79 | searchParams: Object.fromEntries(url.searchParams.entries()), 80 | body, 81 | } 82 | const headers = {'content-type': 'application/json'} 83 | return new Response(JSON.stringify(response_info, null, 2), {headers}) 84 | } 85 | ``` 86 | 87 | (To see how this would be deployed to cloudflare, see the 88 | [cloudflare worker TypeScript template](https://github.com/cloudflare/worker-typescript-template)) 89 | 90 | To test the above `handleRequest` function, you could use the following: 91 | 92 | ```ts 93 | import {makeEdgeEnv} from 'edge-mock' 94 | import {handleRequest} from '../src/handle.ts' 95 | 96 | describe('handleRequest', () => { 97 | beforeEach(() => { 98 | makeEdgeEnv() 99 | jest.resetModules() 100 | }) 101 | 102 | test('post', async () => { 103 | // Request is available here AND in handleRequest because makeEdgeEnv installed 104 | // the proxy EdgeRequest into global under that name 105 | const request = new Request('/?foo=1', {method: 'POST', body: 'hello'}) 106 | // same with FetchEvent, Response etc. 107 | const event = new FetchEvent('fetch', {request}) 108 | const response = await handleRequest(event) 109 | expect(response.status).toEqual(200) 110 | expect(await response.json()).toStrictEqual({ 111 | method: 'POST', 112 | headers: {accept: '*/*'}, 113 | searchParams: {foo: '1'}, 114 | body: 'hello', 115 | }) 116 | }) 117 | }) 118 | ``` 119 | 120 | ### Development Server 121 | 122 | The development server relies on webpack and uses webpack-watch to reload the server on code changes. 123 | 124 | To run the server, add the following to the `scripts` section of `package.json`: 125 | 126 | ```json 127 | ... 128 | "scripts": { 129 | "dev": "edge-mock-server", 130 | ... 131 | }, 132 | ... 133 | ``` 134 | 135 | **TODO:** explain how `edge-mock-config.js` works. 136 | 137 | You can then run the dev server with: 138 | 139 | ```bash 140 | yarn dev 141 | ``` 142 | 143 | (or `npm run dev` if you use `npm` rather than `yarn`) 144 | 145 | # Documentation 146 | 147 | ## KV store 148 | 149 | ```ts 150 | import {EdgeKVNamespace as KVNamespace} from 'edge-mock'; 151 | describe('edge-mock', () => { 152 | test('put and get kv', async() => { 153 | const kv = new KVNamespace(); 154 | await kv.put('foo','bar'); 155 | const value = await kv.get('foo'); 156 | expect(value).toBe('bar'); 157 | }); 158 | }); 159 | ``` 160 | 161 | ### Wrangler sites integration 162 | 163 | TODO 164 | 165 | ## Request Payload 166 | 167 | TODO 168 | 169 | ### JSON 170 | 171 | TODO 172 | 173 | ### FormData 174 | 175 | TODO 176 | 177 | ### Binary Data, ArrayBuffer 178 | 179 | TODO 180 | 181 | ## Request.cf 182 | 183 | TODO 184 | 185 | ## fetch mocking 186 | 187 | TODO 188 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edge-mock", 3 | "version": "0.0.15", 4 | "description": "types for testing an developer edge applications", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "files": [ 8 | "*.js", 9 | "*.js.map", 10 | "*.d.ts", 11 | "README.md", 12 | "LICENSE", 13 | "models/**/*", 14 | "src/**/*" 15 | ], 16 | "bin": { 17 | "edge-mock-server": "./server.js" 18 | }, 19 | "scripts": { 20 | "prepublishOnly": "tsc -b tsconfig.build.json", 21 | "postpublish": "make clean", 22 | "format": "prettier --write '**/*.{json,js,ts}'", 23 | "lint": "eslint --max-warnings=0 src tests && prettier --check '**/*.{json,js,ts}'", 24 | "test": "jest --verbose", 25 | "all": "yarn lint && yarn test" 26 | }, 27 | "author": "Samuel Colvin", 28 | "license": "MIT", 29 | "homepage": "https://github.com/samuelcolvin/edge-mock", 30 | "private": false, 31 | "keywords": [ 32 | "jsx", 33 | "edge", 34 | "edgerender", 35 | "service-worker", 36 | "node", 37 | "typescript" 38 | ], 39 | "eslintConfig": { 40 | "root": true, 41 | "parserOptions": { 42 | "ecmaVersion": 11, 43 | "sourceType": "module", 44 | "ecmaFeatures": { 45 | "jsx": true 46 | } 47 | }, 48 | "globals": { 49 | "xhr_calls": true 50 | }, 51 | "plugins": [ 52 | "unused-imports" 53 | ], 54 | "extends": [ 55 | "typescript", 56 | "prettier" 57 | ], 58 | "rules": { 59 | "unused-imports/no-unused-imports": "error", 60 | "@typescript-eslint/no-explicit-any": "off", 61 | "@typescript-eslint/explicit-module-boundary-types": "off", 62 | "@typescript-eslint/no-unused-vars": "off", 63 | "no-constant-condition": "off" 64 | } 65 | }, 66 | "jest": { 67 | "testRegex": "/tests/.*\\.test\\.ts$", 68 | "collectCoverageFrom": [ 69 | "src/**/*.ts", 70 | "!src/server.ts" 71 | ], 72 | "moduleNameMapper": { 73 | "edge-mock/(.*)": "/src/$1", 74 | "edge-mock": "/src" 75 | }, 76 | "preset": "ts-jest" 77 | }, 78 | "prettier": { 79 | "singleQuote": true, 80 | "semi": false, 81 | "trailingComma": "all", 82 | "tabWidth": 2, 83 | "printWidth": 119, 84 | "bracketSpacing": false, 85 | "arrowParens": "avoid" 86 | }, 87 | "dependencies": { 88 | "@cloudflare/workers-types": "^2.2.2", 89 | "express": "^4.17.1", 90 | "livereload": "^0.9.3", 91 | "node-fetch": "^2.6.1" 92 | }, 93 | "peerDependencies": { 94 | "webpack": "4.x.x || 5.x.x" 95 | }, 96 | "devDependencies": { 97 | "@types/express": "^4.17.12", 98 | "@types/jest": "^26.0.23", 99 | "@types/livereload": "^0.9.0", 100 | "@types/node-fetch": "^2.5.10", 101 | "@typescript-eslint/eslint-plugin": "^4.16.1", 102 | "@typescript-eslint/parser": "^4.16.1", 103 | "eslint": "^7.28.0", 104 | "eslint-config-prettier": "^8.1.0", 105 | "eslint-config-typescript": "^3.0.0", 106 | "eslint-plugin-unused-imports": "^1.1.1", 107 | "jest": "^27.0.1", 108 | "jest-each": "^27.0.2", 109 | "prettier": "^2.3.0", 110 | "ts-jest": "^27.0.1", 111 | "typescript": "^4.3.2", 112 | "webpack": "^5.38.1" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/forms.ts: -------------------------------------------------------------------------------- 1 | import {EdgeFile, EdgeFormData} from './models' 2 | 3 | export function stringAsFormData(boundary: string, body: string): FormData { 4 | let start = body.indexOf(`${boundary}\r\n`) 5 | if (start == -1) { 6 | throw new Error('boundary not found anywhere in body') 7 | } 8 | 9 | const boundaryLength = boundary.length 10 | // + 2 to account for \r\n 11 | start = start + boundaryLength + 2 12 | const form = new EdgeFormData() 13 | while (true) { 14 | const end = body.indexOf(boundary, start) 15 | if (end == -1) { 16 | return form 17 | } 18 | const sep = body.indexOf('\r\n\r\n', start) 19 | if (sep == -1 || sep > end) { 20 | throw new Error('body is not well formed, no break found between headers and body') 21 | } 22 | 23 | const header_content = body.slice(start, sep) 24 | const n = header_content.match(/name ?= ?"(.+?)"/) 25 | if (!n) { 26 | throw new Error('name not found in header') 27 | } 28 | const name = decodeURI(n[1]) 29 | let filename: string | undefined = undefined 30 | let type: string | undefined = undefined 31 | 32 | const fn = header_content.match(/filename ?= ?"(.+?)"/) 33 | if (fn) { 34 | filename = decodeURI(fn[1]) 35 | } 36 | const ct = header_content.match(/\r\nContent-Type: ?(.+)/) 37 | if (ct) { 38 | type = decodeURI(ct[1]) 39 | } 40 | 41 | let chunk_body = body.slice(sep + 4, end) 42 | chunk_body = chunk_body.substr(0, chunk_body.lastIndexOf('\r\n')) 43 | 44 | if (filename || type) { 45 | form.append(name, new EdgeFile([chunk_body], filename || 'blob', {type})) 46 | } else { 47 | form.append(name, chunk_body) 48 | } 49 | 50 | // + 2 to account for \r\n 51 | start = end + boundaryLength + 2 52 | } 53 | } 54 | 55 | export async function formDataAsString(form: FormData, boundary?: string): Promise<[string, string]> { 56 | boundary = boundary || generateBoundary() 57 | let s = '' 58 | for (const [key, value] of form) { 59 | s += await multipartSection(boundary, key, value) 60 | } 61 | return [boundary, `${s}--${boundary}--\r\n`] 62 | } 63 | 64 | export const generateBoundary = () => [...Array(32)].map(randChar).join('') 65 | const characters = 'abcdefghijklmnopqrstuvwxyz0123456789' 66 | const randChar = () => characters.charAt(Math.floor(Math.random() * characters.length)) 67 | 68 | async function multipartSection(boundary: string, key: string, value: FormDataEntryValue): Promise { 69 | let header = `Content-Disposition: form-data; name="${encodeURI(key)}"` 70 | let body: string 71 | if (typeof value == 'string') { 72 | body = value 73 | } else { 74 | header += `; filename="${encodeURI(value.name)}"` 75 | if (value.type) { 76 | header += `\r\nContent-Type: ${encodeURI(value.type)}` 77 | } 78 | body = await value.text() 79 | } 80 | return `--${boundary}\r\n${header}\r\n\r\n${body}\r\n` 81 | } 82 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EdgeRequest, 3 | EdgeBlob, 4 | EdgeFile, 5 | EdgeFormData, 6 | EdgeResponse, 7 | EdgeFetchEvent, 8 | EdgeHeaders, 9 | EdgeReadableStream, 10 | } from './models' 11 | import stub_fetch from './stub_fetch' 12 | 13 | export { 14 | EdgeRequest, 15 | EdgeBlob, 16 | EdgeFile, 17 | EdgeFormData, 18 | EdgeResponse, 19 | EdgeFetchEvent, 20 | EdgeHeaders, 21 | EdgeReadableStream, 22 | stub_fetch, 23 | } 24 | export {EdgeKVNamespace} from './kv_namespace' 25 | 26 | declare const global: any 27 | 28 | type FetchEventListener = (event: FetchEvent) => void 29 | 30 | export class EdgeEnv { 31 | protected listener: FetchEventListener | null = null 32 | 33 | constructor() { 34 | this.addEventListener = this.addEventListener.bind(this) 35 | this.getListener = this.getListener.bind(this) 36 | } 37 | 38 | getListener(): FetchEventListener { 39 | if (this.listener) { 40 | return this.listener 41 | } else { 42 | throw new Error('FetchEvent listener not yet added via addEventListener') 43 | } 44 | } 45 | 46 | addEventListener(type: 'fetch', listener: FetchEventListener): void { 47 | if (type != 'fetch') { 48 | throw new Error(`only "fetch" events are supported, not "${type}"`) 49 | } 50 | this.listener = listener 51 | } 52 | 53 | clearEventListener(): void { 54 | this.listener = null 55 | } 56 | 57 | dispatchEvent(event: FetchEvent): void { 58 | if (this.listener) { 59 | this.listener(event) 60 | } else { 61 | throw new Error('no event listener added') 62 | } 63 | } 64 | } 65 | 66 | const mock_types = { 67 | Request: EdgeRequest, 68 | Response: EdgeResponse, 69 | FetchEvent: EdgeFetchEvent, 70 | Headers: EdgeHeaders, 71 | Blob: EdgeBlob, 72 | File: EdgeFile, 73 | FormData: EdgeFormData, 74 | ReadableStream: EdgeReadableStream, 75 | fetch: stub_fetch, 76 | } 77 | 78 | export function makeEdgeEnv(extra: Record = {}): EdgeEnv { 79 | const env = new EdgeEnv() 80 | Object.assign(global, mock_types, {addEventListener: env.addEventListener}, extra) 81 | return env 82 | } 83 | -------------------------------------------------------------------------------- /src/kv_namespace.ts: -------------------------------------------------------------------------------- 1 | // https://developers.cloudflare.com/workers/runtime-apis/kv 2 | // TODO expiration 3 | import fs from 'fs' 4 | import path from 'path' 5 | import {encode, decode, escape_regex, rsToArrayBufferView, rsFromArray} from './utils' 6 | 7 | type InputValueValue = string | ArrayBuffer | ReadableStream | Buffer 8 | interface InputObject { 9 | value: InputValueValue 10 | metadata?: Record 11 | expiration?: number 12 | } 13 | type InputValue = InputValueValue | InputObject 14 | 15 | interface InternalValue { 16 | value: ArrayBuffer 17 | metadata?: unknown 18 | expiration?: number 19 | } 20 | 21 | interface OutputValue { 22 | value: any 23 | metadata: unknown | null 24 | } 25 | 26 | interface ListValue { 27 | name: string 28 | expiration?: number 29 | metadata?: unknown 30 | } 31 | 32 | type ValueTypeNames = 'text' | 'json' | 'arrayBuffer' | 'stream' 33 | 34 | export class EdgeKVNamespace implements KVNamespace { 35 | protected kv: Map 36 | 37 | constructor() { 38 | this.kv = new Map() 39 | } 40 | 41 | async get(key: string, options?: {type?: ValueTypeNames; cacheTtl?: number} | ValueTypeNames): Promise { 42 | options = options || {} 43 | if (typeof options == 'string') { 44 | options = {type: options} 45 | } 46 | const v = await this.getWithMetadata(key, options.type) 47 | return v.value || null 48 | } 49 | 50 | async getWithMetadata(key: string, type?: ValueTypeNames): Promise { 51 | const v = this.kv.get(key) 52 | if (v == undefined) { 53 | return {value: null, metadata: null} 54 | } 55 | return {value: prepare_value(v.value, type), metadata: v.metadata || {}} 56 | } 57 | 58 | async put(key: string, value: InputValueValue, {metadata}: {metadata?: Record} = {}): Promise { 59 | let _value: ArrayBuffer 60 | if (typeof value == 'string') { 61 | _value = encode(value).buffer 62 | } else if (Buffer.isBuffer(value)) { 63 | _value = value.buffer 64 | } else if ('getReader' in value) { 65 | const view = await rsToArrayBufferView(value) 66 | _value = view.buffer 67 | } else { 68 | _value = value 69 | } 70 | this.kv.set(key, {value: _value, metadata}) 71 | } 72 | 73 | async delete(key: string): Promise { 74 | this.kv.delete(key) 75 | } 76 | 77 | async list(options?: {prefix?: string; limit?: number; cursor?: string}): Promise<{ 78 | keys: ListValue[] 79 | list_complete: boolean 80 | cursor?: string 81 | }> { 82 | options = options || {} 83 | if (options.cursor) { 84 | throw new Error('list cursors not yet implemented') 85 | } 86 | 87 | const prefix = options.prefix 88 | const limit = options.limit || 1000 89 | const keys: ListValue[] = [] 90 | for (const [name, value] of this.kv) { 91 | if (!prefix || name.startsWith(prefix)) { 92 | if (keys.length == limit) { 93 | return {keys, list_complete: false, cursor: 'not-fully-implemented'} 94 | } 95 | // const {expiration, metadata} = value 96 | const {metadata} = value 97 | const list_value: ListValue = {name} 98 | // if (expiration != undefined) { 99 | // list_value.expiration = expiration 100 | // } 101 | if (metadata != undefined) { 102 | list_value.metadata = metadata 103 | } 104 | keys.push(list_value) 105 | } 106 | } 107 | return {keys, list_complete: true} 108 | } 109 | 110 | async _add_files(directory: string, prepare_key?: (file_name: string) => string): Promise { 111 | this._clear() 112 | if (!prepare_key) { 113 | const clean_dir = directory.replace(/\/+$/, '') 114 | const replace_prefix = new RegExp(`^${escape_regex(clean_dir)}\\/`) 115 | prepare_key = (file_name: string) => file_name.replace(replace_prefix, '') 116 | } 117 | return await this._add_directory(directory, prepare_key) 118 | } 119 | 120 | protected async _add_directory(directory: string, prepare_key: (file_name: string) => string): Promise { 121 | if (!(await fs.promises.stat(directory)).isDirectory()) { 122 | throw new Error(`"${directory}" is not a directory`) 123 | } 124 | 125 | const files = await fs.promises.readdir(directory) 126 | let count = 0 127 | for (const file of files) { 128 | const file_path = path.join(directory, file) 129 | const stat = await fs.promises.stat(file_path) 130 | 131 | if (stat.isFile()) { 132 | const content = await fs.promises.readFile(file_path) 133 | await this.put(prepare_key(file_path), content) 134 | count += 1 135 | } else if (stat.isDirectory()) { 136 | count += await this._add_directory(file_path, prepare_key) 137 | } 138 | } 139 | return count 140 | } 141 | 142 | _manifestJson(): string { 143 | const manifest = Object.fromEntries([...this.kv.keys()].map(k => [k, k])) 144 | return JSON.stringify(manifest) 145 | } 146 | 147 | _clear() { 148 | this.kv.clear() 149 | } 150 | 151 | async _putMany(kv: Record): Promise { 152 | const promises: Promise[] = [] 153 | for (const [k, v] of Object.entries(kv)) { 154 | if (typeof v != 'string' && 'value' in v) { 155 | promises.push(this.put(k, v.value, {metadata: v.metadata})) 156 | } else { 157 | promises.push(this.put(k, v, undefined)) 158 | } 159 | } 160 | await Promise.all(promises) 161 | } 162 | } 163 | 164 | function prepare_value(v: ArrayBuffer, type: ValueTypeNames | undefined): any { 165 | switch (type) { 166 | case 'arrayBuffer': 167 | return v 168 | case 'json': 169 | return JSON.parse(decode(v)) 170 | case 'stream': 171 | return rsFromArray([new Uint8Array(v)]) 172 | default: 173 | return decode(v) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/live_fetch.ts: -------------------------------------------------------------------------------- 1 | import node_fetch, {BodyInit} from 'node-fetch' 2 | import {rsToArrayBufferView} from './utils' 3 | import {EdgeFormData, EdgeResponse} from './models' 4 | import {check_method} from './models/Request' 5 | import {formDataAsString} from './forms' 6 | import {asHeaders} from './models/Headers' 7 | 8 | export default async function (resource: string | URL, init: RequestInit | Request = {}): Promise { 9 | const method = check_method(init.method) 10 | let headers: Record = {} 11 | if (init.headers) { 12 | const h = asHeaders(init.headers) 13 | h.delete('host') 14 | headers = Object.fromEntries(h) 15 | } 16 | 17 | let body: BodyInit | undefined = undefined 18 | const init_body = init.body 19 | if (init_body) { 20 | if (typeof init_body == 'string') { 21 | body = init_body 22 | } else if ('arrayBuffer' in init_body) { 23 | // Blob 24 | body = await init_body.arrayBuffer() 25 | } else if ('getReader' in init_body) { 26 | body = await rsToArrayBufferView(init_body) 27 | } else if (init_body instanceof EdgeFormData) { 28 | const [boundary, form_body] = await formDataAsString(init_body) 29 | const ct = headers['content-type'] 30 | if (!ct || ct == 'multipart/form-data') { 31 | headers['content-type'] = `multipart/form-data; boundary=${boundary}` 32 | } 33 | body = form_body 34 | } else { 35 | // TODO this is a bodge until all cases can be checked 36 | body = init_body as any 37 | } 38 | } 39 | 40 | const r = await node_fetch(resource, {method, headers, body}) 41 | const response_headers = Object.fromEntries(r.headers) 42 | const response_body = await r.arrayBuffer() 43 | return new EdgeResponse(response_body, {status: r.status, headers: response_headers}, r.url) 44 | } 45 | -------------------------------------------------------------------------------- /src/models/Blob.ts: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/API/Blob 2 | import {encode, decode, catArraysBufferViews} from '../utils' 3 | import {BlobOptions} from 'buffer' 4 | import {EdgeReadableStream} from './ReadableStream' 5 | 6 | export class EdgeBlob implements Blob { 7 | readonly type: string 8 | protected readonly _parts: BlobPart[] 9 | protected readonly _encoding: string 10 | 11 | constructor(parts: BlobPart[], {type, encoding}: BlobOptions = {}) { 12 | this._parts = parts 13 | this.type = type || '' 14 | this._encoding = encoding || 'utf8' // currently unused 15 | } 16 | 17 | get size(): number { 18 | let size = 0 19 | for (const part of this._parts) { 20 | if (typeof part == 'string') { 21 | size += encode(part).length 22 | } else if ('size' in part) { 23 | size += part.size 24 | } else { 25 | size += part.byteLength 26 | } 27 | } 28 | return size 29 | } 30 | 31 | async text(): Promise { 32 | return decode(await this.arrayBuffer()) 33 | } 34 | 35 | async arrayBuffer(): Promise { 36 | const buffers_views = await Promise.all(this._parts.map(partToArrayBufferView)) 37 | return catArraysBufferViews(buffers_views).buffer 38 | } 39 | 40 | stream(): ReadableStream { 41 | const iterator = this._parts[Symbol.iterator]() 42 | return new EdgeReadableStream({ 43 | async pull(controller) { 44 | const {value, done} = iterator.next() 45 | 46 | if (done) { 47 | controller.close() 48 | } else { 49 | const buffer_view = await partToArrayBufferView(value) 50 | controller.enqueue(buffer_view) 51 | } 52 | }, 53 | }) 54 | } 55 | 56 | slice(start = 0, end: number | undefined = undefined, contentType?: string): Blob { 57 | const size = this.size 58 | if (start < 0) { 59 | start = size + start 60 | } 61 | end = end || size 62 | if (end < 0) { 63 | end = size + end 64 | } 65 | const options = contentType ? {type: contentType} : {} 66 | let offset = 0 67 | if (end <= start) { 68 | return new EdgeBlob([], options) 69 | } 70 | const new_parts: BlobPart[] = [] 71 | for (const part of this._parts) { 72 | if (end <= offset) { 73 | break 74 | } 75 | let part_array: Uint8Array | ArrayBuffer | Blob 76 | let part_size: number 77 | if (typeof part == 'string') { 78 | part_array = encode(part) 79 | part_size = part_array.byteLength 80 | } else if ('arrayBuffer' in part) { 81 | part_array = part 82 | part_size = part_array.size 83 | } else { 84 | part_array = part as Uint8Array | ArrayBuffer 85 | part_size = part_array.byteLength 86 | } 87 | 88 | if (start < offset + part_size) { 89 | new_parts.push(part_array.slice(Math.max(0, start - offset), end - offset)) 90 | } 91 | offset += part_size 92 | } 93 | return new EdgeBlob(new_parts, options) 94 | } 95 | } 96 | 97 | export class EdgeFile extends EdgeBlob implements File { 98 | readonly lastModified: number 99 | readonly name: string 100 | 101 | constructor(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag) { 102 | super(fileBits, options) 103 | this.name = fileName 104 | this.lastModified = options?.lastModified || new Date().getTime() 105 | } 106 | } 107 | 108 | async function partToArrayBufferView(part: BlobPart): Promise { 109 | if (typeof part == 'string') { 110 | return encode(part) 111 | } else if ('buffer' in part) { 112 | return part 113 | } else if ('byteLength' in part) { 114 | return new Uint8Array(part) 115 | } else { 116 | return new Uint8Array(await part.arrayBuffer()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/models/Body.ts: -------------------------------------------------------------------------------- 1 | import {getType, rsToString, rsToArrayBufferView, encode} from '../utils' 2 | import {formDataAsString, stringAsFormData, generateBoundary} from '../forms' 3 | import {EdgeBlob} from './Blob' 4 | import {EdgeReadableStream} from './ReadableStream' 5 | import {EdgeFormData} from './FormData' 6 | 7 | // type BodyInit = Blob | BufferSource | FormData | URLSearchParams | ReadableStream | string; 8 | type BodyInitNotStream = Blob | BufferSource | FormData | URLSearchParams | string 9 | 10 | export class EdgeBody implements Body { 11 | protected _formBoundary?: string 12 | protected _stream: ReadableStream | null = null 13 | 14 | constructor(content: BodyInit | null | undefined, formBoundary?: string) { 15 | this._formBoundary = formBoundary 16 | if (content) { 17 | if (typeof content != 'string' && 'getReader' in content) { 18 | this._stream = content 19 | } else { 20 | this._stream = new EdgeReadableStream({ 21 | start: async controller => { 22 | const abv = await this._bodyToArrayBufferView(content) 23 | controller.enqueue(abv as Uint8Array) 24 | }, 25 | }) 26 | } 27 | } 28 | } 29 | 30 | get body(): ReadableStream | null { 31 | return this._stream 32 | } 33 | 34 | get bodyUsed(): boolean { 35 | return !!this._stream && this._stream.locked 36 | } 37 | 38 | async arrayBuffer(): Promise { 39 | this._check_used('arrayBuffer') 40 | if (this._stream) { 41 | const view = await rsToArrayBufferView(this._stream) 42 | return view.buffer 43 | } else { 44 | return new ArrayBuffer(0) 45 | } 46 | } 47 | 48 | async blob(): Promise { 49 | this._check_used('blob') 50 | let parts: ArrayBufferView[] = [] 51 | if (this._stream) { 52 | parts = [await rsToArrayBufferView(this._stream)] 53 | } 54 | return new EdgeBlob(parts) 55 | } 56 | 57 | async json(): Promise { 58 | this._check_used('json') 59 | return JSON.parse(await this._text()) 60 | } 61 | 62 | async text(): Promise { 63 | this._check_used('text') 64 | return await this._text() 65 | } 66 | 67 | async formData(): Promise { 68 | if (this._formBoundary) { 69 | return stringAsFormData(this._formBoundary, await this.text()) 70 | } else { 71 | throw new Error('unable to parse form data, invalid content-type header') 72 | } 73 | } 74 | 75 | protected async _text(): Promise { 76 | if (this._stream) { 77 | return await rsToString(this._stream) 78 | } else { 79 | return '' 80 | } 81 | } 82 | 83 | protected _check_used(name: string): void { 84 | if (this._stream?.locked) { 85 | throw new Error(`Failed to execute "${name}": body is already used`) 86 | } 87 | } 88 | 89 | protected async _bodyToArrayBufferView(body: BodyInitNotStream): Promise { 90 | if (typeof body == 'string') { 91 | return encode(body) 92 | } else if ('buffer' in body) { 93 | return body 94 | } else if ('byteLength' in body) { 95 | return new Uint8Array(body) 96 | } else if ('arrayBuffer' in body) { 97 | return new Uint8Array(await body.arrayBuffer()) 98 | } else if (body instanceof URLSearchParams) { 99 | return encode(body.toString()) 100 | } else if (body instanceof EdgeFormData) { 101 | const [_, form_body] = await formDataAsString(body, this._formBoundary) 102 | return encode(form_body) 103 | } else { 104 | throw new TypeError(`${getType(body)}s are not supported as body types`) 105 | } 106 | } 107 | } 108 | 109 | export function findBoundary(headers: Headers, content: BodyInit | null | undefined): string | undefined { 110 | const content_type = headers.get('content-type') 111 | const m_boundary = content_type ? content_type.match(/^multipart\/form-data; ?boundary=(.+)$/i) : null 112 | if (m_boundary) { 113 | return m_boundary[1] 114 | } else if (content instanceof EdgeFormData) { 115 | const boundary = generateBoundary() 116 | if (!content_type || content_type == 'multipart/form-data') { 117 | headers.set('content-type', `multipart/form-data; boundary=${boundary}`) 118 | } 119 | return boundary 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/models/FetchEvent.ts: -------------------------------------------------------------------------------- 1 | export class EdgeFetchEvent implements FetchEvent { 2 | readonly type: 'fetch' 3 | readonly request: Request 4 | _response: Response | Promise | null = null 5 | readonly _wait_until_promises: Promise[] = [] 6 | 7 | constructor(type: 'fetch', init: FetchEventInit) { 8 | if (type != 'fetch') { 9 | throw new Error('only "fetch" events are supported') 10 | } 11 | this.type = type 12 | this.request = init.request 13 | } 14 | 15 | respondWith(response: Response | Promise): void { 16 | this._response = response 17 | } 18 | 19 | waitUntil(f: Promise): void { 20 | this._wait_until_promises.push(f) 21 | } 22 | 23 | // all these values/methods are required to be a valid FetchEvent but are not implemented by FetchEvents 24 | // in CloudFlare workers, hence returning undefined 25 | /* istanbul ignore next */ 26 | get clientId(): string { 27 | return undefined as any 28 | } 29 | /* istanbul ignore next */ 30 | get resultingClientId(): string { 31 | return undefined as any 32 | } 33 | /* istanbul ignore next */ 34 | get bubbles(): boolean { 35 | return undefined as any 36 | } 37 | /* istanbul ignore next */ 38 | get cancelBubble(): boolean { 39 | return undefined as any 40 | } 41 | /* istanbul ignore next */ 42 | get cancelable(): boolean { 43 | return undefined as any 44 | } 45 | /* istanbul ignore next */ 46 | get composed(): boolean { 47 | return undefined as any 48 | } 49 | /* istanbul ignore next */ 50 | get currentTarget(): EventTarget | null { 51 | return undefined as any 52 | } 53 | /* istanbul ignore next */ 54 | get defaultPrevented(): boolean { 55 | return undefined as any 56 | } 57 | /* istanbul ignore next */ 58 | get isTrusted(): boolean { 59 | return undefined as any 60 | } 61 | /* istanbul ignore next */ 62 | get returnValue(): boolean { 63 | return undefined as any 64 | } 65 | /* istanbul ignore next */ 66 | get srcElement(): EventTarget | null { 67 | return undefined as any 68 | } 69 | /* istanbul ignore next */ 70 | get target(): EventTarget | null { 71 | return undefined as any 72 | } 73 | /* istanbul ignore next */ 74 | get timeStamp(): number { 75 | return undefined as any 76 | } 77 | /* istanbul ignore next */ 78 | get eventPhase(): number { 79 | return undefined as any 80 | } 81 | /* istanbul ignore next */ 82 | get AT_TARGET(): number { 83 | return undefined as any 84 | } 85 | /* istanbul ignore next */ 86 | get BUBBLING_PHASE(): number { 87 | return undefined as any 88 | } 89 | /* istanbul ignore next */ 90 | get CAPTURING_PHASE(): number { 91 | return undefined as any 92 | } 93 | /* istanbul ignore next */ 94 | get NONE(): number { 95 | return undefined as any 96 | } 97 | /* istanbul ignore next */ 98 | get preloadResponse(): Promise { 99 | return undefined as any 100 | } 101 | /* istanbul ignore next */ 102 | get initEvent(): (_type: string, _bubbles?: boolean, _cancelable?: boolean) => void { 103 | return undefined as any 104 | } 105 | /* istanbul ignore next */ 106 | get passThroughOnException(): () => void { 107 | return undefined as any 108 | } 109 | /* istanbul ignore next */ 110 | get composedPath(): () => EventTarget[] { 111 | return undefined as any 112 | } 113 | /* istanbul ignore next */ 114 | get preventDefault(): () => void { 115 | return undefined as any 116 | } 117 | /* istanbul ignore next */ 118 | get stopImmediatePropagation(): () => void { 119 | return undefined as any 120 | } 121 | /* istanbul ignore next */ 122 | get stopPropagation(): () => void { 123 | return undefined as any 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/models/FormData.ts: -------------------------------------------------------------------------------- 1 | import {EdgeFile} from './Blob' 2 | 3 | export class EdgeFormData implements FormData { 4 | protected map: Map = new Map() 5 | 6 | append(name: string, value: string | Blob | File, fileName?: string): void { 7 | const value_ = asFormDataEntryValue(value) 8 | const v = this.map.get(name) 9 | if (v) { 10 | v.push(value_) 11 | } else { 12 | this.map.set(name, [value_]) 13 | } 14 | } 15 | 16 | delete(name: string): void { 17 | this.map.delete(name) 18 | } 19 | 20 | get(name: string): FormDataEntryValue | null { 21 | const v = this.map.get(name) 22 | return v ? v[0] : null 23 | } 24 | 25 | getAll(name: string): FormDataEntryValue[] { 26 | return this.map.get(name) || [] 27 | } 28 | 29 | has(name: string): boolean { 30 | return this.map.has(name) 31 | } 32 | 33 | set(name: string, value: string | Blob | File, fileName?: string): void { 34 | this.map.set(name, [asFormDataEntryValue(value)]) 35 | } 36 | 37 | forEach(callbackfn: (value: FormDataEntryValue, key: string, parent: FormData) => void, thisArg?: any): void { 38 | if (thisArg) { 39 | callbackfn = callbackfn.bind(thisArg) 40 | } 41 | for (const [key, array] of this.map) { 42 | for (const value of array) { 43 | callbackfn(value, key, this) 44 | } 45 | } 46 | } 47 | 48 | *entries(): IterableIterator<[string, FormDataEntryValue]> { 49 | for (const [key, array] of this.map) { 50 | for (const value of array) { 51 | yield [key, value] 52 | } 53 | } 54 | } 55 | 56 | keys(): IterableIterator { 57 | return this.map.keys() 58 | } 59 | 60 | *values(): IterableIterator { 61 | for (const array of this.map.values()) { 62 | for (const value of array) { 63 | yield value 64 | } 65 | } 66 | } 67 | 68 | [Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]> { 69 | return this.entries() 70 | } 71 | } 72 | 73 | function asFormDataEntryValue(value: string | Blob | File): FormDataEntryValue { 74 | if (typeof value == 'string' || 'name' in value) { 75 | return value 76 | } else { 77 | const parts = (value as any)._parts 78 | return new EdgeFile(parts, 'blob') 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/models/Headers.ts: -------------------------------------------------------------------------------- 1 | // stubs https://developer.mozilla.org/en-US/docs/Web/API/Headers 2 | 3 | export class EdgeHeaders implements Headers { 4 | protected readonly map: Map 5 | 6 | constructor(init: HeadersInit | Map = {}) { 7 | if (init instanceof EdgeHeaders) { 8 | this.map = new Map(init) 9 | } else { 10 | let a: [string, string][] 11 | if (init instanceof Map) { 12 | a = [...init] 13 | } else if (Array.isArray(init)) { 14 | a = init as [string, string][] 15 | } else { 16 | a = Object.entries(init) 17 | } 18 | this.map = new Map(a.map(([k, v]) => [k.toLowerCase(), v])) 19 | } 20 | } 21 | 22 | entries(): IterableIterator<[string, string]> { 23 | return this.map.entries() 24 | } 25 | 26 | keys(): IterableIterator { 27 | return this.map.keys() 28 | } 29 | 30 | values(): IterableIterator { 31 | return this.map.values() 32 | } 33 | 34 | append(name: string, value: string): void { 35 | const k = name.toLowerCase() 36 | if (this.map.has(k)) { 37 | value = `${this.map.get(k)},${value}` 38 | } 39 | this.map.set(k, value) 40 | } 41 | 42 | delete(name: string): void { 43 | this.map.delete(name.toLowerCase()) 44 | } 45 | 46 | forEach(callback: (value: string, key: string, parent: Headers) => void, thisArg?: any): void { 47 | const cb = (value: string, key: string): void => callback(value, key, this) 48 | this.map.forEach(cb, thisArg) 49 | } 50 | 51 | get(name: string): string | null { 52 | const k = name.toLowerCase() 53 | return this.map.get(k) || null 54 | } 55 | 56 | has(name: string): boolean { 57 | return this.map.has(name.toLowerCase()) 58 | } 59 | 60 | set(name: string, value: string): void { 61 | this.map.set(name.toLowerCase(), value) 62 | } 63 | 64 | [Symbol.iterator](): IterableIterator<[string, string]> { 65 | return this.entries() 66 | } 67 | } 68 | 69 | export function asHeaders(h: HeadersInit | undefined, default_headers: Record = {}): Headers { 70 | if (!h) { 71 | return new EdgeHeaders(default_headers) 72 | } else if (h instanceof EdgeHeaders) { 73 | return h 74 | } else { 75 | return new EdgeHeaders(h) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/models/ReadableStream.ts: -------------------------------------------------------------------------------- 1 | export class EdgeReadableStream implements ReadableStream { 2 | protected readonly _internals: StreamInternals 3 | 4 | constructor(underlyingSource?: UnderlyingSource, strategy?: QueuingStrategy) { 5 | this._internals = new StreamInternals(underlyingSource, strategy || {}) 6 | } 7 | 8 | get locked(): boolean { 9 | return this._internals.locked 10 | } 11 | 12 | cancel(reason?: string): Promise { 13 | return this._internals.cancel(reason) 14 | } 15 | 16 | getReader({mode}: {mode?: 'byob'} = {}): ReadableStreamDefaultReader { 17 | if (mode) { 18 | throw new TypeError('ReadableStream modes other than default are not supported') 19 | } 20 | this._internals.acquireLock() 21 | return new EdgeReadableStreamDefaultReader(this._internals) 22 | } 23 | 24 | pipeThrough(_transform: ReadableWritablePair, _options?: StreamPipeOptions): ReadableStream { 25 | throw new Error('pipeThrough not yet implemented') 26 | } 27 | 28 | pipeTo(_dest: WritableStream, _options?: StreamPipeOptions): Promise { 29 | throw new Error('pipeTo not yet implemented') 30 | } 31 | 32 | tee(): [ReadableStream, ReadableStream] { 33 | return this._internals.tee() 34 | } 35 | } 36 | 37 | class StreamInternals { 38 | protected readonly _source?: UnderlyingSource 39 | protected readonly _chunks: R[] 40 | protected readonly _controller: EdgeReadableStreamDefaultController 41 | protected readonly _on_done_resolvers: Set = new Set() 42 | protected _closed = false 43 | protected _done = false 44 | protected _error: any = null 45 | protected _locked = false 46 | protected _start_promise: any = null 47 | protected _highWaterMark: number 48 | 49 | constructor(source?: UnderlyingSource, {highWaterMark, size}: QueuingStrategy = {}) { 50 | this._source = source 51 | if (source?.type) { 52 | throw new Error('UnderlyingSource.type is not yet supported') 53 | } 54 | this._highWaterMark = highWaterMark || 10 55 | if (size) { 56 | throw new Error('TODO call size') 57 | } 58 | this._chunks = [] 59 | this._controller = new EdgeReadableStreamDefaultController(this) 60 | if (this._source?.start) { 61 | this._start_promise = this._source.start(this._controller) 62 | } 63 | } 64 | 65 | cancel(_reason?: string): Promise { 66 | this._chunks.length = 0 67 | this._closed = true 68 | if (this._source?.cancel) { 69 | this._source?.cancel(this._controller) 70 | } 71 | 72 | return new Promise(resolve => { 73 | this.addResolver(resolve) 74 | }) 75 | } 76 | 77 | get locked(): boolean { 78 | return this._locked 79 | } 80 | 81 | acquireLock(): void { 82 | if (this._locked) { 83 | throw new Error('ReadableStream already locked') 84 | } 85 | this._locked = true 86 | } 87 | 88 | releaseLock(): void { 89 | this._locked = false 90 | } 91 | 92 | close(): void { 93 | this._closed = true 94 | } 95 | 96 | enqueue(chunk: R): void { 97 | this._chunks.push(chunk) 98 | } 99 | 100 | error(e?: any): void { 101 | this._error = e || true 102 | } 103 | 104 | addResolver(resolver: BasicCallback): void { 105 | this._on_done_resolvers.add(resolver) 106 | } 107 | 108 | protected done(): ReadableStreamDefaultReadDoneResult { 109 | for (const resolve of this._on_done_resolvers) { 110 | resolve() 111 | } 112 | this._done = true 113 | return {done: true, value: undefined} 114 | } 115 | 116 | async read(): Promise> { 117 | if (this._done) { 118 | return {done: true, value: undefined} 119 | } 120 | if (this._start_promise) { 121 | await this._start_promise 122 | this._start_promise = null 123 | } 124 | if (!this._closed && this._chunks.length < this._highWaterMark && this._source?.pull) { 125 | if (this._error) { 126 | throw this._error 127 | } else { 128 | await Promise.resolve(this._source.pull(this._controller)) 129 | } 130 | } 131 | const value = this._chunks.shift() 132 | if (value == undefined) { 133 | return this.done() 134 | } else { 135 | return {done: false, value} 136 | } 137 | } 138 | 139 | tee(): [ReadableStream, ReadableStream] { 140 | this.acquireLock() 141 | const chunks1: R[] = [...this._chunks] 142 | const chunks2: R[] = [...this._chunks] 143 | const start = async () => { 144 | const p = this._start_promise 145 | if (p) { 146 | this._start_promise = null 147 | await p 148 | } 149 | } 150 | const pull = async (controller: ReadableStreamController, which: 1 | 2): Promise => { 151 | const {value} = await this.read() 152 | if (value) { 153 | chunks1.push(value) 154 | chunks2.push(value) 155 | } 156 | const chunks = which == 1 ? chunks1 : chunks2 157 | const next = chunks.shift() 158 | if (next == undefined) { 159 | controller.close() 160 | } else { 161 | controller.enqueue(next) 162 | } 163 | } 164 | const cancel = async (controller: ReadableStreamController): Promise => { 165 | this.cancel() 166 | const c = this._source?.cancel 167 | if (c) { 168 | delete this._source?.cancel 169 | await c(controller) 170 | } 171 | } 172 | 173 | const source1: UnderlyingSource = { 174 | start: () => start(), 175 | pull: controller => pull(controller, 1), 176 | cancel: controller => cancel(controller), 177 | } 178 | const source2: UnderlyingSource = { 179 | start: () => start(), 180 | pull: controller => pull(controller, 2), 181 | cancel: controller => cancel(controller), 182 | } 183 | return [new EdgeReadableStream(source1), new EdgeReadableStream(source2)] 184 | } 185 | } 186 | 187 | class EdgeReadableStreamDefaultController implements ReadableStreamDefaultController { 188 | readonly desiredSize: number | null = null 189 | protected readonly _internals: StreamInternals 190 | 191 | constructor(internals: StreamInternals) { 192 | this._internals = internals 193 | } 194 | 195 | close(): void { 196 | this._internals.close() 197 | } 198 | 199 | enqueue(chunk: R): void { 200 | this._internals.enqueue(chunk) 201 | } 202 | 203 | error(e?: any): void { 204 | this._internals.error(e) 205 | } 206 | } 207 | 208 | type BasicCallback = () => void 209 | 210 | class EdgeReadableStreamDefaultReader implements ReadableStreamDefaultReader { 211 | protected readonly _internals: StreamInternals 212 | protected readonly _closed_promise: Promise 213 | 214 | constructor(internals: StreamInternals) { 215 | this._internals = internals 216 | this._closed_promise = new Promise(resolve => { 217 | internals.addResolver(() => resolve(undefined)) 218 | }) 219 | } 220 | 221 | get closed(): Promise { 222 | return this._closed_promise 223 | } 224 | 225 | async read(): Promise> { 226 | return this._internals.read() 227 | } 228 | 229 | cancel(reason?: any): Promise { 230 | return this._internals.cancel(reason) 231 | } 232 | 233 | releaseLock(): void { 234 | this._internals.releaseLock() 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/models/Request.ts: -------------------------------------------------------------------------------- 1 | // stubs https://developer.mozilla.org/en-US/docs/Web/API/Request 2 | import {asHeaders} from './Headers' 3 | import {EdgeBody, findBoundary} from './Body' 4 | import {example_cf} from './RequestCf' 5 | 6 | const DEFAULT_HEADERS = { 7 | accept: '*/*', 8 | } 9 | 10 | const MethodStrings = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const 11 | export type Method = typeof MethodStrings[number] 12 | 13 | export class EdgeRequest extends EdgeBody implements Request { 14 | readonly url: string 15 | readonly method: Method 16 | readonly mode: RequestMode 17 | readonly credentials: RequestCredentials 18 | readonly cache: RequestCache 19 | readonly redirect: 'follow' | 'error' | 'manual' 20 | readonly referrer: string 21 | readonly integrity: string 22 | readonly headers: Headers 23 | readonly cf: IncomingRequestCfProperties 24 | readonly destination: RequestDestination = '' 25 | readonly isHistoryNavigation = false 26 | readonly isReloadNavigation = false 27 | readonly keepalive = false 28 | readonly referrerPolicy: ReferrerPolicy = '' 29 | 30 | constructor(input: RequestInfo, init?: RequestInit) { 31 | let url: string 32 | if (typeof input == 'string') { 33 | url = input || '/' 34 | } else { 35 | url = input.url 36 | init = { 37 | body: input.body, 38 | credentials: input.credentials, 39 | headers: input.headers, 40 | method: input.method, 41 | mode: input.mode, 42 | referrer: input.referrer, 43 | cf: input.cf, 44 | ...init, 45 | } 46 | } 47 | 48 | const method = check_method(init?.method) 49 | if (init?.body && (method == 'GET' || method == 'HEAD')) { 50 | throw new TypeError('Request with GET/HEAD method cannot have body.') 51 | } 52 | 53 | const headers = asHeaders(init?.headers, DEFAULT_HEADERS) 54 | const boundary = findBoundary(headers, init?.body) 55 | super(init?.body, boundary) 56 | this.headers = headers 57 | this.url = 'https://example.com' + url 58 | this.method = method 59 | this.mode = init?.mode || 'same-origin' 60 | this.cache = init?.cache || 'default' 61 | this.referrer = init?.referrer && init?.referrer !== 'no-referrer' ? init?.referrer : '' 62 | // See https://fetch.spec.whatwg.org/#concept-request-credentials-mode 63 | this.credentials = init?.credentials || (this.mode === 'navigate' ? 'include' : 'omit') 64 | this.redirect = init?.redirect || 'follow' 65 | this.integrity = init?.integrity || '-' 66 | this.cf = example_cf(init?.cf as any) 67 | } 68 | 69 | get signal(): AbortSignal { 70 | throw new Error('signal not yet implemented') 71 | } 72 | 73 | clone(): Request { 74 | this._check_used('clone') 75 | const constructor = this.constructor as typeof EdgeRequest 76 | return new constructor(this.url, { 77 | method: this.method, 78 | headers: this.headers, 79 | body: this.body, 80 | mode: this.mode, 81 | credentials: this.credentials, 82 | cache: this.cache, 83 | redirect: this.redirect, 84 | referrer: this.referrer, 85 | integrity: this.integrity, 86 | cf: this.cf, 87 | }) 88 | } 89 | } 90 | 91 | const MethodsSet: Set = new Set(MethodStrings) 92 | 93 | export function check_method(m?: string): Method { 94 | if (m == undefined) { 95 | return 'GET' 96 | } 97 | const method = m.toUpperCase() 98 | if (!MethodsSet.has(method)) { 99 | throw new TypeError(`"${m}" is not a valid request method`) 100 | } 101 | return method as Method 102 | } 103 | -------------------------------------------------------------------------------- /src/models/RequestCf.ts: -------------------------------------------------------------------------------- 1 | export function example_cf(custom: Partial | undefined): IncomingRequestCfProperties { 2 | return { 3 | asn: 9009, 4 | city: 'New York', 5 | clientAcceptEncoding: 'gzip, deflate', 6 | clientTcpRtt: 83, 7 | colo: 'EWR', 8 | continent: 'NA', 9 | country: 'US', 10 | edgeRequestKeepAliveStatus: 1, 11 | httpProtocol: 'HTTP/1.1', 12 | latitude: '40.71570', 13 | longitude: '-74.00000', 14 | metroCode: '501', 15 | postalCode: '10013', 16 | region: 'New York', 17 | regionCode: 'NY', 18 | requestPriority: '', 19 | timezone: 'America/New_York', 20 | tlsCipher: 'AEAD-AES256-GCM-SHA384', 21 | tlsClientAuth: { 22 | certFingerprintSHA1: '', 23 | certFingerprintSHA256: '', 24 | certIssuerDN: '', 25 | certIssuerDNLegacy: '', 26 | certIssuerDNRFC2253: '', 27 | certIssuerSKI: '', 28 | certIssuerSerial: '', 29 | certNotAfter: '', 30 | certNotBefore: '', 31 | certPresented: '0', 32 | certRevoked: '0', 33 | certSKI: '', 34 | certSerial: '', 35 | certSubjectDN: '', 36 | certSubjectDNLegacy: '', 37 | certSubjectDNRFC2253: '', 38 | certVerified: 'NONE', 39 | }, 40 | tlsExportedAuthenticator: { 41 | clientFinished: 'b7561d5d8703a', 42 | clientHandshake: 'fa552e3ce2636', 43 | serverFinished: 'e81f70f5b8de4c', 44 | serverHandshake: '0f186e19f0a82', 45 | }, 46 | tlsVersion: 'TLSv1.3', 47 | ...custom, 48 | } as IncomingRequestCfProperties 49 | } 50 | -------------------------------------------------------------------------------- /src/models/Response.ts: -------------------------------------------------------------------------------- 1 | // stubs https://developer.mozilla.org/en-US/docs/Web/API/Response 2 | import {EdgeBody, findBoundary} from './Body' 3 | import {asHeaders} from './Headers' 4 | 5 | const RedirectStatuses: Set = new Set([301, 302, 303, 307, 308]) 6 | 7 | export class EdgeResponse extends EdgeBody implements Response { 8 | readonly status: number 9 | readonly ok: boolean 10 | readonly statusText: string 11 | readonly headers: Headers 12 | readonly redirected = false 13 | readonly type: ResponseType = 'default' 14 | readonly url: string 15 | readonly _extra?: any 16 | 17 | constructor(body?: BodyInit | null, init: ResponseInit = {}, url = 'https://example.com', extra?: any) { 18 | const headers = asHeaders(init.headers) 19 | const boundary = findBoundary(headers, body) 20 | super(body, boundary) 21 | if (typeof body == 'string' && !headers.has('content-type')) { 22 | headers.set('content-type', 'text/plain') 23 | } 24 | this.headers = headers 25 | this.status = init.status === undefined ? 200 : init.status 26 | this.ok = this.status >= 200 && this.status < 300 27 | this.statusText = init.statusText || '' 28 | this.url = url 29 | if (extra) { 30 | this._extra = extra 31 | } 32 | } 33 | 34 | get trailer(): Promise { 35 | throw new Error('trailer not yet implemented') 36 | } 37 | 38 | clone(): Response { 39 | const init = {status: this.status, statusText: this.statusText, headers: this.headers} 40 | if (!this.body) { 41 | return new EdgeResponse(null, init) 42 | } else if (this._stream?.locked) { 43 | throw new TypeError('Response body is already used') 44 | } else { 45 | const [s1, s2] = this.body.tee() 46 | this._stream = s1 47 | return new EdgeResponse(s2, init) 48 | } 49 | } 50 | 51 | static redirect(url: string, status = 302): Response { 52 | // see https://fetch.spec.whatwg.org/#dom-response-redirect 53 | if (!RedirectStatuses.has(status)) { 54 | throw new RangeError('Invalid status code') 55 | } 56 | return new EdgeResponse(null, { 57 | status: status, 58 | headers: { 59 | location: new URL(url).href, 60 | }, 61 | }) 62 | } 63 | 64 | static error(): Response { 65 | return new EdgeResponse(null, {status: 0}) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export {EdgeBlob, EdgeFile} from './Blob' 2 | export {EdgeFormData} from './FormData' 3 | export {EdgeRequest} from './Request' 4 | export {EdgeResponse} from './Response' 5 | export {EdgeFetchEvent} from './FetchEvent' 6 | export {EdgeHeaders} from './Headers' 7 | export {EdgeReadableStream} from './ReadableStream' 8 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from 'path' 4 | import fs from 'fs' 5 | import express, {Response as ExpressResponse} from 'express' 6 | import webpack from 'webpack' 7 | import livereload from 'livereload' 8 | import {makeEdgeEnv, EdgeKVNamespace, EdgeEnv} from './index' 9 | import live_fetch from './live_fetch' 10 | import {catArraysBufferViews, encode} from './utils' 11 | 12 | export interface Config { 13 | webpack_config: string 14 | dist_path: string 15 | dist_assets_path: string 16 | prepare_key: (f: string) => string 17 | livereload: boolean 18 | livereload_port: number 19 | port: number 20 | } 21 | 22 | declare const global: any 23 | 24 | const default_prepare_key = (f: string) => f.replace(/.*?dist\/assets\//, '') 25 | 26 | function pathExists(path: string): Promise { 27 | return fs.promises 28 | .access(path, fs.constants.F_OK) 29 | .then(() => true) 30 | .catch(() => false) 31 | } 32 | 33 | async function load_config(): Promise { 34 | const cwd = process.cwd() 35 | const dev_server_config = path.join(cwd, 'edge-mock-config.js') 36 | let config: Record = {} 37 | if (await pathExists(dev_server_config)) { 38 | try { 39 | config = await import(dev_server_config) 40 | console.log('edge-mock-config.js found, using it for config') 41 | } catch (e) { 42 | console.error('error loading', dev_server_config, e) 43 | } 44 | } else { 45 | console.log('edge-mock-config.js not found, using default config') 46 | } 47 | 48 | config.webpack_config = config.webpack_config || path.join(cwd, 'webpack.config') 49 | config.dist_path = config.dist_path || path.join(cwd, 'dist/worker') 50 | config.dist_assets_path = config.dist_assets_path || path.join(cwd, 'dist/assets') 51 | config.prepare_key = config.prepare_key || default_prepare_key 52 | config.port = config.port || 3000 53 | if (!('livereload' in config)) { 54 | config.livereload = true 55 | } 56 | config.livereload_port = config.livereload_port || 35729 57 | 58 | return config as Config 59 | } 60 | 61 | interface MultiStats { 62 | toString(options?: any): string 63 | } 64 | 65 | class WebpackState { 66 | protected _error: Error | null = null 67 | 68 | get error(): Error | null { 69 | return this._error 70 | } 71 | 72 | clearError(): void { 73 | this._error = null 74 | } 75 | 76 | setError(err: Error): void { 77 | this._error = err 78 | } 79 | } 80 | 81 | async function start_webpack(config: Config): Promise<[EdgeEnv, WebpackState]> { 82 | let static_content_kv: EdgeKVNamespace 83 | 84 | const env = makeEdgeEnv({fetch: live_fetch}) 85 | const webpack_state = new WebpackState() 86 | 87 | async function on_webpack_success(stats: MultiStats): Promise { 88 | console.log(stats.toString('minimal')) 89 | delete require.cache[require.resolve(config.dist_path)] 90 | 91 | if (await pathExists(config.dist_assets_path)) { 92 | if (!static_content_kv) { 93 | static_content_kv = new EdgeKVNamespace() 94 | global.__STATIC_CONTENT = static_content_kv 95 | console.log('adding KV store "__STATIC_CONTENT" to global namespace') 96 | } 97 | await static_content_kv._add_files(config.dist_assets_path, config.prepare_key) 98 | global.__STATIC_CONTENT_MANIFEST = static_content_kv._manifestJson() 99 | } 100 | env.clearEventListener() 101 | try { 102 | await import(config.dist_path) 103 | } catch (err) { 104 | webpack_state.setError(err) 105 | return 106 | } 107 | webpack_state.clearError() 108 | } 109 | 110 | const wp_config = await import(config.webpack_config) 111 | webpack(wp_config.default).watch({}, (err, stats) => { 112 | if (err) { 113 | console.error(err) 114 | webpack_state.setError(err) 115 | } else { 116 | on_webpack_success(stats as MultiStats) 117 | } 118 | }) 119 | 120 | return [env, webpack_state] 121 | } 122 | 123 | function livereload_script(config: Config): string { 124 | if (config.livereload) { 125 | return `\n\n\n` 126 | } else { 127 | return '' 128 | } 129 | } 130 | 131 | class ErrorResponse { 132 | protected readonly html_template = ` 133 | 134 | 135 | 136 | 137 | {status} Error 138 | 172 | 173 | 174 |
175 |

Error

176 |

{message}

177 | {detail} 178 |

Config:

179 |
{config}
180 | 185 |
186 | 187 | {livereload} 188 | ` 189 | protected readonly response: ExpressResponse 190 | protected readonly config: Config 191 | 192 | constructor(response: ExpressResponse, config: Config) { 193 | this.response = response 194 | this.config = config 195 | } 196 | 197 | onError(message: string, error?: Error, status = 502): void { 198 | this.response.status(status) 199 | this.response.set({'content-type': 'text/html'}) 200 | const context: Record = { 201 | message: this.escape(message), 202 | status: status.toString(), 203 | detail: '', 204 | livereload: livereload_script(this.config), 205 | config: this.escape(JSON.stringify(this.config, null, 2)), 206 | } 207 | 208 | if (error) { 209 | const stack = error.stack?.toString().replace(/\n.*(\/express\/lib\/|edge-mock\/server)(.|\n)*/, '') 210 | context.detail = `
${this.escape(error.message)}\n${this.escape(stack || '')}
` 211 | console.error(`${message}\n${error.message}\n${stack || ''}`) 212 | } else { 213 | console.error(message) 214 | } 215 | 216 | let html = this.html_template 217 | for (const [key, value] of Object.entries(context)) { 218 | html = html.replace(new RegExp(`{${key}}`, 'g'), value) 219 | } 220 | this.response.send(html) 221 | } 222 | 223 | protected escape(s: string): string { 224 | const html_tags: Record = {'&': '&', '<': '<', '>': '>'} 225 | return s.replace(/[&<>]/g, letter => html_tags[letter] || letter) 226 | } 227 | } 228 | 229 | function run_server(config: Config, env: EdgeEnv, webpack_state: WebpackState) { 230 | const app = express() 231 | if (config.livereload) { 232 | const reload_server = livereload.createServer({delay: 300, port: config.livereload_port}) 233 | reload_server.watch(path.dirname(config.dist_path)) 234 | } 235 | const reload_html = encode(livereload_script(config)) 236 | 237 | app.all(/.*/, (req, res) => { 238 | const error_handler = new ErrorResponse(res, config) 239 | if (webpack_state.error) { 240 | error_handler.onError('Failed to load worker code', webpack_state.error) 241 | return 242 | } 243 | 244 | let listener: (event: FetchEvent) => any 245 | try { 246 | listener = env.getListener() 247 | } catch (err) { 248 | error_handler.onError(err.message) 249 | return 250 | } 251 | 252 | const {url, method, headers} = req 253 | const request = new Request(url, {method, headers: headers as Record}) 254 | 255 | const event = new FetchEvent('fetch', {request}) 256 | event.respondWith = promise => { 257 | Promise.resolve(promise) 258 | .then(response => { 259 | res.status(response.status) 260 | res.set(Object.fromEntries(response.headers.entries())) 261 | response.arrayBuffer().then(ab => { 262 | let body: Uint8Array | ArrayBuffer = ab 263 | if (config.livereload && (response.headers.get('content-type') || '').includes('text/html')) { 264 | body = catArraysBufferViews([new Uint8Array(ab), reload_html]) 265 | } 266 | res.send(Buffer.from(body)) 267 | }) 268 | }) 269 | .catch(err => error_handler.onError('Error awaiting response promise', err)) 270 | } 271 | 272 | try { 273 | Promise.resolve(listener(event)).catch(err => error_handler.onError('Error awaiting service-worker', err)) 274 | } catch (err) { 275 | error_handler.onError('Error running service-worker', err) 276 | } 277 | }) 278 | 279 | app.listen(config.port, () => { 280 | console.log(`dev app running at http://localhost:${config.port}, livereload: ${config.livereload}`) 281 | }) 282 | } 283 | 284 | async function main(): Promise { 285 | const config = await load_config() 286 | // console.log('starting webpack, config: %o', config) 287 | const [env, wps] = await start_webpack(config) 288 | await run_server(config, env, wps) 289 | } 290 | 291 | if (require.main === module) { 292 | main().catch(e => { 293 | console.error(e) 294 | process.exit(1) 295 | }) 296 | } 297 | -------------------------------------------------------------------------------- /src/stub_fetch.ts: -------------------------------------------------------------------------------- 1 | import {EdgeResponse} from './models' 2 | import {check_method} from './models/Request' 3 | 4 | export default async function stub_fetch(resource: string | URL, init: RequestInit | Request = {}): Promise { 5 | const method = check_method(init.method) 6 | let url: URL 7 | if (typeof resource == 'string') { 8 | url = new URL(resource) 9 | } else { 10 | url = resource 11 | } 12 | if (url.href == 'https://example.com/') { 13 | return new EdgeResponse( 14 | '

response from example.com

', 15 | {status: 200, headers: {'content-type': 'text/html'}}, 16 | url.href, 17 | {init}, 18 | ) 19 | } else { 20 | return new EdgeResponse( 21 | `404 response from ${method}: ${url.href}`, 22 | {status: 404, headers: {'content-type': 'text/plain'}}, 23 | url.href, 24 | {init}, 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {TextDecoder, TextEncoder} from 'util' 2 | import {EdgeReadableStream} from './models' 3 | 4 | const encoder = new TextEncoder() 5 | const decoder = new TextDecoder() 6 | 7 | export function encode(input: string): Uint8Array { 8 | return encoder.encode(input) 9 | } 10 | 11 | export function decode(input: ArrayBufferView | ArrayBuffer): string { 12 | return decoder.decode(input as any) 13 | } 14 | 15 | export async function rsToString(rs: ReadableStream): Promise { 16 | const ab = await rsToArrayBufferView(rs) 17 | return decode(ab) 18 | } 19 | 20 | export async function rsToArrayBufferView(rs: ReadableStream): Promise { 21 | const reader = rs.getReader() 22 | const chunks: Uint8Array[] = [] 23 | while (true) { 24 | const {done, value} = await reader.read() 25 | if (done) { 26 | return catArraysBufferViews(chunks) 27 | } else { 28 | if (typeof value == 'string') { 29 | chunks.push(encode(value)) 30 | } else if ('buffer' in value) { 31 | chunks.push(value) 32 | } else if ('byteLength' in value) { 33 | chunks.push(new Uint8Array(value)) 34 | } else { 35 | throw new TypeError(`Unexpected type "${getType(value)}", expected string, ArrayBuffer or Uint8Array`) 36 | } 37 | } 38 | } 39 | } 40 | 41 | export function rsFromArray(array: T[]): ReadableStream 42 | export function rsFromArray(array: T[]): ReadableStream 43 | export function rsFromArray(array: T[]): ReadableStream 44 | export function rsFromArray(array: string[] | Uint8Array[] | ArrayBuffer[]): ReadableStream { 45 | const iterator = array[Symbol.iterator]() 46 | return new EdgeReadableStream({ 47 | pull(controller) { 48 | const {value, done} = iterator.next() 49 | 50 | if (done) { 51 | controller.close() 52 | } else { 53 | controller.enqueue(value) 54 | } 55 | }, 56 | }) 57 | } 58 | 59 | export function catArraysBufferViews(arrays: ArrayBufferView[]): Uint8Array { 60 | // TODO would Buffer.concat be faster here? 61 | const byteLength = arrays.reduce((byteLength, a) => byteLength + a.byteLength, 0) 62 | const combinedArray = new Uint8Array(byteLength) 63 | let pos = 0 64 | for (const a of arrays) { 65 | combinedArray.set(a as Uint8Array, pos) 66 | pos += a.byteLength 67 | } 68 | return combinedArray 69 | } 70 | 71 | export function getType(obj: any): string { 72 | if (obj === null) { 73 | return 'Null' 74 | } else if (obj === undefined) { 75 | return 'Undefined' 76 | } else { 77 | return Object.getPrototypeOf(obj).constructor.name 78 | } 79 | } 80 | 81 | /* 82 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping 83 | * $& means the whole matched string 84 | */ 85 | export const escape_regex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 86 | -------------------------------------------------------------------------------- /tests/blob.test.ts: -------------------------------------------------------------------------------- 1 | import each from 'jest-each' 2 | import {EdgeBlob, EdgeReadableStream} from 'edge-mock' 3 | import {rsToString} from 'edge-mock/utils' 4 | 5 | describe('EdgeBlob', () => { 6 | test('string', async () => { 7 | const blob = new EdgeBlob(['hello', ' ', 'world']) 8 | expect(blob.type).toEqual('') 9 | expect(await blob.text()).toEqual('hello world') 10 | expect(blob.size).toEqual(11) 11 | }) 12 | 13 | test('string-arrayBuffer', async () => { 14 | const blob = new EdgeBlob(['a', 'b', 'c'], {type: 'foo/bar'}) 15 | expect(blob.type).toEqual('foo/bar') 16 | expect(blob.size).toEqual(3) 17 | const buffer = await blob.arrayBuffer() 18 | expect(new Uint8Array(buffer)).toEqual(new Uint8Array([97, 98, 99])) 19 | }) 20 | 21 | test('string-stream', async () => { 22 | const blob = new EdgeBlob(['a', 'b', 'c']) 23 | expect(blob.size).toEqual(3) 24 | const stream = blob.stream() 25 | expect(stream).toBeInstanceOf(EdgeReadableStream) 26 | expect(await rsToString(stream)).toEqual('abc') 27 | }) 28 | 29 | test('Uint8Array', async () => { 30 | const uint = new Uint8Array([120, 121]) 31 | const blob = new EdgeBlob([uint.buffer, ' ', uint]) 32 | expect(await blob.text()).toEqual('xy xy') 33 | expect(blob.size).toEqual(5) 34 | const buffer = await blob.arrayBuffer() 35 | expect(new Uint8Array(buffer)).toEqual(new Uint8Array([120, 121, 32, 120, 121])) 36 | }) 37 | 38 | test('blob-of-blobs', async () => { 39 | const blob1 = new EdgeBlob(['a', 'b']) 40 | const blob = new EdgeBlob([blob1, ' ', blob1]) 41 | expect(await blob.text()).toEqual('ab ab') 42 | expect(blob.size).toEqual(5) 43 | }) 44 | 45 | test('size', async () => { 46 | const blob = new EdgeBlob(['£', '1']) 47 | expect(await blob.text()).toEqual('£1') 48 | expect(blob.size).toEqual(3) 49 | }) 50 | 51 | test('varied-types', async () => { 52 | const uint = new Uint8Array([110, 111]) 53 | const blob1 = new EdgeBlob(['a', uint]) 54 | const blob = new EdgeBlob(['x', blob1, uint]) 55 | expect(await blob.text()).toEqual('xanono') 56 | expect(blob.size).toEqual(6) 57 | }) 58 | 59 | test('slice', async () => { 60 | const blob = new EdgeBlob(['123', '456']) 61 | const b2 = blob.slice(2) 62 | expect(await b2.text()).toEqual('3456') 63 | expect(b2.type).toEqual('') 64 | expect(b2.size).toEqual(4) 65 | }) 66 | 67 | test('slice-type', async () => { 68 | const blob = new EdgeBlob(['123', '456']) 69 | const b2 = blob.slice(0, 3, 'foo/bar') 70 | expect(await b2.text()).toEqual('123') 71 | expect(b2.type).toEqual('foo/bar') 72 | expect(b2.size).toEqual(3) 73 | }) 74 | 75 | interface SliceTest { 76 | start: number 77 | end?: number 78 | expected: string 79 | } 80 | const slices: SliceTest[] = [ 81 | {start: 2, expected: '345678'}, 82 | {start: 3, expected: '45678'}, 83 | {start: 0, end: 3, expected: '123'}, 84 | {start: 0, end: 4, expected: '1234'}, 85 | {start: -3, expected: '678'}, 86 | {start: -2, end: -1, expected: '7'}, 87 | {start: 3, end: 2, expected: ''}, 88 | ] 89 | each(slices).test('slices %o', async ({start, end, expected}: SliceTest) => { 90 | const blob = new EdgeBlob(['123', '456', '78']) 91 | expect(await blob.slice(start, end).text()).toEqual(expected) 92 | }) 93 | 94 | each(slices).test('slices-types %o', async ({start, end, expected}: SliceTest) => { 95 | const inner_blob = new EdgeBlob(['45', new Uint8Array([48 + 6, 48 + 7])]) 96 | const blob = new EdgeBlob(['123', inner_blob, new Uint8Array([48 + 8])]) 97 | expect(await blob.text()).toEqual('12345678') 98 | expect(await blob.slice(start, end).text()).toEqual(expected) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /tests/env.test.ts: -------------------------------------------------------------------------------- 1 | import {makeEdgeEnv} from 'edge-mock' 2 | 3 | async function handleRequest(event: FetchEvent) { 4 | const {request, type} = event 5 | const {method, url, headers} = request 6 | const body = await request.text() 7 | const event_details = { 8 | type, 9 | request: {method, url, headers: Object.fromEntries(headers.entries()), body}, 10 | } 11 | return new Response(JSON.stringify({event: event_details}, null, 2)) 12 | } 13 | 14 | describe('makeEdgeEnv', () => { 15 | test('basic', async () => { 16 | const env = makeEdgeEnv() 17 | 18 | expect(env.getListener).toThrow('FetchEvent listener not yet added via addEventListener') 19 | addEventListener('fetch', e => { 20 | e.respondWith(handleRequest(e)) 21 | }) 22 | expect(typeof env.getListener()).toEqual('function') 23 | const request = new Request('/bar/', {method: 'POST', body: 'testing'}) 24 | const event = new FetchEvent('fetch', {request}) 25 | env.dispatchEvent(event) 26 | const response: Response = await (event as any)._response 27 | expect(response.status).toEqual(200) 28 | 29 | const obj = await response.json() 30 | expect(obj).toStrictEqual({ 31 | event: { 32 | type: 'fetch', 33 | request: { 34 | method: 'POST', 35 | url: 'https://example.com/bar/', 36 | headers: {accept: '*/*'}, 37 | body: 'testing', 38 | }, 39 | }, 40 | }) 41 | }) 42 | 43 | test('wrong-event-type', async () => { 44 | makeEdgeEnv() 45 | expect(() => addEventListener('foobar', null as any)).toThrow('only "fetch" events are supported, not "foobar"') 46 | }) 47 | 48 | test('clearEventListener', async () => { 49 | const env = makeEdgeEnv() 50 | 51 | env.addEventListener('fetch', e => { 52 | e.respondWith(handleRequest(e)) 53 | }) 54 | expect(env.getListener()).toBeInstanceOf(Function) 55 | env.clearEventListener() 56 | expect(() => env.getListener()).toThrow('FetchEvent listener not yet added via addEventListener') 57 | }) 58 | 59 | test('dispatch-no-listener', async () => { 60 | const env = makeEdgeEnv() 61 | const event = new FetchEvent('fetch', {request: new Request('/bar/')}) 62 | expect(() => env.dispatchEvent(event)).toThrow('no event listener added') 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /tests/example.test.ts: -------------------------------------------------------------------------------- 1 | import {makeEdgeEnv} from 'edge-mock' 2 | import {handleRequest} from './example' 3 | 4 | describe('handleRequest', () => { 5 | beforeEach(() => { 6 | makeEdgeEnv() 7 | jest.resetModules() 8 | }) 9 | 10 | test('post', async () => { 11 | const request = new Request('/?foo=1', {method: 'POST', body: 'hello'}) 12 | const event = new FetchEvent('fetch', {request}) 13 | const response = await handleRequest(event) 14 | expect(response.status).toEqual(200) 15 | expect(await response.json()).toStrictEqual({ 16 | method: 'POST', 17 | headers: {accept: '*/*'}, 18 | searchParams: {foo: '1'}, 19 | body: 'hello', 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /tests/example.ts: -------------------------------------------------------------------------------- 1 | export async function handleRequest(event: FetchEvent): Promise { 2 | const {request} = event 3 | const method = request.method 4 | let body: string | null = null 5 | if (method == 'POST') { 6 | body = await request.text() 7 | } 8 | const url = new URL(request.url) 9 | const response_info = { 10 | method, 11 | headers: Object.fromEntries(request.headers.entries()), 12 | searchParams: Object.fromEntries(url.searchParams.entries()), 13 | body, 14 | } 15 | const headers = {'content-type': 'application/json'} 16 | return new Response(JSON.stringify(response_info, null, 2), {headers}) 17 | } 18 | -------------------------------------------------------------------------------- /tests/fetch-event.test.ts: -------------------------------------------------------------------------------- 1 | import {EdgeFetchEvent, EdgeRequest} from 'edge-mock' 2 | 3 | describe('EdgeFetchEvent', () => { 4 | test('wrong-types', async () => { 5 | const efe = EdgeFetchEvent as any 6 | const t = () => new efe('not-fetch', null) 7 | expect(t).toThrow('only "fetch" events are supported') 8 | }) 9 | 10 | test('waitUntil', async () => { 11 | const request = new EdgeRequest('/') 12 | const event = new EdgeFetchEvent('fetch', {request}) 13 | expect(event._wait_until_promises).toHaveLength(0) 14 | expect(event.clientId).toStrictEqual(undefined) 15 | 16 | const f = async () => 123 17 | event.waitUntil(f()) 18 | expect(event._wait_until_promises).toHaveLength(1) 19 | expect(await event._wait_until_promises[0]).toEqual(123) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/fetch.test.ts: -------------------------------------------------------------------------------- 1 | import stub_fetch from 'edge-mock/stub_fetch' 2 | import live_fetch from 'edge-mock/live_fetch' 3 | import {EdgeBlob, EdgeFile, EdgeFormData, EdgeRequest} from 'edge-mock' 4 | 5 | describe('stub_fetch', () => { 6 | test('200', async () => { 7 | const r = await stub_fetch('https://example.com') 8 | expect(r.status).toEqual(200) 9 | expect(await r.text()).toEqual('

response from example.com

') 10 | expect(r.headers.get('content-type')).toEqual('text/html') 11 | }) 12 | 13 | test('404', async () => { 14 | const r = await stub_fetch('https://foobar.com') 15 | expect(r.status).toEqual(404) 16 | expect(await r.text()).toEqual('404 response from GET: https://foobar.com/') 17 | expect(r.headers.get('content-type')).toEqual('text/plain') 18 | }) 19 | test('URL', async () => { 20 | const url = new URL('https://example.com') 21 | const r = await stub_fetch(url) 22 | expect(r.status).toEqual(200) 23 | expect(await r.text()).toEqual('

response from example.com

') 24 | expect(r.headers.get('content-type')).toEqual('text/html') 25 | }) 26 | }) 27 | 28 | describe('live_fetch', () => { 29 | test('200', async () => { 30 | const r = await live_fetch('https://httpbin.org/get') 31 | expect(r.status).toEqual(200) 32 | expect(r.headers.get('content-type')).toEqual('application/json') 33 | expect(await r.json()).toEqual({ 34 | args: {}, 35 | headers: { 36 | Accept: '*/*', 37 | 'Accept-Encoding': 'gzip,deflate', 38 | Host: 'httpbin.org', 39 | 'User-Agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 40 | 'X-Amzn-Trace-Id': expect.any(String), 41 | }, 42 | origin: expect.any(String), 43 | url: 'https://httpbin.org/get', 44 | }) 45 | expect(r.url).toBe('https://httpbin.org/get') 46 | }) 47 | 48 | test('headers-body-string', async () => { 49 | const body = 'this is a test' 50 | const r = await live_fetch('https://httpbin.org/post', {method: 'POST', body, headers: {foo: 'bar'}}) 51 | expect(r.status).toEqual(200) 52 | const obj = await r.json() 53 | expect(obj.headers['Foo']).toEqual('bar') 54 | expect(obj.data).toEqual(body) 55 | }) 56 | 57 | test('blob', async () => { 58 | const body = new EdgeBlob(['foo', 'bar']) 59 | const r = await live_fetch('https://httpbin.org/post', {method: 'POST', body}) 60 | expect(r.status).toEqual(200) 61 | const obj = await r.json() 62 | // console.log(obj) 63 | expect(obj.data).toEqual('foobar') 64 | }) 65 | 66 | test('existing-request', async () => { 67 | const request = new EdgeRequest('https://www.example.com', {method: 'POST', body: 'abc'}) 68 | const r = await live_fetch('https://httpbin.org/post', request) 69 | expect(r.status).toEqual(200) 70 | const obj = await r.json() 71 | expect(obj.data).toEqual('abc') 72 | }) 73 | 74 | test('post-buffer', async () => { 75 | const body = new Uint8Array([100, 101, 102]) 76 | const r = await live_fetch('https://httpbin.org/post', {method: 'POST', body}) 77 | expect(r.status).toEqual(200) 78 | const obj = await r.json() 79 | expect(obj.data).toEqual('def') 80 | }) 81 | 82 | test('formdata', async () => { 83 | const body = new EdgeFormData() 84 | const file = new EdgeFile(['this is content'], 'foobar.txt') 85 | body.append('foo', file) 86 | body.append('spam', 'ham') 87 | const headers = {'content-type': 'multipart/form-data'} 88 | const r = await live_fetch('https://httpbin.org/post', {method: 'POST', body, headers}) 89 | expect(r.status).toEqual(200) 90 | const obj = await r.json() 91 | expect(obj.files).toEqual({foo: 'this is content'}) 92 | expect(obj.form).toEqual({spam: 'ham'}) 93 | expect(obj.headers['Content-Type']).toMatch(/multipart\/form-data; boundary=[a-z0-9]{32}/) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /tests/form-data.test.ts: -------------------------------------------------------------------------------- 1 | import {EdgeFormData, EdgeFile, EdgeBlob} from 'edge-mock' 2 | import {formDataAsString, stringAsFormData} from 'edge-mock/forms' 3 | 4 | describe('EdgeFormData', () => { 5 | test('append', () => { 6 | const fd = new EdgeFormData() 7 | fd.append('a', '1') 8 | fd.append('a', '2') 9 | fd.append('b', '3') 10 | expect([...fd]).toStrictEqual([ 11 | ['a', '1'], 12 | ['a', '2'], 13 | ['b', '3'], 14 | ]) 15 | }) 16 | 17 | test('delete', () => { 18 | const fd = new EdgeFormData() 19 | fd.append('a', '1') 20 | fd.append('a', '2') 21 | fd.append('b', '3') 22 | fd.delete('a') 23 | expect([...fd]).toStrictEqual([['b', '3']]) 24 | }) 25 | 26 | test('get', () => { 27 | const fd = new EdgeFormData() 28 | fd.append('a', '1') 29 | expect(fd.get('a')).toEqual('1') 30 | fd.append('a', '2') 31 | expect(fd.get('a')).toEqual('1') 32 | expect(fd.get('b')).toBeNull() 33 | }) 34 | 35 | test('getAll', () => { 36 | const fd = new EdgeFormData() 37 | expect(fd.getAll('a')).toStrictEqual([]) 38 | fd.append('a', '1') 39 | expect(fd.getAll('a')).toStrictEqual(['1']) 40 | fd.append('a', '2') 41 | expect(fd.getAll('a')).toStrictEqual(['1', '2']) 42 | }) 43 | 44 | test('has', () => { 45 | const fd = new EdgeFormData() 46 | expect(fd.has('a')).toStrictEqual(false) 47 | fd.append('a', '1') 48 | expect(fd.has('a')).toStrictEqual(true) 49 | fd.append('a', '2') 50 | expect(fd.has('a')).toStrictEqual(true) 51 | }) 52 | 53 | test('set', () => { 54 | const fd = new EdgeFormData() 55 | fd.append('a', '1') 56 | fd.append('a', '2') 57 | expect([...fd]).toStrictEqual([ 58 | ['a', '1'], 59 | ['a', '2'], 60 | ]) 61 | fd.set('a', '3') 62 | expect([...fd]).toStrictEqual([['a', '3']]) 63 | }) 64 | 65 | test('set-order', () => { 66 | const fd = new EdgeFormData() 67 | fd.append('a', '1') 68 | fd.append('b', '2') 69 | expect([...fd]).toStrictEqual([ 70 | ['a', '1'], 71 | ['b', '2'], 72 | ]) 73 | fd.set('a', '3') 74 | expect([...fd]).toStrictEqual([ 75 | ['a', '3'], 76 | ['b', '2'], 77 | ]) 78 | }) 79 | 80 | test('append-blob', async () => { 81 | const fd = new EdgeFormData() 82 | const blob = new EdgeBlob(['this is', ' content']) 83 | fd.append('foo', blob) 84 | const f: File = fd.get('foo') as any 85 | expect(f.name).toEqual('blob') 86 | expect(await f.text()).toEqual('this is content') 87 | expect(Number.isInteger(f.lastModified)).toBeTruthy() 88 | }) 89 | 90 | test('keys-values', () => { 91 | const fd = new EdgeFormData() 92 | fd.append('a', '1') 93 | fd.append('a', '2') 94 | fd.append('b', '3') 95 | expect([...fd.keys()]).toEqual(['a', 'b']) 96 | expect([...fd.values()]).toEqual(['1', '2', '3']) 97 | }) 98 | 99 | test('ForEach', () => { 100 | const fd = new EdgeFormData() 101 | fd.append('a', '1') 102 | fd.append('a', '2') 103 | fd.append('b', '3') 104 | const array: any[] = [] 105 | fd.forEach((value, key, parent) => { 106 | expect(parent).toBeInstanceOf(EdgeFormData) 107 | array.push({value, key}) 108 | }) 109 | expect(array).toStrictEqual([ 110 | {value: '1', key: 'a'}, 111 | {value: '2', key: 'a'}, 112 | {value: '3', key: 'b'}, 113 | ]) 114 | }) 115 | 116 | test('ForEach-thisArg', () => { 117 | const fd = new EdgeFormData() 118 | fd.append('a', '1') 119 | fd.append('a', '1') 120 | fd.append('b', '1') 121 | 122 | function cb(this: any, value: FormDataEntryValue): void { 123 | expect(value).toEqual('1') 124 | expect(this).toEqual('test-this') 125 | } 126 | fd.forEach(cb, 'test-this') 127 | }) 128 | }) 129 | 130 | describe('formDataAsMultipart', () => { 131 | test('as-multipart-one', async () => { 132 | const fd = new EdgeFormData() 133 | fd.append('foo', 'bar') 134 | 135 | const [boundary, data] = await formDataAsString(fd) 136 | expect(boundary).toMatch(/^[a-z0-9]{32}$/) 137 | expect(data).toEqual( 138 | `--${boundary}\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--${boundary}--\r\n`, 139 | ) 140 | }) 141 | 142 | test('encode', async () => { 143 | const fd = new EdgeFormData() 144 | fd.append('f\noo', 'bar') 145 | 146 | const [boundary, data] = await formDataAsString(fd) 147 | expect(boundary).toMatch(/^[a-z0-9]{32}$/) 148 | expect(data).toEqual( 149 | `--${boundary}\r\nContent-Disposition: form-data; name="f%0Aoo"\r\n\r\nbar\r\n--${boundary}--\r\n`, 150 | ) 151 | }) 152 | 153 | test('as-multipart-file', async () => { 154 | const fd = new EdgeFormData() 155 | const file = new EdgeFile(['this is content'], 'foobar.txt') 156 | fd.append('foo', file) 157 | 158 | const [boundary, data] = await formDataAsString(fd) 159 | expect(data).toEqual( 160 | `--${boundary}\r\n` + 161 | `Content-Disposition: form-data; name="foo"; filename="foobar.txt"\r\n\r\n` + 162 | `this is content\r\n` + 163 | `--${boundary}--\r\n`, 164 | ) 165 | }) 166 | 167 | test('content-type-encode', async () => { 168 | const fd = new EdgeFormData() 169 | const file = new EdgeFile(['this is content'], 'foo"bar.txt', {type: 'text/plain'}) 170 | fd.append('foo', file) 171 | 172 | const [boundary, data] = await formDataAsString(fd) 173 | expect(data).toEqual( 174 | `--${boundary}\r\n` + 175 | `Content-Disposition: form-data; name="foo"; filename="foo%22bar.txt"\r\n` + 176 | `Content-Type: text/plain\r\n\r\n` + 177 | `this is content\r\n` + 178 | `--${boundary}--\r\n`, 179 | ) 180 | }) 181 | }) 182 | 183 | describe('stringAsFormData', () => { 184 | test('basic', async () => { 185 | const fd = new EdgeFormData() 186 | fd.append('foo', 'bar') 187 | fd.append('spam', 'ham') 188 | 189 | const [boundary, body] = await formDataAsString(fd) 190 | 191 | const fd2 = stringAsFormData(boundary, body) 192 | 193 | expect(Object.fromEntries(fd2)).toStrictEqual({foo: 'bar', spam: 'ham'}) 194 | }) 195 | 196 | test('file', async () => { 197 | const fd = new EdgeFormData() 198 | const file = new EdgeFile(['this is content'], 'foobar.txt') 199 | fd.append('foo', file) 200 | 201 | const [boundary, body] = await formDataAsString(fd) 202 | const fd2 = stringAsFormData(boundary, body) 203 | expect([...fd2.keys()]).toStrictEqual(['foo']) 204 | const foo = fd2.get('foo') as EdgeFile 205 | expect(foo).toBeInstanceOf(EdgeFile) 206 | expect(foo.name).toEqual('foobar.txt') 207 | expect(foo.type).toEqual('') 208 | expect(await foo.text()).toEqual('this is content') 209 | }) 210 | 211 | test('file-with-ct', async () => { 212 | const fd = new EdgeFormData() 213 | const file = new EdgeFile(['this is content', ' and some more'], 'foobar.txt', {type: 'text/plain'}) 214 | fd.append('foo', file) 215 | 216 | const [boundary, body] = await formDataAsString(fd) 217 | const fd2 = stringAsFormData(boundary, body) 218 | const foo = fd2.get('foo') as EdgeFile 219 | expect(foo).toBeInstanceOf(EdgeFile) 220 | expect(foo.name).toEqual('foobar.txt') 221 | expect(foo.type).toEqual('text/plain') 222 | expect(await foo.text()).toEqual('this is content and some more') 223 | }) 224 | 225 | test('escaping', async () => { 226 | const fd = new EdgeFormData() 227 | fd.append('"foo"', 'apple\r\n"banana"\r\n\r\ncarrot\n\r\n') 228 | 229 | const [boundary, body] = await formDataAsString(fd) 230 | 231 | const fd2 = stringAsFormData(boundary, body) 232 | 233 | expect(Object.fromEntries(fd2)).toStrictEqual({'"foo"': 'apple\r\n"banana"\r\n\r\ncarrot\n\r\n'}) 234 | }) 235 | 236 | test('invalid-no-boundary', async () => { 237 | expect(() => stringAsFormData('foobar', 'spam')).toThrow('boundary not found anywhere in body') 238 | }) 239 | 240 | test('invalid-no-separator', async () => { 241 | const body = '--[boundary]\r\nContent-Disposition: form-data; name="foo";\r\nthis is body--[boundary]--\r\n' 242 | expect(() => stringAsFormData('[boundary]', body)).toThrow( 243 | 'body is not well formed, no break found between headers and body', 244 | ) 245 | }) 246 | 247 | test('invalid-no-name', async () => { 248 | const body = '--[boundary]\r\nContent-Disposition: form-data;\r\n\r\nthis is body\r\n--[boundary]--\r\n' 249 | expect(() => stringAsFormData('[boundary]', body)).toThrow('name not found in header') 250 | }) 251 | }) 252 | -------------------------------------------------------------------------------- /tests/headers.test.ts: -------------------------------------------------------------------------------- 1 | import {EdgeHeaders} from 'edge-mock' 2 | 3 | describe('EdgeHeaders', () => { 4 | test('object', async () => { 5 | const headers = new EdgeHeaders({Foo: 'bar', apple: 'Banana'}) 6 | expect(headers.get('Foo')).toEqual('bar') 7 | expect(headers.get('foo')).toEqual('bar') 8 | expect(headers.get('FOO')).toEqual('bar') 9 | expect([...headers.keys()]).toEqual(['foo', 'apple']) 10 | expect([...headers.values()]).toEqual(['bar', 'Banana']) 11 | expect([...headers.entries()]).toEqual([ 12 | ['foo', 'bar'], 13 | ['apple', 'Banana'], 14 | ]) 15 | expect([...headers]).toEqual([ 16 | ['foo', 'bar'], 17 | ['apple', 'Banana'], 18 | ]) 19 | expect(headers.has('FOO')).toBeTruthy() 20 | expect(headers.has('other')).toBeFalsy() 21 | }) 22 | 23 | test('map', async () => { 24 | const m = new Map([ 25 | ['foo', 'bar'], 26 | ['APPLE', 'Banana'], 27 | ]) 28 | const headers = new EdgeHeaders(m) 29 | expect([...headers]).toEqual([ 30 | ['foo', 'bar'], 31 | ['apple', 'Banana'], 32 | ]) 33 | }) 34 | 35 | test('other-headers', async () => { 36 | const headers1 = new EdgeHeaders({Foo: 'bar', apple: 'Banana'}) 37 | const headers = new EdgeHeaders(headers1) 38 | expect(Object.fromEntries(headers)).toEqual({foo: 'bar', apple: 'Banana'}) 39 | headers.delete('foo') 40 | expect(Object.fromEntries(headers)).toEqual({apple: 'Banana'}) 41 | expect(Object.fromEntries(headers1)).toEqual({foo: 'bar', apple: 'Banana'}) 42 | }) 43 | 44 | test('array', async () => { 45 | const headers = new EdgeHeaders([ 46 | ['Foo', 'bar'], 47 | ['apple', 'Banana'], 48 | ]) 49 | expect(Object.fromEntries(headers)).toEqual({foo: 'bar', apple: 'Banana'}) 50 | }) 51 | 52 | test('append', async () => { 53 | const headers = new EdgeHeaders({Foo: 'bar', apple: 'Banana'}) 54 | expect(Object.fromEntries(headers)).toEqual({foo: 'bar', apple: 'Banana'}) 55 | headers.append('Spam', 'HAM') 56 | expect(Object.fromEntries(headers)).toEqual({foo: 'bar', apple: 'Banana', spam: 'HAM'}) 57 | headers.append('FOO', 'More') 58 | expect(Object.fromEntries(headers)).toEqual({foo: 'bar,More', apple: 'Banana', spam: 'HAM'}) 59 | }) 60 | 61 | test('delete', async () => { 62 | const headers = new EdgeHeaders({Foo: 'bar', apple: 'Banana'}) 63 | expect(Object.fromEntries(headers)).toEqual({foo: 'bar', apple: 'Banana'}) 64 | headers.delete('Foo') 65 | expect(Object.fromEntries(headers)).toEqual({apple: 'Banana'}) 66 | headers.delete('sniffle') 67 | expect(Object.fromEntries(headers)).toEqual({apple: 'Banana'}) 68 | }) 69 | 70 | test('set', async () => { 71 | const headers = new EdgeHeaders({Foo: 'bar', apple: 'Banana'}) 72 | expect(Object.fromEntries(headers)).toEqual({foo: 'bar', apple: 'Banana'}) 73 | headers.set('Foo', 'changed') 74 | expect(Object.fromEntries(headers)).toEqual({foo: 'changed', apple: 'Banana'}) 75 | headers.set('Sniffle', 'new-value') 76 | expect(Object.fromEntries(headers)).toEqual({foo: 'changed', apple: 'Banana', sniffle: 'new-value'}) 77 | }) 78 | 79 | test('forEach', async () => { 80 | const headers = new EdgeHeaders({Foo: 'bar', apple: 'Banana'}) 81 | const each_items: any[] = [] 82 | headers.forEach((value, key, parent) => { 83 | each_items.push({value, key, parent}) 84 | }) 85 | expect(each_items).toStrictEqual([ 86 | {value: 'bar', key: 'foo', parent: headers}, 87 | {value: 'Banana', key: 'apple', parent: headers}, 88 | ]) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /tests/kv.test.ts: -------------------------------------------------------------------------------- 1 | import {EdgeKVNamespace, EdgeReadableStream} from 'edge-mock' 2 | import {rsFromArray, rsToString} from 'edge-mock/utils' 3 | 4 | describe('EdgeKVNamespace', () => { 5 | test('get-value', async () => { 6 | const kv = new EdgeKVNamespace() 7 | await kv.put('foo', 'Foo Value') 8 | const v = await kv.get('foo') 9 | expect(v).toEqual('Foo Value') 10 | }) 11 | 12 | test('get-missing', async () => { 13 | const kv = new EdgeKVNamespace() 14 | const v = await kv.get('foo') 15 | expect(v).toStrictEqual(null) 16 | }) 17 | 18 | test('get-json', async () => { 19 | const kv = new EdgeKVNamespace() 20 | await kv.put('foo', '{"spam": 123}') 21 | const v = await kv.get('foo', 'json') 22 | expect(v).toStrictEqual({spam: 123}) 23 | }) 24 | 25 | test('get-arrayBuffer', async () => { 26 | const kv = new EdgeKVNamespace() 27 | await kv.put('foo', 'abc') 28 | const v = await kv.get('foo', 'arrayBuffer') 29 | expect(v).toBeInstanceOf(ArrayBuffer) 30 | const array = new Uint8Array([97, 98, 99]) 31 | expect(new Uint8Array(v)).toStrictEqual(array) 32 | }) 33 | 34 | test('get-stream', async () => { 35 | const kv = new EdgeKVNamespace() 36 | await kv.put('foo', 'abc') 37 | const v = await kv.get('foo', 'stream') 38 | expect(v).toBeInstanceOf(EdgeReadableStream) 39 | expect(await rsToString(v)).toEqual('abc') 40 | }) 41 | 42 | test('getWithMetadata-with', async () => { 43 | const kv = new EdgeKVNamespace() 44 | await kv.put('foo', 'abc', {metadata: {m: 'n'}}) 45 | const v = await kv.getWithMetadata('foo') 46 | expect(v).toStrictEqual({value: 'abc', metadata: {m: 'n'}}) 47 | }) 48 | 49 | test('getWithMetadata-without', async () => { 50 | const kv = new EdgeKVNamespace() 51 | await kv.put('foo', 'abc') 52 | const v = await kv.getWithMetadata('foo') 53 | expect(v).toStrictEqual({value: 'abc', metadata: {}}) 54 | }) 55 | 56 | test('getWithMetadata-missing', async () => { 57 | const kv = new EdgeKVNamespace() 58 | await kv.put('foo', 'abc') 59 | const v = await kv.getWithMetadata('missing') 60 | expect(v).toStrictEqual({value: null, metadata: null}) 61 | }) 62 | 63 | test('put', async () => { 64 | const kv = new EdgeKVNamespace() 65 | await kv.put('foo', 'bar') 66 | expect(await kv.getWithMetadata('foo')).toStrictEqual({value: 'bar', metadata: {}}) 67 | }) 68 | 69 | test('put-metadata', async () => { 70 | const kv = new EdgeKVNamespace() 71 | await kv.put('foo', 'bar', {metadata: {apple: 'pear'}}) 72 | expect(await kv.getWithMetadata('foo')).toStrictEqual({value: 'bar', metadata: {apple: 'pear'}}) 73 | }) 74 | 75 | test('put-arrayBuffer', async () => { 76 | const kv = new EdgeKVNamespace() 77 | const array = new Uint8Array([97, 98, 99]) 78 | await kv.put('foo', array.buffer) 79 | expect(await kv.get('foo')).toEqual('abc') 80 | expect(await kv.get('foo')).toEqual('abc') 81 | const v_ab = await kv.get('foo', 'arrayBuffer') 82 | expect(v_ab).toBeInstanceOf(ArrayBuffer) 83 | expect(new Uint8Array(v_ab)).toEqual(array) 84 | }) 85 | 86 | test('put-stream', async () => { 87 | const kv = new EdgeKVNamespace() 88 | const stream = rsFromArray(['a', 'b', 'cde']) 89 | await kv.put('foo', stream) 90 | expect(await kv.get('foo')).toEqual('abcde') 91 | expect(await kv.get('foo')).toEqual('abcde') 92 | }) 93 | 94 | test('_clear', async () => { 95 | const kv = new EdgeKVNamespace() 96 | await kv.put('foo', 'Foo Value') 97 | expect(await kv.get('foo')).toEqual('Foo Value') 98 | kv._clear() 99 | expect(await kv.get('foo')).toStrictEqual(null) 100 | }) 101 | 102 | test('list', async () => { 103 | const kv = new EdgeKVNamespace() 104 | await kv._putMany({foo: 'foobar', bar: 'spam'}) 105 | expect(await kv.get('foo')).toEqual('foobar') 106 | expect(await kv.get('bar')).toEqual('spam') 107 | expect(await kv.list()).toStrictEqual({ 108 | keys: [{name: 'foo'}, {name: 'bar'}], 109 | list_complete: true, 110 | }) 111 | }) 112 | 113 | test('list-cursor', async () => { 114 | const kv = new EdgeKVNamespace() 115 | await expect(kv.list({cursor: 'foobar'})).rejects.toThrow('list cursors not yet implemented') 116 | }) 117 | 118 | test('list-limit', async () => { 119 | const kv = new EdgeKVNamespace() 120 | await kv._putMany({foo: 'foobar', bar: 'spam'}) 121 | expect(await kv.list({limit: 1})).toStrictEqual({ 122 | keys: [{name: 'foo'}], 123 | list_complete: false, 124 | cursor: 'not-fully-implemented', 125 | }) 126 | }) 127 | 128 | test('list-metadata', async () => { 129 | const kv = new EdgeKVNamespace() 130 | await kv._putMany({foo: 'foobar', bar: {value: 'spam', metadata: {apple: 'banana'}}}) 131 | expect(await kv.get('foo')).toEqual('foobar') 132 | expect(await kv.get('bar')).toEqual('spam') 133 | expect(await kv.list()).toStrictEqual({ 134 | keys: [{name: 'foo'}, {name: 'bar', metadata: {apple: 'banana'}}], 135 | list_complete: true, 136 | }) 137 | }) 138 | 139 | test('list-prefix', async () => { 140 | const kv = new EdgeKVNamespace() 141 | await kv._putMany({foo: 'foobar', bar: 'spam'}) 142 | expect(await kv.list({prefix: 'f'})).toStrictEqual({ 143 | keys: [{name: 'foo'}], 144 | list_complete: true, 145 | }) 146 | }) 147 | 148 | test('delete', async () => { 149 | const kv = new EdgeKVNamespace() 150 | await kv.put('foo', 'foobar') 151 | expect(await kv.get('foo')).toEqual('foobar') 152 | await kv.delete('foo') 153 | expect(await kv.get('foo')).toStrictEqual(null) 154 | }) 155 | 156 | test('_add_files', async () => { 157 | const kv = new EdgeKVNamespace() 158 | const count = await kv._add_files('.github/') 159 | expect(count).toEqual(1) 160 | expect(await kv.list()).toStrictEqual({ 161 | keys: [{name: 'workflows/ci.yml'}], 162 | list_complete: true, 163 | }) 164 | const content = await kv.get('workflows/ci.yml') 165 | expect(content).toMatch(/^name: ci/) 166 | const content_ab = await kv.get('workflows/ci.yml', 'arrayBuffer') 167 | expect(content_ab).toBeInstanceOf(ArrayBuffer) 168 | expect(new Uint8Array(content_ab)[0]).toEqual(110) 169 | 170 | expect(kv._manifestJson()).toEqual('{"workflows/ci.yml":"workflows/ci.yml"}') 171 | }) 172 | 173 | test('_add_files-error-file', async () => { 174 | const kv = new EdgeKVNamespace() 175 | await expect(kv._add_files('package.json')).rejects.toThrow('"package.json" is not a directory') 176 | }) 177 | 178 | test('_add_files-error-missing', async () => { 179 | const kv = new EdgeKVNamespace() 180 | await expect(kv._add_files('does/not/exist')).rejects.toThrow( 181 | "ENOENT: no such file or directory, stat 'does/not/exist'", 182 | ) 183 | }) 184 | }) 185 | -------------------------------------------------------------------------------- /tests/readable-stream.test.ts: -------------------------------------------------------------------------------- 1 | import {EdgeReadableStream} from 'edge-mock' 2 | import {rsFromArray, rsToArrayBufferView, rsToString} from 'edge-mock/utils' 3 | 4 | describe('EdgeReadableStream', () => { 5 | test('basic-string', async () => { 6 | const iterator = ['foo', 'bar'][Symbol.iterator]() 7 | const stream = new EdgeReadableStream({ 8 | async pull(controller) { 9 | const {value, done} = await iterator.next() 10 | 11 | if (done) { 12 | controller.close() 13 | } else { 14 | controller.enqueue(value) 15 | } 16 | }, 17 | }) 18 | 19 | expect(stream.locked).toBeFalsy() 20 | const reader = stream.getReader() 21 | expect(stream.locked).toBeTruthy() 22 | expect(Object.getPrototypeOf(reader).constructor.name).toEqual('EdgeReadableStreamDefaultReader') 23 | 24 | let closed = false 25 | 26 | reader.closed.then(() => { 27 | closed = true 28 | }) 29 | expect(closed).toBeFalsy() 30 | 31 | expect(await reader.read()).toStrictEqual({done: false, value: 'foo'}) 32 | expect(await reader.read()).toStrictEqual({done: false, value: 'bar'}) 33 | expect(closed).toBeFalsy() 34 | expect(await reader.read()).toStrictEqual({done: true, value: undefined}) 35 | expect(closed).toBeTruthy() 36 | }) 37 | 38 | test('get_reader-uint', async () => { 39 | const stream = rsFromArray([new Uint8Array([97, 98]), new Uint8Array([100, 101])]) 40 | expect(stream).toBeInstanceOf(EdgeReadableStream) 41 | const reader = stream.getReader() 42 | 43 | expect(await reader.read()).toStrictEqual({done: false, value: new Uint8Array([97, 98])}) 44 | expect(await reader.read()).toStrictEqual({done: false, value: new Uint8Array([100, 101])}) 45 | expect(await reader.read()).toStrictEqual({done: true, value: undefined}) 46 | }) 47 | 48 | test('releaseLock', async () => { 49 | const stream = new EdgeReadableStream({}) 50 | expect(stream.locked).toBeFalsy() 51 | const reader = stream.getReader() 52 | expect(stream.locked).toBeTruthy() 53 | reader.releaseLock() 54 | expect(stream.locked).toBeFalsy() 55 | }) 56 | 57 | test('get_reader-twice', async () => { 58 | const stream = new EdgeReadableStream({}) 59 | stream.getReader() 60 | expect(() => stream.getReader()).toThrow('ReadableStream already locked') 61 | }) 62 | 63 | test('cancel', async () => { 64 | const stream = rsFromArray(['foo', 'bar']) 65 | const reader = stream.getReader() 66 | 67 | let cancelled = false 68 | reader.cancel('foobar').then(() => { 69 | cancelled = true 70 | }) 71 | expect(cancelled).toBeFalsy() 72 | 73 | expect(await reader.read()).toStrictEqual({done: true, value: undefined}) 74 | expect(cancelled).toBeTruthy() 75 | }) 76 | 77 | test('ArrayBuffer', async () => { 78 | const stream = rsFromArray([new Uint8Array([100, 101]), new Uint8Array([102, 103])]) 79 | expect(await rsToArrayBufferView(stream)).toEqual(new Uint8Array([100, 101, 102, 103])) 80 | }) 81 | 82 | test('wrong-type', async () => { 83 | const a = new Date() as any 84 | const stream = rsFromArray([a]) 85 | await expect(rsToArrayBufferView(stream)).rejects.toThrow( 86 | 'Unexpected type "Date", expected string, ArrayBuffer or Uint8Array', 87 | ) 88 | }) 89 | 90 | test('tee', async () => { 91 | const stream = rsFromArray(['foo', 'bar']) 92 | const [s1, s2] = stream.tee() 93 | expect(stream.locked).toBeTruthy() 94 | expect(s1.locked).toBeFalsy() 95 | expect(await rsToString(s1)).toEqual('foobar') 96 | expect(await rsToString(s2)).toEqual('foobar') 97 | }) 98 | 99 | test('tee-reverse', async () => { 100 | const stream = rsFromArray(['foo', 'bar']) 101 | const [s1, s2] = stream.tee() 102 | expect(await rsToString(s2)).toEqual('foobar') 103 | expect(await rsToString(s1)).toEqual('foobar') 104 | }) 105 | 106 | test('tee-cancel', async () => { 107 | const iterator = ['foo', 'bar', 'spam'][Symbol.iterator]() 108 | let cancelled = false 109 | const stream = new EdgeReadableStream({ 110 | async pull(controller) { 111 | const {value, done} = await iterator.next() 112 | if (done) { 113 | controller.close() 114 | } else { 115 | controller.enqueue(value) 116 | } 117 | }, 118 | cancel() { 119 | cancelled = true 120 | }, 121 | }) 122 | 123 | const [s1, s2] = stream.tee() 124 | const r1 = s1.getReader() 125 | expect(await r1.read()).toStrictEqual({done: false, value: 'foo'}) 126 | expect(cancelled).toBeFalsy() 127 | const cancel_promise = s1.cancel() 128 | expect(await r1.read()).toStrictEqual({done: true, value: undefined}) 129 | expect(cancelled).toBeTruthy() 130 | expect(await cancel_promise).toBeUndefined() 131 | const r2 = s2.getReader() 132 | expect(await r2.read()).toStrictEqual({done: false, value: 'foo'}) 133 | expect(await r2.read()).toStrictEqual({done: true, value: undefined}) 134 | }) 135 | 136 | test('controller-cancel', async () => { 137 | const iterator = ['foo', 'bar', 'spam'][Symbol.iterator]() 138 | const stream = new EdgeReadableStream({ 139 | async pull(controller) { 140 | const {value, done} = await iterator.next() 141 | 142 | if (done) { 143 | controller.close() 144 | } else { 145 | if (value == 'bar') { 146 | controller.error(new Error('this is an error')) 147 | } 148 | controller.enqueue(value) 149 | } 150 | }, 151 | }) 152 | 153 | const reader = stream.getReader() 154 | 155 | await expect(reader.read()).resolves.toStrictEqual({done: false, value: 'foo'}) 156 | await expect(reader.read()).resolves.toStrictEqual({done: false, value: 'bar'}) 157 | await expect(reader.read()).rejects.toThrow('this is an error') 158 | }) 159 | 160 | test('source-type', async () => { 161 | const s = {type: 'bytes'} as any 162 | expect(() => new EdgeReadableStream(s)).toThrow('UnderlyingSource.type is not yet supported') 163 | }) 164 | 165 | test('reader-type', async () => { 166 | const stream = new EdgeReadableStream() 167 | expect(() => stream.getReader({mode: 'byob'})).toThrow('ReadableStream modes other than default are not supported') 168 | }) 169 | 170 | test('pipeThrough', async () => { 171 | expect(new EdgeReadableStream({}).pipeThrough).toThrow('pipeThrough not yet implemented') 172 | }) 173 | 174 | test('pipeTo', async () => { 175 | expect(new EdgeReadableStream({}).pipeTo).toThrow('pipeTo not yet implemented') 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /tests/request.test.ts: -------------------------------------------------------------------------------- 1 | import {EdgeFile, EdgeFormData, EdgeReadableStream, EdgeRequest} from 'edge-mock' 2 | import {encode, rsToArrayBufferView} from 'edge-mock/utils' 3 | 4 | describe('EdgeRequest', () => { 5 | test('construct', async () => { 6 | const request = new EdgeRequest('/bar/', {method: 'GET'}) 7 | expect(request.method).toEqual('GET') 8 | expect(request.url).toEqual('https://example.com/bar/') 9 | expect(Object.fromEntries(request.headers.entries())).toStrictEqual({ 10 | accept: '*/*', 11 | }) 12 | expect(request.cf.colo).toEqual('EWR') 13 | }) 14 | 15 | test('body-buffer', async () => { 16 | const body = new Uint8Array([100, 101, 102]) 17 | const request = new EdgeRequest('https://www.example.com', {method: 'POST', body: body.buffer}) 18 | expect(await request.text()).toEqual('def') 19 | }) 20 | 21 | test('stream', async () => { 22 | const body = new Uint8Array([100, 101, 102]) 23 | const request = new EdgeRequest('https://www.example.com', {method: 'POST', body: body.buffer}) 24 | expect(request.bodyUsed).toStrictEqual(false) 25 | const stream = request.body 26 | expect(stream).toBeInstanceOf(EdgeReadableStream) 27 | expect(request.bodyUsed).toStrictEqual(false) 28 | const buffer_view = await rsToArrayBufferView(stream as ReadableStream) 29 | expect(request.bodyUsed).toStrictEqual(true) 30 | expect(buffer_view).toEqual(body) 31 | }) 32 | 33 | test('from-request', async () => { 34 | const r1 = new EdgeRequest('https://www.example.com', {method: 'POST', body: 'test'}) 35 | const r2 = new EdgeRequest(r1) 36 | expect(r2.method).toEqual('POST') 37 | expect(await r2.text()).toEqual('test') 38 | }) 39 | 40 | test('bad-method', async () => { 41 | const init = {method: 'FOOBAR'} as any 42 | expect(() => new EdgeRequest('/', init)).toThrow('"FOOBAR" is not a valid request method') 43 | }) 44 | 45 | test('signal', async () => { 46 | const r1 = new EdgeRequest('https://www.example.com', {method: 'POST', body: 'test'}) 47 | expect(() => r1.signal).toThrow('signal not yet implemented') 48 | }) 49 | 50 | test('clone', async () => { 51 | const init = {method: 'POST', body: 'test', cf: {colo: 'ABC'}} as any 52 | const r1 = new EdgeRequest('https://www.example.com', init) 53 | expect(r1.cf.colo).toEqual('ABC') 54 | const r2 = r1.clone() 55 | expect(r2.method).toEqual('POST') 56 | expect(await r2.text()).toEqual('test') 57 | expect(r2.cf.colo).toEqual('ABC') 58 | }) 59 | 60 | test('get-body', async () => { 61 | const init = {method: 'FOOBAR'} as any 62 | expect(() => new EdgeRequest('/', {body: 'xx'})).toThrow('Request with GET/HEAD method cannot have body.') 63 | }) 64 | 65 | test('FormData', async () => { 66 | const body = new EdgeFormData() 67 | body.append('foo', 'bar') 68 | body.append('foo', 'bat') 69 | 70 | const request = new EdgeRequest('https://www.example.com', {method: 'POST', body}) 71 | expect([...(await request.formData())]).toStrictEqual([ 72 | ['foo', 'bar'], 73 | ['foo', 'bat'], 74 | ]) 75 | }) 76 | 77 | test('FormData-body', async () => { 78 | const body = new EdgeFormData() 79 | body.append('foo', 'bar') 80 | body.append('foo', 'bat') 81 | 82 | const r1 = new EdgeRequest('https://www.example.com', {method: 'POST', body}) 83 | expect(r1.headers.get('content-type')).toMatch(/multipart\/form-data; boundary=\S+/) 84 | const boundary = (r1.headers.get('content-type') as string).match(/boundary=(\S+)/) 85 | expect(boundary).not.toBeNull() 86 | const f = await r1.text() 87 | expect(f.startsWith(`--${(boundary as RegExpExecArray)[1]}`)) 88 | }) 89 | 90 | test('FormData-raw', async () => { 91 | const raw_body = ` 92 | --1d1ea31edf6ccb39794b748ce125e269 93 | Content-Disposition: form-data; name="foo" 94 | 95 | bar 96 | --1d1ea31edf6ccb39794b748ce125e269 97 | Content-Disposition: form-data; name="filekey"; filename="file.txt" 98 | Content-Type: text/plain 99 | 100 | file content 101 | --1d1ea31edf6ccb39794b748ce125e269--` 102 | const body = encode(raw_body.replace(/\r?\n/g, '\r\n')) 103 | const headers = {'Content-Type': 'multipart/form-data; boundary=1d1ea31edf6ccb39794b748ce125e269'} 104 | 105 | const request = new EdgeRequest('/', {method: 'POST', body, headers}) 106 | 107 | const fd = await request.formData() 108 | expect([...fd.keys()]).toEqual(['foo', 'filekey']) 109 | expect(fd.get('foo')).toEqual('bar') 110 | const file = fd.get('filekey') as EdgeFile 111 | expect(file).toBeInstanceOf(EdgeFile) 112 | expect(file.name).toEqual('file.txt') 113 | expect(file.type).toEqual('text/plain') 114 | expect(await file.text()).toEqual('file content') 115 | }) 116 | 117 | test('clone-FormData', async () => { 118 | const body = new EdgeFormData() 119 | body.append('foo', 'bar') 120 | body.append('foo', 'bat') 121 | 122 | const r1 = new EdgeRequest('https://www.example.com', {method: 'POST', body}) 123 | 124 | const r2 = r1.clone() 125 | expect(r2.method).toEqual('POST') 126 | expect([...(await r2.formData())]).toStrictEqual([ 127 | ['foo', 'bar'], 128 | ['foo', 'bat'], 129 | ]) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /tests/response.test.ts: -------------------------------------------------------------------------------- 1 | import {EdgeResponse, EdgeReadableStream, EdgeBlob, EdgeFormData, EdgeFile} from 'edge-mock' 2 | import {rsFromArray, rsToString} from 'edge-mock/utils' 3 | 4 | describe('EdgeResponse', () => { 5 | test('string', async () => { 6 | const response = new EdgeResponse('abc') 7 | expect(response.status).toStrictEqual(200) 8 | expect(response.headers.get('content-type')).toEqual('text/plain') 9 | expect(response.statusText).toStrictEqual('') 10 | expect(response.type).toStrictEqual('default') 11 | expect(response.bodyUsed).toStrictEqual(false) 12 | expect(await response.text()).toEqual('abc') 13 | expect(response.bodyUsed).toStrictEqual(true) 14 | }) 15 | 16 | test('string-arrayBuffer', async () => { 17 | const response = new EdgeResponse('abc') 18 | expect(response.bodyUsed).toStrictEqual(false) 19 | const buffer = await response.arrayBuffer() 20 | expect(new Uint8Array(buffer)).toEqual(new Uint8Array([97, 98, 99])) 21 | expect(response.bodyUsed).toStrictEqual(true) 22 | }) 23 | 24 | test('blob-string', async () => { 25 | const blob = new EdgeBlob([new Uint8Array([97, 98, 99])]) 26 | const response = new EdgeResponse(blob) 27 | expect(await response.text()).toEqual('abc') 28 | expect(response.headers.get('content-type')).toEqual(null) 29 | }) 30 | 31 | test('blob-arrayBuffer', async () => { 32 | const uint = new Uint8Array([97, 98, 99]) 33 | const blob = new EdgeBlob([uint]) 34 | const response = new EdgeResponse(blob) 35 | const buffer = await response.arrayBuffer() 36 | expect(new Uint8Array(buffer)).toEqual(uint) 37 | }) 38 | 39 | test('null-string', async () => { 40 | const response = new EdgeResponse(null) 41 | expect(await response.text()).toEqual('') 42 | }) 43 | 44 | test('stream-string', async () => { 45 | const chunks = [new Uint8Array([97, 98]), new Uint8Array([100, 101])] 46 | const stream = rsFromArray(chunks) 47 | const response = new EdgeResponse(stream) 48 | expect(await response.text()).toEqual('abde') 49 | }) 50 | 51 | test('stream-array-buffer', async () => { 52 | const chunks = [new Uint8Array([97, 98]), new Uint8Array([100, 101])] 53 | const stream = rsFromArray(chunks) 54 | const response = new EdgeResponse(stream) 55 | const buffer = await response.arrayBuffer() 56 | expect(new Uint8Array(buffer)).toEqual(new Uint8Array([97, 98, 100, 101])) 57 | }) 58 | 59 | test('invalid-body', async () => { 60 | const d = new Date() as any 61 | const r = new EdgeResponse(d) 62 | await expect(r.text()).rejects.toThrow('Dates are not supported as body types') 63 | }) 64 | 65 | test('body', async () => { 66 | const response = new EdgeResponse('abc') 67 | expect(response.bodyUsed).toStrictEqual(false) 68 | const body = response.body 69 | expect(body).toBeInstanceOf(EdgeReadableStream) 70 | expect(response.bodyUsed).toStrictEqual(false) 71 | expect(await rsToString(body as ReadableStream)).toEqual('abc') 72 | expect(response.bodyUsed).toStrictEqual(true) 73 | }) 74 | 75 | test('no-body', async () => { 76 | const response = new EdgeResponse() 77 | expect(response.bodyUsed).toStrictEqual(false) 78 | expect(response.body).toStrictEqual(null) 79 | expect(response.bodyUsed).toStrictEqual(false) 80 | }) 81 | 82 | test('blob', async () => { 83 | const response = new EdgeResponse('abc') 84 | expect(response.bodyUsed).toStrictEqual(false) 85 | const blob = await response.blob() 86 | expect(blob).toBeInstanceOf(EdgeBlob) 87 | expect(response.bodyUsed).toStrictEqual(true) 88 | expect(await blob.text()).toEqual('abc') 89 | }) 90 | 91 | test('blob-no-body', async () => { 92 | const response = new EdgeResponse() 93 | expect(response.bodyUsed).toStrictEqual(false) 94 | const blob = await response.blob() 95 | expect(blob).toBeInstanceOf(EdgeBlob) 96 | expect(response.bodyUsed).toStrictEqual(false) 97 | expect(await blob.text()).toEqual('') 98 | }) 99 | 100 | test('json', async () => { 101 | const response = new EdgeResponse('{"foo": 123}') 102 | expect(response.bodyUsed).toStrictEqual(false) 103 | expect(await response.json()).toStrictEqual({foo: 123}) 104 | expect(response.bodyUsed).toStrictEqual(true) 105 | }) 106 | 107 | test('json-no-body', async () => { 108 | const response = new EdgeResponse() 109 | expect(response.bodyUsed).toStrictEqual(false) 110 | await expect(response.json()).rejects.toThrow('Unexpected end of JSON input') 111 | expect(response.bodyUsed).toStrictEqual(false) 112 | }) 113 | 114 | test('arrayBuffer', async () => { 115 | const response = new EdgeResponse('abc') 116 | expect(response.bodyUsed).toStrictEqual(false) 117 | expect(new Uint8Array(await response.arrayBuffer())).toStrictEqual(new Uint8Array([97, 98, 99])) 118 | expect(response.bodyUsed).toStrictEqual(true) 119 | }) 120 | 121 | test('arrayBuffer-no-body', async () => { 122 | const response = new EdgeResponse() 123 | expect(response.bodyUsed).toStrictEqual(false) 124 | expect(new Uint8Array(await response.arrayBuffer())).toStrictEqual(new Uint8Array([])) 125 | expect(response.bodyUsed).toStrictEqual(false) 126 | }) 127 | 128 | test('formData', async () => { 129 | const f = new EdgeFormData() 130 | f.append('a', 'b') 131 | f.append('c', 'd') 132 | const response = new EdgeResponse(f) 133 | expect(response.headers.get('content-type')).toMatch(/^multipart\/form-data; boundary=/) 134 | expect([...(await response.formData())]).toStrictEqual([ 135 | ['a', 'b'], 136 | ['c', 'd'], 137 | ]) 138 | }) 139 | 140 | test('formData-not-available', async () => { 141 | const response = new EdgeResponse() 142 | await expect(response.formData()).rejects.toThrow('unable to parse form data, invalid content-type header') 143 | }) 144 | 145 | test('trailer', async () => { 146 | const response = new EdgeResponse() 147 | const t = async () => await response.trailer 148 | await expect(t()).rejects.toThrow('trailer not yet implemented') 149 | }) 150 | 151 | test('redirect', async () => { 152 | const response = EdgeResponse.redirect('https://www.example.com') 153 | expect(response.status).toBe(302) 154 | expect(response.headers.get('location')).toBe('https://www.example.com/') 155 | }) 156 | 157 | test('redirect-invalid', async () => { 158 | const t = () => EdgeResponse.redirect('https://www.example.com', 200) 159 | expect(t).toThrow(new RangeError('Invalid status code')) 160 | }) 161 | 162 | test('error', async () => { 163 | const r = EdgeResponse.error() 164 | expect(r.status).toBe(0) 165 | expect(r.body).toBeNull() 166 | }) 167 | 168 | test('clone', async () => { 169 | const r1 = new EdgeResponse('foobar', {status: 404}) 170 | const r2 = r1.clone() 171 | expect(r1.status).toBe(404) 172 | expect(r1.statusText).toStrictEqual('') 173 | expect(r2.status).toBe(404) 174 | expect(r1.body).not.toBeNull() 175 | expect(await r1.text()).toBe('foobar') 176 | expect(r2.body).not.toBeNull() 177 | expect(await r2.text()).toBe('foobar') 178 | }) 179 | 180 | test('clone-no-body', async () => { 181 | const r1 = new EdgeResponse(undefined, {status: 405}) 182 | const r2 = r1.clone() 183 | expect(r1.status).toBe(405) 184 | expect(r2.status).toBe(405) 185 | expect(r1.body).toBeNull() 186 | expect(await r1.text()).toBe('') 187 | expect(r2.body).toBeNull() 188 | expect(await r2.text()).toBe('') 189 | }) 190 | 191 | test('clone-body-used', async () => { 192 | const r1 = new EdgeResponse('foobar', {status: 405}) 193 | await r1.text() 194 | const t = () => r1.clone() 195 | expect(t).toThrow(new TypeError('Response body is already used')) 196 | }) 197 | 198 | test('body-already-used', async () => { 199 | const response = new EdgeResponse('abc') 200 | expect(await response.text()).toEqual('abc') 201 | await expect(response.json()).rejects.toThrow('Failed to execute "json": body is already used') 202 | }) 203 | 204 | test('Uint8Array', async () => { 205 | const response = new EdgeResponse(new Uint8Array([120, 121, 122])) 206 | expect(await response.text()).toEqual('xyz') 207 | }) 208 | 209 | test('ReadableStream', async () => { 210 | const stream = rsFromArray([new Uint8Array([120, 121]), new Uint8Array([122])]) 211 | const response = new EdgeResponse(stream) 212 | expect(await response.text()).toEqual('xyz') 213 | }) 214 | 215 | test('URLSearchParams', async () => { 216 | const searchParams = new URLSearchParams('foo=1&foo=2&bar=345') 217 | const response = new EdgeResponse(searchParams) 218 | expect(await response.text()).toEqual('foo=1&foo=2&bar=345') 219 | }) 220 | 221 | test('form-response', async () => { 222 | const body = new EdgeFormData() 223 | const file = new EdgeFile(['this is content'], 'foobar.txt') 224 | body.append('foo', file) 225 | body.append('spam', 'ham') 226 | 227 | const response = new EdgeResponse(body) 228 | const text = await response.text() 229 | expect(text).toMatch(/Content-Disposition: form-data; name="foo"; filename="foobar.txt"\r\n/) 230 | }) 231 | }) 232 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import each from 'jest-each' 2 | import {EdgeBlob, EdgeRequest} from 'edge-mock' 3 | import {getType} from 'edge-mock/utils' 4 | 5 | interface TestType { 6 | input: any 7 | expected: string 8 | } 9 | const types: TestType[] = [ 10 | {input: null, expected: 'Null'}, 11 | {input: undefined, expected: 'Undefined'}, 12 | {input: false, expected: 'Boolean'}, 13 | {input: 123, expected: 'Number'}, 14 | {input: '123', expected: 'String'}, 15 | {input: [1, 2, 3], expected: 'Array'}, 16 | {input: new EdgeBlob(['a']), expected: 'EdgeBlob'}, 17 | {input: new EdgeRequest(''), expected: 'EdgeRequest'}, 18 | ] 19 | 20 | describe('getType', () => { 21 | each(types).test('types %p', async ({input, expected}: TestType) => { 22 | expect(getType(input)).toEqual(expected) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": ".", 4 | "module": "commonjs", 5 | "target": "esnext", 6 | "lib": ["esnext", "webworker"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "declaration": true, 14 | "baseUrl": "src", 15 | "paths": { 16 | "edge-mock": [""], 17 | "edge-mock/*": ["*"] 18 | }, 19 | "types": ["@cloudflare/workers-types", "@types/jest", "node"] 20 | }, 21 | "include": ["src", "tests"], 22 | "exclude": ["node_modules", "lib"] 23 | } 24 | --------------------------------------------------------------------------------