Repository: wheresrhys/fetch-mock
Pull request #687 (2024-07-14 14:41:57)

Rhys/core package
Creates a new @fetch-mock/core package that contains the mock implementation of fetch but doesn't actually replace fetch


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 05a0927..4853c51 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -58,31 +58,31 @@ jobs:
       - *workspace
       - run: npm run lint:ci
 
-  typecheck:
+  typelint:
     <<: *nodelts
     steps:
       - *workspace
-      - run: npm run lint:types
+      - run: npm run types:lint
 
   test:
     <<: *nodelts
     steps:
       - *workspace
-      - run: npm run test:coverage
+      - run: npm run test:ci
 
   nodefetch3:
     <<: *nodelts
     steps:
       - *workspace
       - run: npm install node-fetch@3
-      - run: TESTING_ENV=node-fetch npm run test
+      - run: TESTING_ENV=node-fetch npm run test:legacy
 
   commonjs:
     <<: *nodelts
     steps:
       - *workspace
       - run: npm run build
-      - run: TESTING_ENV=commonjs npm run test
+      - run: TESTING_ENV=commonjs npm run test:legacy
   jest:
     <<: *nodelts
     steps:
@@ -134,7 +134,7 @@ workflows:
       # could be parallel with build, lint, and unit but it's a slow job
       # And circlecifree tier only has 3 concurrent jobs, so overall faster
       # to defer
-      - typecheck:
+      - typelint:
           <<: *triggerable-by-tag
           <<: *run-after-first-jobs
       - nodefetch3:
@@ -158,7 +158,7 @@ workflows:
             # - chrome
             # - firefox
             - build
-            - typecheck
+            - typelint
             - commonjs
             - jest
           filters:
diff --git a/.eslintignore b/.eslintignore
index ffc5569..aedafb7 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,3 +1,4 @@
+packages/standalone/*
 packages/*/test/fixtures/*
 coverage/*
 .nyc_output/
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 9d84d71..6a01728 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -27,5 +27,8 @@ module.exports = {
 	},
 	rules: {
 		'@lwc/lwc/no-async-await': 0,
+		'jsdoc/require-param-description': 0,
+		'jsdoc/require-returns-description': 0,
+		'jsdoc/require-property-description': 0,
 	},
 };
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 8b30036..d8d2cbc 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,4 @@
 {
-  "packages/fetch-mock": "10.0.8-alpha.1"
+  "packages/fetch-mock": "10.0.8",
+  "packages/core": "0.0.0",
 }
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..478664f
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,26 @@
+{
+    "compilerOptions": {
+        "lib": [
+            "es2021",
+            "dom",
+            "dom.iterable"
+        ],
+        "allowJs": true,
+        "checkJs": true,
+        "strict": true,
+        "noImplicitAny": true,
+        "noImplicitThis": true,
+        "strictNullChecks": false,
+        "strictFunctionTypes": true,
+        "types": [        ],
+        "noEmit": true,
+        "downlevelIteration": true,
+        "forceConsistentCasingInFileNames": true
+    },
+    "include": [
+        "packages/core/src/*.js",
+    ],
+    "exclude": [
+        "node_modules"
+    ]
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 4f15a3f..13f33c6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,8 @@
         "@types/node": "^20.14.10",
         "@vitest/browser": "^1.1.0",
         "@vitest/coverage-istanbul": "^1.1.0",
+        "@vitest/coverage-v8": "^1.6.0",
+        "@vitest/ui": "^1.6.0",
         "dtslint": "^1.0.2",
         "eslint": "^8.56.0",
         "eslint-config-airbnb-base": "^15.0.0",
@@ -32,7 +34,9 @@
         "lint-staged": "^15.2.7",
         "prettier": "^3.1.1",
         "rollup": "^4.9.1",
-        "typescript": "^3.6.4",
+        "ts-to-jsdoc": "^2.1.0",
+        "typescript": "^3.9.10",
+        "v8": "^0.1.0",
         "vitest": "^1.1.0",
         "webdriverio": "^8.27.0"
       },
@@ -1441,6 +1445,10 @@
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
+    "node_modules/@fetch-mock/core": {
+      "resolved": "packages/core",
+      "link": true
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.11.14",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@@ -2821,6 +2829,42 @@
       "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
       "dev": true
     },
+    "node_modules/@ts-morph/common": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.22.0.tgz",
+      "integrity": "sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==",
+      "dev": true,
+      "dependencies": {
+        "fast-glob": "^3.3.2",
+        "minimatch": "^9.0.3",
+        "mkdirp": "^3.0.1",
+        "path-browserify": "^1.0.1"
+      }
+    },
+    "node_modules/@ts-morph/common/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/@ts-morph/common/node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3048,6 +3092,33 @@
         "vitest": "1.6.0"
       }
     },
+    "node_modules/@vitest/coverage-v8": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz",
+      "integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==",
+      "dev": true,
+      "dependencies": {
+        "@ampproject/remapping": "^2.2.1",
+        "@bcoe/v8-coverage": "^0.2.3",
+        "debug": "^4.3.4",
+        "istanbul-lib-coverage": "^3.2.2",
+        "istanbul-lib-report": "^3.0.1",
+        "istanbul-lib-source-maps": "^5.0.4",
+        "istanbul-reports": "^3.1.6",
+        "magic-string": "^0.30.5",
+        "magicast": "^0.3.3",
+        "picocolors": "^1.0.0",
+        "std-env": "^3.5.0",
+        "strip-literal": "^2.0.0",
+        "test-exclude": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "vitest": "1.6.0"
+      }
+    },
     "node_modules/@vitest/expect": {
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz",
@@ -3129,6 +3200,27 @@
         "url": "https://opencollective.com/vitest"
       }
     },
+    "node_modules/@vitest/ui": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-1.6.0.tgz",
+      "integrity": "sha512-k3Lyo+ONLOgylctiGovRKy7V4+dIN2yxstX3eY5cWFXH6WP+ooVX79YSyi0GagdTQzLmT43BF27T0s6dOIPBXA==",
+      "dev": true,
+      "dependencies": {
+        "@vitest/utils": "1.6.0",
+        "fast-glob": "^3.3.2",
+        "fflate": "^0.8.1",
+        "flatted": "^3.2.9",
+        "pathe": "^1.1.1",
+        "picocolors": "^1.0.0",
+        "sirv": "^2.0.4"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "vitest": "1.6.0"
+      }
+    },
     "node_modules/@vitest/utils": {
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz",
@@ -3557,6 +3649,12 @@
         "node": ">=14"
       }
     },
+    "node_modules/arg": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+      "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+      "dev": true
+    },
     "node_modules/argparse": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -4555,6 +4653,12 @@
         "node": ">= 0.12.0"
       }
     },
+    "node_modules/code-block-writer": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz",
+      "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==",
+      "dev": true
+    },
     "node_modules/code-point-at": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
@@ -6645,6 +6749,34 @@
       "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
       "dev": true
     },
+    "node_modules/fast-glob": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+      "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/fast-json-stable-stringify": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -6717,6 +6849,12 @@
       "resolved": "packages/fetch-mock",
       "link": true
     },
+    "node_modules/fflate": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+      "dev": true
+    },
     "node_modules/file-entry-cache": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -10457,6 +10595,15 @@
       "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
       "dev": true
     },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/micromatch": {
       "version": "4.0.7",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
@@ -10552,15 +10699,18 @@
       "dev": true
     },
     "node_modules/mkdirp": {
-      "version": "0.5.6",
-      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
-      "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
+      "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
       "dev": true,
-      "dependencies": {
-        "minimist": "^1.2.6"
-      },
       "bin": {
-        "mkdirp": "bin/cmd.js"
+        "mkdirp": "dist/cjs/src/bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
       }
     },
     "node_modules/mkdirp-classic": {
@@ -11237,6 +11387,12 @@
       "integrity": "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw==",
       "dev": true
     },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true
+    },
     "node_modules/path-exists": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
@@ -11661,6 +11817,15 @@
       "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
       "dev": true
     },
+    "node_modules/querystring": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz",
+      "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==",
+      "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+      "engines": {
+        "node": ">=0.4.x"
+      }
+    },
     "node_modules/querystringify": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@@ -13066,6 +13231,29 @@
         "node": ">=18"
       }
     },
+    "node_modules/ts-morph": {
+      "version": "21.0.1",
+      "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-21.0.1.tgz",
+      "integrity": "sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg==",
+      "dev": true,
+      "dependencies": {
+        "@ts-morph/common": "~0.22.0",
+        "code-block-writer": "^12.0.0"
+      }
+    },
+    "node_modules/ts-to-jsdoc": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/ts-to-jsdoc/-/ts-to-jsdoc-2.1.0.tgz",
+      "integrity": "sha512-1c8T+dBpaZDwGDHXMZiCYxotcWj4Q72RylXB30nuoj0NpxrbaVoO2IcKP3ruNpRllh1Nb8LVWtsj4AmMSv7j6Q==",
+      "dev": true,
+      "dependencies": {
+        "arg": "^5.0.1",
+        "ts-morph": "^21.0.1"
+      },
+      "bin": {
+        "ts-to-jsdoc": "bin/ts-to-jsdoc"
+      }
+    },
     "node_modules/tsconfig-paths": {
       "version": "3.15.0",
       "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -13237,6 +13425,18 @@
         "js-yaml": "bin/js-yaml.js"
       }
     },
+    "node_modules/tslint/node_modules/mkdirp": {
+      "version": "0.5.6",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+      "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+      "dev": true,
+      "dependencies": {
+        "minimist": "^1.2.6"
+      },
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      }
+    },
     "node_modules/tslint/node_modules/semver": {
       "version": "5.7.2",
       "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
@@ -13581,6 +13781,15 @@
         "uuid": "bin/uuid"
       }
     },
+    "node_modules/v8": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/v8/-/v8-0.1.0.tgz",
+      "integrity": "sha512-cSrJCQ7WRDkSP8zbIwOO38kLSp1mGmBbx/I0pHdzQROZIMlO+qkiC4deQxg1I7pKguYJNMhMD5g/Nc1muiVyYw==",
+      "dev": true,
+      "bin": {
+        "v8": "bin/v8"
+      }
+    },
     "node_modules/v8-to-istanbul": {
       "version": "9.3.0",
       "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -14595,6 +14804,18 @@
         "node": ">= 14"
       }
     },
+    "packages/core": {
+      "name": "@fetch-mock/core",
+      "version": "0.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "glob-to-regexp": "^0.4.1",
+        "is-subset": "^0.1.1",
+        "lodash.isequal": "^4.5.0",
+        "path-to-regexp": "^2.4.0",
+        "querystring": "^0.2.1"
+      }
+    },
     "packages/fetch-mock": {
       "version": "10.0.8",
       "license": "MIT",
@@ -14608,10 +14829,6 @@
       "engines": {
         "node": ">=4.0.0"
       },
-      "funding": {
-        "type": "charity",
-        "url": "https://www.justgiving.com/refugee-support-europe"
-      },
       "peerDependenciesMeta": {
         "node-fetch": {
           "optional": true
diff --git a/package.json b/package.json
index 0aff53d..bf640e3 100644
--- a/package.json
+++ b/package.json
@@ -22,12 +22,15 @@
   "scripts": {
     "lint": "eslint --cache --fix --ext .js,.cjs . && prettier --cache --write *.md docs/*.md docs/**/*.md",
     "lint:ci": "eslint --ext .js,.cjs . && prettier *.md docs/*.md docs/**/*.md",
-    "lint:types": "dtslint --expectOnly packages/fetch-mock/types",
+    "types:check": "tsc --project ./jsconfig.json",
+    "types:lint": "dtslint --expectOnly packages/fetch-mock/types",
     "prepare": "husky",
     "build": "rollup -c",
     "docs:serve": "cd docs; jekyll serve build --watch",
-    "test": "vitest ./packages/fetch-mock/test/specs",
-    "test:coverage": "vitest run --coverage ./packages/fetch-mock/test/specs",
+    "test:ci": "vitest .",
+    "test:legacy": "vitest ./packages/fetch-mock/test/specs",
+    "test": "vitest --ui .",
+    "test:coverage": "vitest run --coverage ./packages/**/src/__tests__",
     "test:jest": "jest ./packages/fetch-mock/test/framework-compat/jest.spec.js",
     "coverage:send": "cat ./coverage/lcov.info | coveralls"
   },
@@ -39,6 +42,8 @@
     "@types/node": "^20.14.10",
     "@vitest/browser": "^1.1.0",
     "@vitest/coverage-istanbul": "^1.1.0",
+    "@vitest/coverage-v8": "^1.6.0",
+    "@vitest/ui": "^1.6.0",
     "dtslint": "^1.0.2",
     "eslint": "^8.56.0",
     "eslint-config-airbnb-base": "^15.0.0",
@@ -52,7 +57,9 @@
     "lint-staged": "^15.2.7",
     "prettier": "^3.1.1",
     "rollup": "^4.9.1",
-    "typescript": "^3.6.4",
+    "ts-to-jsdoc": "^2.1.0",
+    "typescript": "^3.9.10",
+    "v8": "^0.1.0",
     "vitest": "^1.1.0",
     "webdriverio": "^8.27.0"
   },
diff --git a/packages/ARCHITECTURE.md b/packages/ARCHITECTURE.md
new file mode 100644
index 0000000..51ece78
--- /dev/null
+++ b/packages/ARCHITECTURE.md
@@ -0,0 +1,91 @@
+# Goals
+
+Completely separate the core behaviour from behaviours that other test libraries may have their own ideas about so that
+1. APIs don't have any hard conflicts
+2. Within a given ecosystem there is one way to do something, idiomatic to that ecosystem
+3. When a new flavour of the month testing library comes along, it's easy to add idiomatic support
+
+# Modules
+
+## fetch handler
+- orchestrates building a response and sending it
+- Needs to be provided with a router instance
+- Puts all details of each call in a CallHistory instance if provided, including which route handled it
+
+## Response builder
+
+## Router
+- has a submodule - Route
+- given a request finds (if it can) a matching route
+- Should provide some debugging info
+
+## CallHistory
+- records all fetch calls and provides low level APIs for inspecting them
+- API for matching calls should - with the exceotion of respecting route names - behave identically to the router.
+- Shodl provide some debugging info
+
+## FetchMock
+- Wraps fetch handler, router and inspection together 
+- Allows creating instances
+- Allows setting options
+
+- DOES NOT DO ANY ACTUAL MOCKING!!! Or maybe there is a very explicit .mockGlobal() method (or ios this in @fetch-mock/standalone?)
+
+
+FetchMock.createInstance = function () {
+	const instance = Object.create(FetchMock);
+	instance.router = this.router.clone()
+    instance.calls = this.calls.clone()
+	return instance;
+};
+
+- Where do spy() and pass() live? TBD
+- Note that sandbox is gone - complex to implement and a source of many clashes with other testing libraries
+## @fetch-mock/standalone, @fetch-mock/jest, @fetch-mock/vitest, ...
+
+Wrappers that act as plugins for the testing libraries' own mocking, inspection and lifecycle management APIs
+
+API split
+
+FetchMock
+- config
+- createInstance
+- bindMethods
+- getOption
+- flush
+
+FetchHandler
+- extractBodyThenHandle
+- fetchHandler
+- generateResponse
+- statusTextMap (but probably doesn't need to be public anyway)
+
+Router
+- needsAsyncBodyExtraction
+- execute
+- addMatcher
+- done
+- add/route
+- get/post/...
+- catch
+- compileRoute
+
+CallHistory
+- recordCall
+- filterCalls (private)
+- calls
+- lastCall
+
+Standalone wrapper
+- getNativeFetch
+- called
+- lastUrl
+- lastOptions
+- lastResponse
+- mockGlobal
+- spy??
+- pass??
+- resetBehavior
+- resetHistory
+- restore
+- reset
\ No newline at end of file
diff --git a/packages/README.md b/packages/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000..ca4e917
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,27 @@
+{
+  "name": "@fetch-mock/core",
+  "description": "Utility for creating mock fetch implementation",
+  "exports": "src/index.js",
+  "version": "0.0.0",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/wheresrhys/fetch-mock.git"
+  },
+  "author": "",
+  "license": "ISC",
+  "bugs": {
+    "url": "https://github.com/wheresrhys/fetch-mock/issues"
+  },
+  "homepage": "https://github.com/wheresrhys/fetch-mock#readme",
+  "dependencies": {
+    "glob-to-regexp": "^0.4.1",
+    "is-subset": "^0.1.1",
+    "lodash.isequal": "^4.5.0",
+    "path-to-regexp": "^2.4.0",
+    "querystring": "^0.2.1"
+  }
+}
diff --git a/packages/core/src/CallHistory.js b/packages/core/src/CallHistory.js
new file mode 100644
index 0000000..045f818
--- /dev/null
+++ b/packages/core/src/CallHistory.js
@@ -0,0 +1,210 @@
+//@type-check
+/** @typedef {import('./Route').RouteConfig} RouteConfig */
+/** @typedef {import('./Route').RouteName} RouteName */
+/** @typedef {import('./RequestUtils').NormalizedRequestOptions} NormalizedRequestOptions */
+/** @typedef {import('./Matchers').RouteMatcher} RouteMatcher */
+/** @typedef {import('./FetchMock').FetchMockConfig} FetchMockConfig */
+import { normalizeRequest } from './RequestUtils.js';
+import { isUrlMatcher } from './Matchers.js';
+import Route from './Route.js';
+
+/**
+ * @typedef CallLog
+ * @property {string} url
+ * @property {NormalizedRequestOptions} options
+ * @property {Request} [request]
+ * @property {Route} [route]
+ * @property {Response} [response]
+ * @property {Promise<any>[]} pendingPromises
+ */
+
+/** @typedef {'matched'} Matched */
+/** @typedef {'unmatched'} Unmatched */
+/** @typedef  {RouteName | Matched| Unmatched| boolean | RouteMatcher } CallHistoryFilter*/
+
+/**
+ *
+ * @param {CallHistoryFilter} filter
+ * @returns {filter is RouteName}
+ */
+const isName = (filter) =>
+	typeof filter === 'string' &&
+	/^[\da-zA-Z\-]+$/.test(filter) &&
+	!['matched', 'unmatched'].includes(filter);
+
+/**
+ *
+ * @param {CallHistoryFilter} filter
+ * @returns {filter is (Matched| Unmatched| boolean)}
+ */
+const isMatchedOrUnmatched = (filter) =>
+	typeof filter === 'boolean' ||
+	/** @type {CallHistoryFilter[]}*/ (['matched', 'unmatched']).includes(filter);
+
+class CallHistory {
+	/**
+	 * @param {FetchMockConfig} globalConfig
+	 */
+	constructor(globalConfig) {
+		/** @type {CallLog[]} */
+		this.callLogs = [];
+		this.config = globalConfig;
+	}
+	/**
+	 *
+	 * @param {CallLog} callLog
+	 */
+	recordCall(callLog) {
+		this.callLogs.push(callLog);
+	}
+
+	clear() {
+		this.callLogs.forEach(({ route }) => route.reset());
+		this.callLogs = [];
+	}
+
+	/**
+	 *
+	 * @param {boolean} [waitForResponseMethods]
+	 * @returns {Promise<void>}
+	 */
+	async flush(waitForResponseMethods) {
+		const queuedPromises = this.callLogs.flatMap(
+			(call) => call.pendingPromises,
+		);
+		await Promise.allSettled(queuedPromises);
+		if (waitForResponseMethods) {
+			// forces an extra tick, which is needed to ensure that flush doesn't resolve
+			// before all the complicated promises we set up in the proxy that wraps all
+			// the response body methods
+			await Promise.resolve();
+			await this.flush();
+		}
+	}
+
+	/**
+	 *
+	 * @param {CallHistoryFilter} filter
+	 * @param {RouteConfig} options
+	 * @returns {CallLog[]}
+	 */
+	calls(filter, options) {
+		let calls = [...this.callLogs];
+		if (typeof filter === 'undefined' && !options) {
+			return calls;
+		}
+
+		if (isMatchedOrUnmatched(filter)) {
+			if (
+				/** @type {CallHistoryFilter[]} */ ([true, 'matched']).includes(filter)
+			) {
+				calls = calls.filter(({ route }) => !route.config.isFallback);
+			} else if (
+				/** @type {CallHistoryFilter[]} */ ([false, 'unmatched']).includes(
+					filter,
+				)
+			) {
+				calls = calls.filter(({ route }) => Boolean(route.config.isFallback));
+			}
+
+			if (!options) {
+				return calls;
+			}
+		} else if (isName(filter)) {
+			calls = calls.filter(
+				({
+					route: {
+						config: { name },
+					},
+				}) => name === filter,
+			);
+			if (!options) {
+				return calls;
+			}
+		} else {
+			if (isUrlMatcher(filter)) {
+				options = { matcher: filter, ...(options || {}) };
+			} else {
+				options = { ...filter, ...(options || {}) };
+			}
+		}
+
+		const { matcher } = new Route({
+			response: 'ok',
+			...options,
+		});
+
+		calls = calls.filter(({ url, options }) => {
+			const {
+				url: normalizedUrl,
+				options: normalizedOptions,
+				request,
+			} = normalizeRequest(url, options, this.config.Request);
+			return matcher(normalizedUrl, normalizedOptions, request);
+		});
+
+		return calls;
+	}
+	/**
+	 *
+	 * @param {CallHistoryFilter} filter
+	 * @param {RouteConfig} options
+	 * @returns {boolean}
+	 */
+	called(filter, options) {
+		return Boolean(this.calls(filter, options).length);
+	}
+	/**
+	 *
+	 * @param {CallHistoryFilter} filter
+	 * @param {RouteConfig} options
+	 * @returns {CallLog}
+	 */
+	lastCall(filter, options) {
+		return this.calls(filter, options).pop();
+	}
+	/**
+	 *
+	 * @param {RouteName[]} [routeNames]
+	 * @param {Route[]} allRoutes
+	 * @returns {boolean}
+	 */
+	done(allRoutes, routeNames) {
+		const routesToCheck = routeNames
+			? allRoutes.filter(({ config: { name } }) => routeNames.includes(name))
+			: allRoutes;
+
+		// TODO when checking all routes needs to check against all calls
+		// Can't use array.every because would exit after first failure, which would
+		// break the logging
+		return routesToCheck
+			.map(
+				/** @type {function(Route):boolean}*/ (route) => {
+					const calls = this.callLogs.filter(
+						({ route: routeApplied }) => routeApplied === route,
+					);
+					if (!calls.length) {
+						console.warn(`Warning: ${route.config.name} not called`); // eslint-disable-line
+						return false;
+					}
+
+					const expectedTimes = route.config.repeat;
+
+					if (!expectedTimes) {
+						return true;
+					}
+					const actualTimes = calls.length;
+
+					if (expectedTimes > actualTimes) {
+						console.warn(
+							`Warning: ${route.config.name} only called ${actualTimes} times, but ${expectedTimes} expected`,
+						); // eslint-disable-line
+						return false;
+					}
+					return true;
+				},
+			)
+			.every((isDone) => isDone);
+	}
+}
+export default CallHistory;
diff --git a/packages/core/src/FetchMock.js b/packages/core/src/FetchMock.js
new file mode 100644
index 0000000..76feff8
--- /dev/null
+++ b/packages/core/src/FetchMock.js
@@ -0,0 +1,239 @@
+//@type-check
+import Router from './Router.js';
+import Route from './Route.js';
+import CallHistory from './CallHistory.js';
+import * as requestUtils from './RequestUtils.js';
+/** @typedef {import('./Router').RouteMatcher} RouteMatcher */
+/** @typedef {import('./Route').RouteName} RouteName */
+/** @typedef {import('./Route').UserRouteConfig} UserRouteConfig */
+/** @typedef {import('./Router').RouteResponse} RouteResponse */
+/** @typedef {import('./Matchers').MatcherDefinition} MatcherDefinition */
+/** @typedef {import('./CallHistory').CallLog} CallLog */
+/** @typedef {import('./Route').RouteResponseFunction} RouteResponseFunction */
+
+/**
+ * @typedef FetchMockConfig
+ * @property {boolean} [sendAsJson]
+ * @property {boolean} [includeContentLength]
+ * @property {boolean} [warnOnFallback]
+ * @property {boolean} [matchPartialBody]
+ * @property {function(string | Request, RequestInit): Promise<Response>} [fetch]
+ * @property {typeof Headers} [Headers]
+ * @property {typeof Request} [Request]
+ * @property {typeof Response} [Response]
+ */
+
+/** @type {FetchMockConfig} */
+const defaultConfig = {
+	includeContentLength: true,
+	sendAsJson: true,
+	warnOnFallback: true,
+	matchPartialBody: false,
+	Request: globalThis.Request,
+	Response: globalThis.Response,
+	Headers: globalThis.Headers,
+	fetch: globalThis.fetch,
+};
+/**
+ * @typedef FetchMockCore
+ * @this {FetchMock}
+ * @property {FetchMockConfig} config
+ * @property {Router} router
+ * @property {CallHistory} callHistory
+ * @property {function():FetchMock} createInstance
+ * @property {function(string | Request, RequestInit): Promise<Response>} fetchHandler
+ * @property {function(any,any,any): FetchMock} route
+ * @property {function(RouteResponse=): FetchMock} catch
+ * @property {function(boolean): Promise<any>} flush
+ * @property {function(RouteName[]=): boolean} done
+ * @property {function(MatcherDefinition):void} defineMatcher
+ * @property {function(object): void} removeRoutes
+ * @property {function():void} clearHistory
+ */
+
+/** @type {FetchMockCore} */
+const FetchMock = {
+	config: defaultConfig,
+	router: new Router(defaultConfig),
+	callHistory: new CallHistory(defaultConfig),
+	createInstance() {
+		const instance = Object.create(FetchMock);
+		instance.config = { ...this.config };
+		instance.router = new Router(instance.config, {
+			routes: [...this.router.routes],
+			fallbackRoute: this.router.fallbackRoute,
+		});
+		instance.callHistory = new CallHistory(this.config);
+		return instance;
+	},
+	/**
+	 *
+	 * @param {string | Request} requestInput
+	 * @param {RequestInit} [requestInit]
+	 * @this {FetchMock}
+	 * @returns {Promise<Response>}
+	 */
+	fetchHandler(requestInput, requestInit) {
+		// TODO move into router
+		const normalizedRequest = requestUtils.normalizeRequest(
+			requestInput,
+			requestInit,
+			this.config.Request,
+		);
+		/** @type {Promise<any>[]} */
+		const pendingPromises = [];
+		const callLog = { ...normalizedRequest, pendingPromises };
+		this.callHistory.recordCall(callLog);
+		const responsePromise = this.router.execute(callLog, normalizedRequest);
+		pendingPromises.push(responsePromise);
+		return responsePromise;
+	},
+	/**
+	 * @overload
+	 * @param {UserRouteConfig} matcher
+	 * @this {FetchMock}
+	 * @returns {FetchMock}
+	 */
+
+	/**
+	 * @overload
+	 * @param {RouteMatcher } matcher
+	 * @param {RouteResponse} response
+	 * @param {UserRouteConfig | string} [options]
+	 * @this {FetchMock}
+	 * @returns {FetchMock}
+	 */
+
+	/**
+	 * @param {RouteMatcher | UserRouteConfig} matcher
+	 * @param {RouteResponse} [response]
+	 * @param {UserRouteConfig | string} [options]
+	 * @this {FetchMock}
+	 * @returns {FetchMock}
+	 */
+	route(matcher, response, options) {
+		this.router.addRoute(matcher, response, options);
+		return this;
+	},
+	catch(response) {
+		this.router.setFallback(response);
+		return this;
+	},
+	defineMatcher(matcher) {
+		Route.defineMatcher(matcher);
+	},
+	flush(waitForResponseBody) {
+		return this.callHistory.flush(waitForResponseBody);
+	},
+	/**
+	 *
+	 * @param {RouteName|RouteName[]} routeNames
+	 * @returns {boolean}
+	 */
+	done(routeNames) {
+		if (!routeNames) {
+			return this.callHistory.done(this.router.routes);
+		}
+		return this.callHistory.done(
+			this.router.routes,
+			Array.isArray(routeNames) ? routeNames : [routeNames],
+		);
+	},
+	removeRoutes(options) {
+		this.router.removeRoutes(options);
+		return this;
+	},
+	clearHistory() {
+		this.callHistory.clear();
+		return this;
+	},
+};
+
+/** @typedef {'get' |'post' |'put' |'delete' |'head' |'patch' |'once' |'sticky' |'any' |'anyOnce' |'getOnce' |'postOnce' |'putOnce' |'deleteOnce' |'headOnce' |'patchOnce' |'getAny' |'postAny' |'putAny' |'deleteAny' |'headAny' |'patchAny' |'getAnyOnce' |'postAnyOnce' |'putAnyOnce' |'deleteAnyOnce' |'headAnyOnce' |'patchAnyOnce'} PresetRouteMethodName} */
+/** @typedef {Object.<PresetRouteMethodName, function(any,any,any): FetchMock>} PresetRoutes */
+
+/** @type {PresetRoutes} */
+const PresetRoutes = {};
+/**
+ *
+ * @param {PresetRouteMethodName} methodName
+ * @param {string} underlyingMethod
+ * @param {UserRouteConfig} shorthandOptions
+ */
+const defineShorthand = (methodName, underlyingMethod, shorthandOptions) => {
+	/**
+	 * @overload
+	 * @param {UserRouteConfig} matcher
+	 * @this {FetchMock}
+	 * @returns {FetchMock}
+	 */
+
+	/**
+	 * @overload
+	 * @param {RouteMatcher } matcher
+	 * @param {RouteResponse} response
+	 * @param {UserRouteConfig | string} [options]
+	 * @this {FetchMock}
+	 * @returns {FetchMock}
+	 */
+
+	/**
+	 * @param {RouteMatcher | UserRouteConfig} matcher
+	 * @param {RouteResponse} [response]
+	 * @param {UserRouteConfig | string} [options]
+	 * @this {FetchMock}
+	 * @returns {FetchMock}
+	 */
+	PresetRoutes[methodName] = function (matcher, response, options) {
+		return this[underlyingMethod](
+			matcher,
+			response,
+			Object.assign(options || {}, shorthandOptions),
+		);
+	};
+};
+/**
+ *
+ * @param {PresetRouteMethodName} methodName
+ * @param {string} underlyingMethod
+ */
+const defineGreedyShorthand = (methodName, underlyingMethod) => {
+	/**
+	 * @param {RouteResponse} response
+	 * @param {UserRouteConfig | string} [options]
+	 * @this {FetchMock}
+	 * @returns {FetchMock}
+	 */
+	PresetRoutes[methodName] = function (response, options) {
+		return this[underlyingMethod]('*', response, options);
+	};
+};
+
+defineShorthand('sticky', 'route', { sticky: true });
+defineShorthand('once', 'route', { repeat: 1 });
+defineGreedyShorthand('any', 'route');
+defineGreedyShorthand('anyOnce', 'once');
+
+['get', 'post', 'put', 'delete', 'head', 'patch'].forEach((method) => {
+	defineShorthand(/** @type {PresetRouteMethodName} */ (method), 'route', {
+		method,
+	});
+	defineShorthand(
+		/** @type {PresetRouteMethodName} */ (`${method}Once`),
+		'once',
+		{ method },
+	);
+	defineGreedyShorthand(
+		/** @type {PresetRouteMethodName} */ (`${method}Any`),
+		method,
+	);
+	defineGreedyShorthand(
+		/** @type {PresetRouteMethodName} */ (`${method}AnyOnce`),
+		`${method}Once`,
+	);
+});
+
+/** @typedef {FetchMockCore & PresetRoutes} FetchMock*/
+Object.assign(FetchMock, PresetRoutes);
+
+export default FetchMock.createInstance();
diff --git a/packages/core/src/Matchers.js b/packages/core/src/Matchers.js
new file mode 100644
index 0000000..dd23a53
--- /dev/null
+++ b/packages/core/src/Matchers.js
@@ -0,0 +1,244 @@
+//@type-check
+/** @typedef {import('./Route').RouteConfig} RouteConfig */
+/** @typedef {import('./RequestUtils').NormalizedRequestOptions} NormalizedRequestOptions */
+/** @typedef {import('path-to-regexp').Key} Key */
+import glob from 'glob-to-regexp';
+import pathToRegexp from 'path-to-regexp';
+import querystring from 'querystring';
+import isSubset from 'is-subset';
+import isEqual from 'lodash.isequal';
+import {
+	normalizeHeaders,
+	getPath,
+	getQuery,
+	normalizeUrl,
+} from './RequestUtils.js';
+
+/**
+ * @param {RouteMatcher | RouteConfig} matcher
+ * @returns {matcher is RouteMatcherUrl}
+ */
+export const isUrlMatcher = (matcher) =>
+	matcher instanceof RegExp ||
+	typeof matcher === 'string' ||
+	(typeof matcher === 'object' && 'href' in matcher);
+
+/**
+ *
+ * @param {RouteMatcher| RouteConfig} matcher
+ * @returns {matcher is RouteMatcherFunction}
+ */
+export const isFunctionMatcher = (matcher) => typeof matcher === 'function';
+
+/** @typedef {string | RegExp | URL} RouteMatcherUrl */
+/** @typedef {function(string): boolean} UrlMatcher */
+/** @typedef {function(string): UrlMatcher} UrlMatcherGenerator */
+/** @typedef {function(string, NormalizedRequestOptions, Request): boolean} RouteMatcherFunction */
+/** @typedef {function(RouteConfig): RouteMatcherFunction} MatcherGenerator */
+/** @typedef {RouteMatcherUrl | RouteMatcherFunction} RouteMatcher */
+
+/**
+ * @typedef MatcherDefinition
+ * @property {string} name
+ * @property {MatcherGenerator} matcher
+ * @property  {boolean} [usesBody]
+ */
+
+/**
+ * @type {Object.<string, UrlMatcherGenerator>}
+ */
+const stringMatchers = {
+	begin: (targetString) => (url) => url.indexOf(targetString) === 0,
+	end: (targetString) => (url) =>
+		url.substr(-targetString.length) === targetString,
+
+	glob: (targetString) => {
+		const urlRX = glob(targetString);
+		return (url) => urlRX.test(url);
+	},
+	express: (targetString) => {
+		const urlRX = pathToRegexp(targetString);
+		return (url) => urlRX.test(getPath(url));
+	},
+	path: (targetString) => (url) => getPath(url) === targetString,
+};
+/**
+ * @type {MatcherGenerator}
+ */
+const getHeaderMatcher = ({ headers: expectedHeaders }) => {
+	if (!expectedHeaders) {
+		return;
+	}
+	const expectation = normalizeHeaders(expectedHeaders);
+	return (url, { headers = {} }) => {
+		// TODO do something to handle multi value headers
+		const lowerCaseHeaders = normalizeHeaders(headers);
+		return Object.keys(expectation).every(
+			(headerName) => lowerCaseHeaders[headerName] === expectation[headerName],
+		);
+	};
+};
+/**
+ * @type {MatcherGenerator}
+ */
+const getMethodMatcher = ({ method: expectedMethod }) => {
+	if (!expectedMethod) {
+		return;
+	}
+	return (url, { method }) => {
+		const actualMethod = method ? method.toLowerCase() : 'get';
+		return expectedMethod === actualMethod;
+	};
+};
+/**
+ * @type {MatcherGenerator}
+ */
+const getQueryStringMatcher = ({ query: passedQuery }) => {
+	if (!passedQuery) {
+		return;
+	}
+	const expectedQuery = querystring.parse(querystring.stringify(passedQuery));
+	const keys = Object.keys(expectedQuery);
+	return (url) => {
+		const query = querystring.parse(getQuery(url));
+		return keys.every((key) => {
+			if (Array.isArray(query[key])) {
+				if (!Array.isArray(expectedQuery[key])) {
+					return false;
+				}
+				return isEqual(
+					/** @type {string[]}*/ (query[key]).sort(),
+					/** @type {string[]}*/ (expectedQuery[key]).sort(),
+				);
+			}
+			return query[key] === expectedQuery[key];
+		});
+	};
+};
+/**
+ * @type {MatcherGenerator}
+ */
+const getParamsMatcher = ({ params: expectedParams, url: matcherUrl }) => {
+	if (!expectedParams) {
+		return;
+	}
+	if (typeof matcherUrl === 'string') {
+		if (!/express:/.test(matcherUrl)) {
+			throw new Error(
+				'fetch-mock: matching on params is only possible when using an express: matcher',
+			);
+		}
+		const expectedKeys = Object.keys(expectedParams);
+		/** @type {Key[]} */
+		const keys = [];
+		const re = pathToRegexp(matcherUrl.replace(/^express:/, ''), keys);
+		return (url) => {
+			const vals = re.exec(getPath(url)) || [];
+			vals.shift();
+			/** @type {Object.<string,string>} */
+			const params = keys.reduce(
+				(map, { name }, i) =>
+					vals[i] ? Object.assign(map, { [name]: vals[i] }) : map,
+				{},
+			);
+			return expectedKeys.every((key) => params[key] === expectedParams[key]);
+		};
+	}
+};
+/**
+ * @type {MatcherGenerator}
+ */
+const getBodyMatcher = (route) => {
+	const { body: expectedBody } = route;
+
+	return (url, { body, method = 'get' }) => {
+		if (method.toLowerCase() === 'get') {
+			// GET requests don’t send a body so the body matcher should be ignored for them
+			return true;
+		}
+
+		let sentBody;
+
+		try {
+			sentBody = JSON.parse(body);
+		} catch (err) {}
+
+		return (
+			sentBody &&
+			(route.matchPartialBody
+				? isSubset(sentBody, expectedBody)
+				: isEqual(sentBody, expectedBody))
+		);
+	};
+};
+/**
+ *
+ * @param {RouteConfig} route
+ * @param {string} matcherUrl
+ * @param {Object.<string,string>} query
+ * @returns {RouteMatcherFunction}
+ */
+const getFullUrlMatcher = (route, matcherUrl, query) => {
+	// if none of the special syntaxes apply, it's just a simple string match
+	// but we have to be careful to normalize the url we check and the name
+	// of the route to allow for e.g. http://it.at.there being indistinguishable
+	// from http://it.at.there/ once we start generating Request/Url objects
+	const expectedUrl = normalizeUrl(matcherUrl);
+	if (route.url === matcherUrl) {
+		route.url = expectedUrl;
+	}
+
+	return (url) => {
+		if (query && expectedUrl.indexOf('?')) {
+			return getPath(url) === getPath(expectedUrl);
+		}
+		return normalizeUrl(url) === expectedUrl;
+	};
+};
+
+/**
+ * @type {MatcherGenerator}
+ */
+const getFunctionMatcher = ({ functionMatcher }) => functionMatcher;
+/**
+ * @type {MatcherGenerator}
+ */
+const getUrlMatcher = (route) => {
+	const { url: matcherUrl, query } = route;
+
+	if (matcherUrl === '*') {
+		return () => true;
+	}
+
+	if (matcherUrl instanceof RegExp) {
+		return (url) => matcherUrl.test(url);
+	}
+	if (matcherUrl instanceof URL) {
+		if (matcherUrl.href) {
+			return getFullUrlMatcher(route, matcherUrl.href, query);
+		}
+	}
+	if (typeof matcherUrl === 'string') {
+		for (const shorthand in stringMatchers) {
+			if (matcherUrl.indexOf(`${shorthand}:`) === 0) {
+				const urlFragment = matcherUrl.replace(
+					new RegExp(`^${shorthand}:`),
+					'',
+				);
+				return stringMatchers[shorthand](urlFragment);
+			}
+		}
+		return getFullUrlMatcher(route, matcherUrl, query);
+	}
+};
+
+/** @type {MatcherDefinition[]} */
+export const builtInMatchers = [
+	{ name: 'query', matcher: getQueryStringMatcher },
+	{ name: 'method', matcher: getMethodMatcher },
+	{ name: 'headers', matcher: getHeaderMatcher },
+	{ name: 'params', matcher: getParamsMatcher },
+	{ name: 'body', matcher: getBodyMatcher, usesBody: true },
+	{ name: 'functionMatcher', matcher: getFunctionMatcher },
+	{ name: 'url', matcher: getUrlMatcher },
+];
diff --git a/packages/core/src/RequestUtils.js b/packages/core/src/RequestUtils.js
new file mode 100644
index 0000000..65dad0b
--- /dev/null
+++ b/packages/core/src/RequestUtils.js
@@ -0,0 +1,133 @@
+// @type-check
+// https://stackoverflow.com/a/19709846/308237 plus data: scheme
+// split into 2 code paths as URL constructor does not support protocol-relative urls
+const absoluteUrlRX = new RegExp('^[a-z]+://|^data:', 'i');
+const protocolRelativeUrlRX = new RegExp('^//', 'i');
+
+/**
+ * @typedef DerivedRequestOptions
+ * @property  {string} method
+ * @property  {Promise<string>} [body]
+ * @property  {{ [key: string]: string }} [headers]
+ */
+
+/** @typedef {RequestInit | (RequestInit & DerivedRequestOptions) } NormalizedRequestOptions */
+/**
+ * @typedef NormalizedRequest
+ * @property  {string} url
+ * @property  {NormalizedRequestOptions} options
+ * @property  {Request} [request]
+ * @property  {AbortSignal} [signal]
+ */
+
+/**
+ *
+ * @param {string} url
+ * @returns {string}
+ */
+export function normalizeUrl(url) {
+	if (absoluteUrlRX.test(url)) {
+		const u = new URL(url);
+		return u.href;
+	}
+	if (protocolRelativeUrlRX.test(url)) {
+		const u = new URL(url, 'http://dummy');
+		return u.href;
+	}
+	const u = new URL(url, 'http://dummy');
+	return u.pathname + u.search;
+}
+/**
+ *
+ * @param {string|Request} urlOrRequest
+ * @param {typeof Request} Request
+ * @returns  {urlOrRequest is Request}
+ */
+const isRequest = (urlOrRequest, Request) =>
+	Request.prototype.isPrototypeOf(urlOrRequest);
+
+/**
+ *
+ * @param {string|Request} urlOrRequest
+ * @param {RequestInit} options
+ * @param {typeof Request} Request
+ * @returns {NormalizedRequest}
+ */
+export function normalizeRequest(urlOrRequest, options, Request) {
+	if (isRequest(urlOrRequest, Request)) {
+		/** @type {NormalizedRequestOptions} */
+		const derivedOptions = {
+			method: urlOrRequest.method,
+		};
+
+		try {
+			derivedOptions.body = urlOrRequest.clone().text();
+		} catch (err) {}
+
+		if (urlOrRequest.headers) {
+			derivedOptions.headers = normalizeHeaders(urlOrRequest.headers);
+		}
+		const normalizedRequestObject = {
+			url: normalizeUrl(urlOrRequest.url),
+			options: Object.assign(derivedOptions, options),
+			request: urlOrRequest,
+			signal: (options && options.signal) || urlOrRequest.signal,
+		};
+		return normalizedRequestObject;
+	}
+	if (
+		typeof urlOrRequest === 'string' ||
+		/** @type {object} */ (urlOrRequest) instanceof String ||
+		// horrible URL object duck-typing
+		(typeof urlOrRequest === 'object' && 'href' in urlOrRequest)
+	) {
+		return {
+			url: normalizeUrl(urlOrRequest),
+			options,
+			signal: options && options.signal,
+		};
+	}
+	if (typeof urlOrRequest === 'object') {
+		throw new TypeError(
+			'fetch-mock: Unrecognised Request object. Read the Config and Installation sections of the docs',
+		);
+	} else {
+		throw new TypeError('fetch-mock: Invalid arguments passed to fetch');
+	}
+}
+/**
+ * @param {string} url
+ * @returns {string}
+ */
+export function getPath(url) {
+	const u = absoluteUrlRX.test(url)
+		? new URL(url)
+		: new URL(url, 'http://dummy');
+	return u.pathname;
+}
+
+/**
+ * @param {string} url
+ * @returns {string}
+ */
+export function getQuery(url) {
+	const u = absoluteUrlRX.test(url)
+		? new URL(url)
+		: new URL(url, 'http://dummy');
+	return u.search ? u.search.substr(1) : '';
+}
+
+/**
+ *
+ * @param {Headers | [string, string][] | Record < string, string > | Object.<string, string | number>} headers
+ * @returns {Object.<string, string>}
+ */
+export const normalizeHeaders = (headers) => {
+	const entries =
+		headers instanceof Headers
+			? [...headers.entries()]
+			: Object.entries(headers);
+	return Object.fromEntries(
+		entries.map(([key, val]) => [key.toLowerCase(), String(val).valueOf()]),
+	);
+};
diff --git a/packages/core/src/Route.js b/packages/core/src/Route.js
new file mode 100644
index 0000000..d32cf7e
--- /dev/null
+++ b/packages/core/src/Route.js
@@ -0,0 +1,274 @@
+//@type-check
+import { builtInMatchers, isUrlMatcher, isFunctionMatcher } from './Matchers';
+import statusTextMap from './StatusTextMap';
+
+/** @typedef {import('./Matchers').RouteMatcher} RouteMatcher */
+/** @typedef {import('./Matchers').RouteMatcherFunction} RouteMatcherFunction */
+/** @typedef {import('./Matchers').RouteMatcherUrl} RouteMatcherUrl */
+/** @typedef {import('./Matchers').MatcherDefinition} MatcherDefinition */
+/** @typedef {import('./FetchMock').FetchMockConfig} FetchMockConfig */
+
+/**
+ * @typedef RouteResponseConfig {
+ * @property {string | {}} [body]
+ * @property {number} [status]
+ * @property {{ [key: string]: string }} [headers]
+ * @property {Error} [throws]
+ * @property {string} [redirectUrl]
+ * @property {ResponseInit} [options]
+ */
+
+/**
+ * @typedef ResponseInitUsingHeaders
+ * @property {number} status
+ * @property {string} statusText
+ * @property {Headers} headers
+ */
+
+/** @typedef {RouteResponseConfig | object}  RouteResponseObjectData */
+/** @typedef {Response | number| string | RouteResponseObjectData }  RouteResponseData */
+/** @typedef {Promise<RouteResponseData>}  RouteResponsePromise */
+/** @typedef {function(string, RequestInit, Request=): (RouteResponseData|RouteResponsePromise)} RouteResponseFunction */
+/** @typedef {RouteResponseData | RouteResponsePromise | RouteResponseFunction} RouteResponse*/
+
+/** @typedef {string} RouteName */
+
+/**
+ * @typedef UserRouteConfig
+ * @property {RouteName} [name]
+ * @property {string} [method]
+ * @property {{ [key: string]: string | number  }} [headers]
+ * @property {{ [key: string]: string }} [query]
+ * @property {{ [key: string]: string }} [params]
+ * @property {object} [body]
+ * @property {RouteMatcherFunction} [functionMatcher]
+ * @property {RouteMatcher} [matcher]
+ * @property {RouteMatcherUrl} [url]
+ * @property {RouteResponse | RouteResponseFunction} [response]
+ * @property {number} [repeat]
+ * @property {number} [delay]
+ * @property {boolean} [sendAsJson] - TODO this is global
+ * @property {boolean} [includeContentLength] - TODO this is global
+ * @property {boolean} [matchPartialBody] - TODO this is global
+ * @property {boolean} [sticky]
+ * @property {boolean} [usesBody] - TODO this shoudl not be in user config
+ * @property {boolean} [isFallback]
+ */
+
+/** @typedef {UserRouteConfig & FetchMockConfig} RouteConfig*/
+
+/**
+ *
+ * @param {number} [status]
+ * @returns {number}
+ */
+function sanitizeStatus(status) {
+	if (!status) {
+		return 200;
+	}
+
+	if (
+		(typeof status === 'number' &&
+			parseInt(String(status), 10) !== status &&
+			status >= 200) ||
+		status < 600
+	) {
+		return status;
+	}
+
+	throw new TypeError(`fetch-mock: Invalid status ${status} passed on response object.
+To respond with a JSON object that has status as a property assign the object to body
+e.g. {"body": {"status: "registered"}}`);
+}
+
+/**
+ * @class Route
+ */
+class Route {
+	/**
+	 * @param {RouteConfig} config
+	 */
+	constructor(config) {
+		this.config = config;
+		this.#sanitize();
+		this.#validate();
+		this.#generateMatcher();
+		this.#limit();
+		this.#delayResponse();
+	}
+
+	/** @type {RouteConfig} */
+	config = {};
+	/** @type {RouteMatcherFunction=} */
+	matcher = null;
+	/**
+	 * @returns {void}
+	 */
+	// eslint-disable-next-line class-methods-use-this
+	reset() {}
+
+	/**
+	 * @returns {void}
+	 */
+	// @ts-ignore
+	#validate() {
+		if (['matched', 'unmatched'].includes(this.config.name)) {
+			throw new Error(
+				`fetch-mock: Routes cannot use the reserved name \`${this.config.name}\``,
+			);
+		}
+		if (!('response' in this.config)) {
+			throw new Error('fetch-mock: Each route must define a response');
+		}
+		if (!Route.registeredMatchers.some(({ name }) => name in this.config)) {
+			throw new Error(
+				"fetch-mock: Each route must specify some criteria for matching calls to fetch. To match all calls use '*'",
+			);
+		}
+	}
+	/**
+	 * @returns {void}
+	 */
+	// @ts-ignore
+	#sanitize() {
+		if (this.config.method) {
+			this.config.method = this.config.method.toLowerCase();
+		}
+		if (isUrlMatcher(this.config.matcher)) {
+			this.config.url = this.config.matcher;
+			delete this.config.matcher;
+		}
+		if (isFunctionMatcher(this.config.matcher)) {
+			this.config.functionMatcher = this.config.matcher;
+		}
+	}
+	/**
+	 * @returns {void}
+	 */
+	// @ts-ignore
+	#generateMatcher() {
+		const activeMatchers = Route.registeredMatchers
+			.filter(({ name }) => name in this.config)
+			.map(({ matcher, usesBody }) => ({
+				matcher: matcher(this.config),
+				usesBody,
+			}));
+		this.config.usesBody = activeMatchers.some(({ usesBody }) => usesBody);
+		/** @type {RouteMatcherFunction} */
+		this.matcher = (url, options = {}, request) =>
+			activeMatchers.every(({ matcher }) => matcher(url, options, request));
+	}
+	/**
+	 * @returns {void}
+	 */
+	// @ts-ignore
+	#limit() {
+		if (!this.config.repeat) {
+			return;
+		}
+		const originalMatcher = this.matcher;
+		let timesLeft = this.config.repeat;
+		this.matcher = (url, options, request) => {
+			const match = timesLeft && originalMatcher(url, options, request);
+			if (match) {
+				timesLeft--;
+				return true;
+			}
+		};
+		this.reset = () => {
+			timesLeft = this.config.repeat;
+		};
+	}
+	/**
+	 * @returns {void}
+	 */
+	// @ts-ignore
+	#delayResponse() {
+		if (this.config.delay) {
+			const { response } = this.config;
+			this.config.response = () => {
+				return new Promise((res) =>
+					setTimeout(() => res(response), this.config.delay),
+				);
+			};
+		}
+	}
+
+	/**
+	 *
+	 * @param {RouteResponseConfig} responseInput
+	 * @returns {{response: Response, responseOptions: ResponseInit, responseInput: RouteResponseConfig}}
+	 */
+	constructResponse(responseInput) {
+		const responseOptions = this.constructResponseOptions(responseInput);
+		const body = this.constructResponseBody(responseInput, responseOptions);
+
+		return {
+			response: new this.config.Response(body, responseOptions),
+			responseOptions,
+			responseInput,
+		};
+	}
+	/**
+	 *
+	 * @param {RouteResponseConfig} responseInput
+	 * @returns {ResponseInitUsingHeaders}
+	 */
+	constructResponseOptions(responseInput) {
+		const options = responseInput.options || {};
+		options.status = sanitizeStatus(responseInput.status);
+		options.statusText = statusTextMap[options.status];
+		// we use Headers rather than an object because it allows us to add
+		// to them without worrying about case sensitivity of keys
+		options.headers = new this.config.Headers(responseInput.headers);
+		return /** @type {ResponseInitUsingHeaders} */ (options);
+	}
+	/**
+	 *
+	 * @param {RouteResponseConfig} responseInput
+	 * @param {ResponseInitUsingHeaders} responseOptions
+	 * @returns {string|null}
+	 */
+	constructResponseBody(responseInput, responseOptions) {
+		// start to construct the body
+		let body = responseInput.body;
+		// convert to json if we need to
+		if (typeof body === 'object') {
+			if (
+				this.config.sendAsJson &&
+				responseInput.body != null //eslint-disable-line
+			) {
+				body = JSON.stringify(body);
+				if (!responseOptions.headers.has('Content-Type')) {
+					responseOptions.headers.set('Content-Type', 'application/json');
+				}
+			}
+		}
+
+		if (typeof body === 'string') {
+			// add a Content-Length header if we need to
+			if (
+				this.config.includeContentLength &&
+				!responseOptions.headers.has('Content-Length')
+			) {
+				responseOptions.headers.set('Content-Length', body.length.toString());
+			}
+			return body;
+		}
+		// @ts-ignore
+		return body || null;
+	}
+
+	/**
+	 * @param {MatcherDefinition} matcher
+	 */
+	static defineMatcher(matcher) {
+		Route.registeredMatchers.push(matcher);
+	}
+	/** @type {MatcherDefinition[]} */
+	static registeredMatchers = [];
+}
+
+builtInMatchers.forEach(Route.defineMatcher);
+
+export default Route;
diff --git a/packages/core/src/Router.js b/packages/core/src/Router.js
new file mode 100644
index 0000000..7fed0ed
--- /dev/null
+++ b/packages/core/src/Router.js
@@ -0,0 +1,404 @@
+//@type-check
+import Route from './Route.js';
+import { isUrlMatcher, isFunctionMatcher } from './Matchers.js';
+/** @typedef {import('./Route').UserRouteConfig} UserRouteConfig */
+/** @typedef {import('./Route').RouteConfig} RouteConfig */
+/** @typedef {import('./Route').RouteResponse} RouteResponse */
+/** @typedef {import('./Route').RouteResponseData} RouteResponseData */
+/** @typedef {import('./Route').RouteResponseObjectData} RouteResponseObjectData */
+/** @typedef {import('./Route').RouteResponseConfig} RouteResponseConfig */
+/** @typedef {import('./Route').RouteResponseFunction} RouteResponseFunction */
+/** @typedef {import('./Matchers').RouteMatcher} RouteMatcher */
+/** @typedef {import('./FetchMock').FetchMockConfig} FetchMockConfig */
+/** @typedef {import('./FetchMock')} FetchMock */
+/** @typedef {import('./RequestUtils').NormalizedRequest} NormalizedRequest */
+/** @typedef {import('./CallHistory').CallLog} CallLog */
+
+/** @typedef {'body' |'headers' |'throws' |'status' |'redirectUrl' } ResponseConfigProp */
+
+/** @type {ResponseConfigProp[]} */
+const responseConfigProps = [
+	'body',
+	'headers',
+	'throws',
+	'status',
+	'redirectUrl',
+];
+
+/**
+ *
+ * @param {RouteConfig | string} options
+ * @returns {RouteConfig}
+ */
+const nameToOptions = (options) =>
+	typeof options === 'string' ? { name: options } : options;
+
+/**
+ *
+ * @param {RouteResponse} response
+ * @returns {RouteResponse is RouteResponseFunction}
+ */
+const isPromise = (response) =>
+	typeof (/** @type {Promise<any>} */ (response).then) === 'function';
+
+/**
+ *
+ * @param {RouteResponseData} responseInput
+ * @returns {RouteResponseConfig}
+ */
+function normalizeResponseInput(responseInput) {
+	// If the response config looks like a status, start to generate a simple response
+	if (typeof responseInput === 'number') {
+		return {
+			status: responseInput,
+		};
+		// If the response config is not an object, or is an object that doesn't use
+		// any reserved properties, assume it is meant to be the body of the response
+	} else if (
+		typeof responseInput === 'string' ||
+		shouldSendAsObject(responseInput)
+	) {
+		return {
+			body: responseInput,
+		};
+	}
+	return /** @type{RouteResponseConfig} */ (responseInput);
+}
+
+/**
+ *
+ * @param {RouteResponseData} responseInput
+ * @returns {boolean}
+ */
+function shouldSendAsObject(responseInput) {
+	// if (Object.keys(responseInput).some(key => responseConfigProps.includes(key)) {
+	// 	if (Object.keys(responseInput).some(key => !responseConfigProps.includes(key)) {
+	// 		throw new Error(`Ambiguous whether response is a configuration object `)
+	// 	} else {return true}
+	// }
+	// TODO improve this... make it less hacky and magic
+	if (
+		responseConfigProps.some(
+			(prop) => /** @type {RouteResponseConfig}*/ (responseInput)[prop],
+		)
+	) {
+		if (
+			Object.keys(responseInput).every((key) =>
+				responseConfigProps.includes(/** @type {ResponseConfigProp} */ (key)),
+			)
+		) {
+			return false;
+		}
+		return true;
+	}
+	return true;
+}
+
+/**
+ * @param {RouteResponse} response
+ * @param {NormalizedRequest} normalizedRequest
+ * @returns
+ */
+const resolveUntilResponseConfig = async (response, normalizedRequest) => {
+	const { url, options, request } = normalizedRequest;
+	// We want to allow things like
+	// - function returning a Promise for a response
+	// - delaying (using a timeout Promise) a function's execution to generate
+	//   a response
+	// Because of this we can't safely check for function before Promisey-ness,
+	// or vice versa. So to keep it DRY, and flexible, we keep trying until we
+	// have something that looks like neither Promise nor function
+	//eslint-disable-next-line no-constant-condition
+	while (true) {
+		if (typeof response === 'function') {
+			response = response(url, options, request);
+		} else if (isPromise(response)) {
+			response = await response; // eslint-disable-line  no-await-in-loop
+		} else {
+			return response;
+		}
+	}
+};
+
+export default class Router {
+	/**
+	 * @param {FetchMockConfig} fetchMockConfig
+	 * @param {object} [inheritedRoutes]
+	 * @param {Route[]} [inheritedRoutes.routes]
+	 * @param {Route} [inheritedRoutes.fallbackRoute]
+	 */
+	constructor(fetchMockConfig, { routes, fallbackRoute } = {}) {
+		this.config = fetchMockConfig;
+		this.routes = routes || []; // TODO deep clone this??
+		this.fallbackRoute = fallbackRoute;
+	}
+	/**
+	 *
+	 * @param {Request} request
+	 * @returns {boolean}
+	 */
+	needsToReadBody(request) {
+		return Boolean(
+			request && this.routes.some((route) => route.config.usesBody),
+		);
+	}
+
+	/**
+	 * @param {CallLog} callLog
+	 * @param {NormalizedRequest} normalizedRequest
+	 * @returns {Promise<Response>}
+	 */
+	execute(callLog, normalizedRequest) {
+		// TODO make abort vs reject neater
+		return new Promise(async (resolve, reject) => {
+			const { url, options, request, pendingPromises } = callLog;
+			if (normalizedRequest.signal) {
+				const abort = () => {
+					// TODO may need to bring that flushy thing back.
+					// Add a test to combvine flush with abort
+					// done();
+					reject(new DOMException('The operation was aborted.', 'AbortError'));
+				};
+				if (normalizedRequest.signal.aborted) {
+					abort();
+				}
+				normalizedRequest.signal.addEventListener('abort', abort);
+			}
+
+			if (this.needsToReadBody(request)) {
+				options.body = await options.body;
+			}
+
+			const routesToTry = this.fallbackRoute
+				? [...this.routes, this.fallbackRoute]
+				: this.routes;
+			const route = routesToTry.find((route) =>
+				route.matcher(url, options, request),
+			);
+
+			if (route) {
+				try {
+					callLog.route = route;
+					const { response, responseOptions, responseInput } =
+						await this.generateResponse(route, callLog);
+					const observableResponse = this.createObservableResponse(
+						response,
+						responseOptions,
+						responseInput,
+						url,
+						pendingPromises,
+					);
+					callLog.response = response;
+					resolve(observableResponse);
+				} catch (err) {
+					reject(err);
+				}
+			} else {
+				reject(
+					new Error(
+						`fetch-mock: No response or fallback rule to cover ${
+							(options && options.method) || 'GET'
+						} to ${url}`,
+					),
+				);
+			}
+		});
+	}
+
+	/**
+	 *
+	 * @param {Route} route
+	 * @param {CallLog} callLog
+	 * @returns {Promise<{response: Response, responseOptions: ResponseInit, responseInput: RouteResponseConfig}>}
+	 */
+	// eslint-disable-next-line class-methods-use-this
+	async generateResponse(route, callLog) {
+		const responseInput = await resolveUntilResponseConfig(
+			route.config.response,
+			callLog,
+		);
+
+		// If the response is a pre-made Response, respond with it
+		if (responseInput instanceof Response) {
+			return {
+				response: responseInput,
+				responseOptions: {},
+				responseInput: {},
+			};
+		}
+
+		const responseConfig = normalizeResponseInput(responseInput);
+
+		// If the response says to throw an error, throw it
+		if (responseConfig.throws) {
+			throw responseConfig.throws;
+		}
+
+		return route.constructResponse(responseConfig);
+	}
+	/**
+	 *
+	 * @param {Response} response
+	 * @param {ResponseInit} responseConfig
+	 * @param {RouteResponseConfig} responseInput
+	 * @param {string} responseUrl
+	 * @param {Promise<any>[]} pendingPromises
+	 * @returns {Response}
+	 */
+	// eslint-disable-next-line class-methods-use-this
+	createObservableResponse(
+		response,
+		responseConfig,
+		responseInput,
+		responseUrl,
+		pendingPromises,
+	) {
+		// Using a proxy means we can set properties that may not be writable on
+		// the original Response. It also means we can track the resolution of
+		// promises returned by res.json(), res.text() etc
+		return new Proxy(response, {
+			get: (originalResponse, name) => {
+				if (responseInput.redirectUrl) {
+					if (name === 'url') {
+						return responseInput.redirectUrl;
+					}
+
+					if (name === 'redirected') {
+						return true;
+					}
+				} else {
+					if (name === 'url') {
+						return responseUrl;
+					}
+					if (name === 'redirected') {
+						return false;
+					}
+				}
+				//@ts-ignore
+				if (typeof response[name] === 'function') {
+					//@ts-ignore
+					return new Proxy(response[name], {
+						apply: (func, thisArg, args) => {
+							const result = func.apply(response, args);
+							if (result.then) {
+								pendingPromises.push(
+									result.catch(/** @type {function(): void} */ () => undefined),
+								);
+							}
+							return result;
+						},
+					});
+				}
+				//@ts-ignore
+				return originalResponse[name];
+			},
+		});
+	}
+
+	/**
+	 * @overload
+	 * @param {UserRouteConfig} matcher
+	 * @returns {void}
+	 */
+
+	/**
+	 * @overload
+	 * @param {RouteMatcher } matcher
+	 * @param {RouteResponse} response
+	 * @param {UserRouteConfig | string} [nameOrOptions]
+	 * @returns {void}
+	 */
+
+	/**
+	 * @param {RouteMatcher | UserRouteConfig} matcher
+	 * @param {RouteResponse} [response]
+	 * @param {UserRouteConfig | string} [nameOrOptions]
+	 * @returns {void}
+	 */
+	addRoute(matcher, response, nameOrOptions) {
+		/** @type {RouteConfig} */
+		const config = {};
+		if (isUrlMatcher(matcher) || isFunctionMatcher(matcher)) {
+			config.matcher = matcher;
+		} else {
+			Object.assign(config, matcher);
+		}
+
+		if (typeof response !== 'undefined') {
+			config.response = response;
+		}
+
+		if (nameOrOptions) {
+			Object.assign(
+				config,
+				typeof nameOrOptions === 'string'
+					? nameToOptions(nameOrOptions)
+					: nameOrOptions,
+			);
+		}
+
+		const route = new Route({
+			...this.config,
+			...config,
+		});
+
+		if (
+			route.config.name &&
+			this.routes.some(
+				({ config: { name: existingName } }) =>
+					route.config.name === existingName,
+			)
+		) {
+			throw new Error(
+				'fetch-mock: Adding route with same name as existing route.',
+			);
+		}
+		this.routes.push(route);
+	}
+	/**
+	 * @param {RouteResponse} [response]
+	 */
+	setFallback(response) {
+		if (this.fallbackRoute) {
+			console.warn(
+				'calling fetchMock.catch() twice - are you sure you want to overwrite the previous fallback response',
+			); // eslint-disable-line
+		}
+
+		this.fallbackRoute = new Route({
+			matcher: (url, options) => {
+				if (this.config.warnOnFallback) {
+					console.warn(
+						`Unmatched ${(options && options.method) || 'GET'} to ${url}`,
+					); // eslint-disable-line
+				}
+				return true;
+			},
+			response: response || 'ok',
+			...this.config,
+		});
+		this.fallbackRoute.config.isFallback = true;
+	}
+	/**
+	 *
+	 * @param {object} [options]
+	 * @param {string[]} [options.names]
+	 * @param {boolean} [options.includeSticky]
+	 * @param {boolean} [options.includeFallback]
+	 */
+	removeRoutes({ names, includeSticky, includeFallback } = {}) {
+		includeFallback = includeFallback ?? true;
+		this.routes = this.routes.filter(({ config: { sticky, name } }) => {
+			if (sticky && !includeSticky) {
+				return true;
+			}
+			if (!names) {
+				return false;
+			}
+			return !names.includes(name);
+		});
+		if (includeFallback) {
+			delete this.fallbackRoute;
+		}
+	}
+}
diff --git a/packages/core/src/StatusTextMap.js b/packages/core/src/StatusTextMap.js
new file mode 100644
index 0000000..6b33ce9
--- /dev/null
+++ b/packages/core/src/StatusTextMap.js
@@ -0,0 +1,70 @@
+//@type-check
+/**
+ * @type {Object.<number, string>}
+ */
+const statusTextMap = {
+	100: 'Continue',
+	101: 'Switching Protocols',
+	102: 'Processing',
+	200: 'OK',
+	201: 'Created',
+	202: 'Accepted',
+	203: 'Non-Authoritative Information',
+	204: 'No Content',
+	205: 'Reset Content',
+	206: 'Partial Content',
+	207: 'Multi-Status',
+	208: 'Already Reported',
+	226: 'IM Used',
+	300: 'Multiple Choices',
+	301: 'Moved Permanently',
+	302: 'Found',
+	303: 'See Other',
+	304: 'Not Modified',
+	305: 'Use Proxy',
+	307: 'Temporary Redirect',
+	308: 'Permanent Redirect',
+	400: 'Bad Request',
+	401: 'Unauthorized',
+	402: 'Payment Required',
+	403: 'Forbidden',
+	404: 'Not Found',
+	405: 'Method Not Allowed',
+	406: 'Not Acceptable',
+	407: 'Proxy Authentication Required',
+	408: 'Request Timeout',
+	409: 'Conflict',
+	410: 'Gone',
+	411: 'Length Required',
+	412: 'Precondition Failed',
+	413: 'Payload Too Large',
+	414: 'URI Too Long',
+	415: 'Unsupported Media Type',
+	416: 'Range Not Satisfiable',
+	417: 'Expectation Failed',
+	418: "I'm a teapot",
+	421: 'Misdirected Request',
+	422: 'Unprocessable Entity',
+	423: 'Locked',
+	424: 'Failed Dependency',
+	425: 'Unordered Collection',
+	426: 'Upgrade Required',
+	428: 'Precondition Required',
+	429: 'Too Many Requests',
+	431: 'Request Header Fields Too Large',
+	451: 'Unavailable For Legal Reasons',
+	500: 'Internal Server Error',
+	501: 'Not Implemented',
+	502: 'Bad Gateway',
+	503: 'Service Unavailable',
+	504: 'Gateway Timeout',
+	505: 'HTTP Version Not Supported',
+	506: 'Variant Also Negotiates',
+	507: 'Insufficient Storage',
+	508: 'Loop Detected',
+	509: 'Bandwidth Limit Exceeded',
+	510: 'Not Extended',
+	511: 'Network Authentication Required',
+};
+
+export default statusTextMap;
diff --git a/packages/core/src/index.js b/packages/core/src/index.js
new file mode 100644
index 0000000..e69de29
diff --git a/packages/core/types/FetchHandler.d.ts b/packages/core/types/FetchHandler.d.ts
new file mode 100644
index 0000000..e69de29
diff --git a/packages/core/types/InstanceManagement.d.ts b/packages/core/types/InstanceManagement.d.ts
new file mode 100644
index 0000000..22c63e1
--- /dev/null
+++ b/packages/core/types/InstanceManagement.d.ts
@@ -0,0 +1,18 @@
+type RequestConstructor = new (input: string | Request, init ?: RequestInit) => Request;
+
+declare type FetchMockCore ={
+    createInstance: () => FetchMock
+    config: FetchMockConfig;
+}
+
+declare type FetchMock = FetchMockCore & Router
+
+// 5. Declaration merging
+// Unlike a type alias, an interface can be defined multiple times, and will be treated as a single interface(with members of all declarations being merged).
+
+// // These two declarations become:
+// // interface Point { x: number; y: number; }
+// interface Point { x: number; }
+// interface Point { y: number; }
+
+// const point: Point = { x: 1, y: 2 };
\ No newline at end of file
diff --git a/packages/core/types/Matchers.d.ts b/packages/core/types/Matchers.d.ts
new file mode 100644
index 0000000..acacfba
--- /dev/null
+++ b/packages/core/types/Matchers.d.ts
@@ -0,0 +1,38 @@
+
+
+/**
+ * Mock matcher function
+ */
+type RouteMatcherUrl = string | RegExp | URL;
+
+
+
+
+/**
+ * Mock matcher. Can be one of following:
+ * string: Either
+ *   * an exact url to match e.g. 'http://www.site.com/page.html'
+ *   * if the string begins with a `^`, the string following the `^` must
+ *     begin the url e.g. '^http://www.site.com' would match
+ *      'http://www.site.com' or 'http://www.site.com/page.html'
+ *    * '*' to match any url
+ * RegExp: A regular expression to test the url against
+ * Function(url, opts): A function (returning a Boolean) that is passed the
+ *  url and opts fetch() is called with (or, if fetch() was called with one,
+ *  the Request instance)
+ */
+type RouteMatcher = RouteMatcherUrl | RouteMatcherFunction;
+
+type UrlMatcher = (url: string) => boolean;
+
+type UrlMatcherGenerator = (pattern: string) => UrlMatcher;
+
+type RouteMatcherFunction = (url: string, opts: NormalizedRequestOptions, request: Request) => boolean;
+
+type MatcherGenerator = (route: RouteOptions) => RouteMatcherFunction;
+
+type MatcherDefinition = {
+    name: string;
+    matcher: MatcherGenerator;
+    usesBody?: boolean;
+}
\ No newline at end of file
diff --git a/packages/core/types/RequestUtils.d.ts b/packages/core/types/RequestUtils.d.ts
new file mode 100644
index 0000000..fdd6685
--- /dev/null
+++ b/packages/core/types/RequestUtils.d.ts
@@ -0,0 +1,12 @@
+interface DerivedRequestOptions {
+    method: string;
+    body?: Promise<string>;
+    headers?: { [key: string]: string | [string] }
+}
+type NormalizedRequestOptions = RequestInit | (RequestInit & DerivedRequestOptions)
+interface NormalizedRequest {
+    url: string;
+    options: NormalizedRequestOptions;
+    request?: Request;
+    signal?: AbortSignal;
+}
\ No newline at end of file
diff --git a/packages/core/types/Route.d.ts b/packages/core/types/Route.d.ts
new file mode 100644
index 0000000..ab351b1
--- /dev/null
+++ b/packages/core/types/Route.d.ts
@@ -0,0 +1,145 @@
+declare class Route {
+    /**
+     * @param {MatcherDefinition} matcher
+     */
+    static defineMatcher(matcher: any): void;
+    /**
+     * @overload
+     * @param {RouteOptions} matcher
+     * @param {undefined} response
+     * @param {undefined} options
+     * @param {FetchMockConfig} globalConfig
+     */
+    /**
+     * @overload
+     * @param {RouteMatcher } matcher
+     * @param {RouteResponse} response
+     * @param {RouteOptions | string} options
+     * @param {FetchMockConfig} globalConfig
+     */
+    /**
+     * @param {RouteMatcher | RouteOptions} matcher
+     * @param {RouteResponse} [response]
+     * @param {RouteOptions | string} [options]
+     * @param {FetchMockConfig} [globalConfig]
+     */
+    constructor(matcher: any | any, response?: any, options?: any | string, globalConfig?: any);
+    originalInput: {
+        matcher: any;
+        response: any;
+        options: any;
+    };
+    routeOptions: RouteOptions;
+    reset: () => void;
+    response: () => Promise<any>;
+    matcher: RouteMatcherFunction;
+    #private;
+}
+declare namespace Route {
+    export const registeredMatchers: any[];
+}
+
+
+
+
+/**
+ * Mock options object
+ */
+interface RouteOptions {
+    /**
+     * A unique string naming the route. Used to subsequently retrieve
+     * references to the calls, grouped by name.
+     */
+    name?: string;
+
+    /**
+     * http method to match
+     */
+    method?: string;
+
+    /**
+     * key/value map of headers to match
+     */
+    headers?: { [key: string]: string | number };
+
+    /**
+     * key/value map of query strings to match, in any order
+     */
+    query?: { [key: string]: string };
+
+    /**
+     * key/value map of express style path params to match
+     */
+    params?: { [key: string]: string };
+
+    /**
+     * JSON serialisable object literal. Allowing any object for now
+     * But in typescript 3.7 will change to JSON
+     */
+    body?: object;
+
+    /**
+     * A function for arbitrary matching
+     */
+    functionMatcher?: RouteMatcherFunction;
+
+    /**
+     * as specified above
+     */
+    matcher?: RouteMatcher;
+
+    url?: RouteMatcherUrl;
+
+    /**
+     * This option allows for existing routes in a mock to be overwritten.
+     * It’s also possible to define multiple routes with ‘the same’ matcher.
+     * Default behaviour is to error
+     */
+    overwriteRoutes?: boolean;
+
+    /**
+     * as specified above
+     */
+    response?: RouteResponse | RouteResponseFunction;
+
+    /**
+     * integer, n, limiting the number of times the matcher can be used.
+     * If the route has already been called n times the route will be
+     * ignored and the call to fetch() will fall through to be handled by
+     * any other routes defined (which may eventually result in an error
+     * if nothing matches it).
+     */
+    repeat?: number;
+
+    /**
+     * integer, n, delays responding for the number of milliseconds
+     * specified.
+     */
+    delay?: number;
+
+    /**
+     * Convert objects into JSON before delivering as stub responses. Can
+     * be useful to set to false globally if e.g. dealing with a lot of
+     * array buffers. If true, will also add content-type: application/json
+     * header.
+     * @default true
+     */
+    sendAsJson?: boolean;
+
+    /**
+     * Automatically sets a content-length header on each response.
+     * @default true
+     */
+    includeContentLength?: boolean;
+
+    /**
+     * Match calls that only partially match a specified body json.
+     */
+    matchPartialBody?: boolean;
+
+    /**
+     * Avoids a route being removed when reset(), restore() or resetBehavior() are called.
+     * Note - this does not preserve the history of calls to the route
+     */
+    sticky?: boolean;
+}
\ No newline at end of file
diff --git a/packages/core/types/Router.d.ts b/packages/core/types/Router.d.ts
new file mode 100644
index 0000000..0ffb915
--- /dev/null
+++ b/packages/core/types/Router.d.ts
@@ -0,0 +1,219 @@
+
+
+interface RouteOptionsMethodGet extends RouteOptions {
+    method?: 'GET';
+}
+
+interface RouteOptionsMethodPost extends RouteOptions {
+    method?: 'POST';
+}
+
+interface RouteOptionsMethodPut extends RouteOptions {
+    method?: 'PUT';
+}
+
+interface RouteOptionsMethodDelete extends RouteOptions {
+    method?: 'DELETE';
+}
+
+interface RouteOptionsMethodPatch extends RouteOptions {
+    method?: 'PATCH';
+}
+
+interface RouteOptionsMethodHead extends RouteOptions {
+    method?: 'HEAD';
+}
+
+interface Router {
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Calls to .mock() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+route(matcher: RouteMatcher | RouteOptions, response: MockResponse | MockResponseFunction, options ?: RouteOptions): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Calls to .mock() can be chained.
+ * @param options The route to mock
+ */
+route(options: RouteOptions): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() which creates a route
+ *  that persists even when restore(), reset() or resetbehavior() are called.
+ *  Calls to .sticky() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+sticky(matcher: RouteMatcher | RouteOptions, response: MockResponse | MockResponseFunction, options ?: RouteOptions): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() limited to being
+ *  called one time only. Calls to .once() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Optional additional properties defining the route to mock
+ */
+once(matcher: RouteMatcher | RouteOptions, response: MockResponse | MockResponseFunction, options ?: RouteOptions): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the GET
+ *  method. Calls to .get() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+get(matcher: RouteMatcher | RouteOptionsMethodGet, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodGet): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the GET
+ *  method and limited to being called one time only. Calls to .getOnce()
+ *  can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+getOnce(matcher: RouteMatcher | RouteOptionsMethodGet, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodGet): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the POST
+ *  method. Calls to .post() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+post(matcher: RouteMatcher | RouteOptionsMethodPost, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodPost): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the POST
+ *  method and limited to being called one time only. Calls to .postOnce()
+ *  can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+postOnce(matcher: RouteMatcher | RouteOptionsMethodPost, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodPost): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the PUT
+ *  method. Calls to .put() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+put(matcher: RouteMatcher | RouteOptionsMethodPut, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodPut): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the PUT
+ *  method and limited to being called one time only. Calls to .putOnce()
+ *  can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+putOnce(matcher: RouteMatcher | RouteOptionsMethodPut, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodPut): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the
+ *  DELETE method. Calls to .delete() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+delete (matcher: RouteMatcher | RouteOptionsMethodDelete, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodDelete): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the
+ *  DELETE method and limited to being called one time only. Calls to
+ *  .deleteOnce() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+deleteOnce(matcher: RouteMatcher | RouteOptionsMethodDelete, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodDelete): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the HEAD
+ *  method. Calls to .head() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+head(matcher: RouteMatcher | RouteOptionsMethodHead, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodHead): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the HEAD
+ *  method and limited to being called one time only. Calls to .headOnce()
+ *  can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+headOnce(matcher: RouteMatcher | RouteOptionsMethodHead, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodHead): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the PATCH
+ *  method. Calls to .patch() can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+patch(matcher: RouteMatcher | RouteOptionsMethodPatch, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodPatch): this;
+
+/**
+ * Replaces fetch() with a stub which records its calls, grouped by
+ * route, and optionally returns a mocked Response object or passes the
+ *  call through to fetch(). Shorthand forroute() restricted to the PATCH
+ *  method and limited to being called one time only. Calls to .patchOnce()
+ *  can be chained.
+ * @param matcher Condition for selecting which requests to mock
+ * @param response Configures the http response returned by the mock
+ * @param [options] Additional properties defining the route to mock
+ */
+patchOnce(matcher: RouteMatcher | RouteOptionsMethodPatch, response: MockResponse | MockResponseFunction, options ?: RouteOptionsMethodPatch): this;
+
+    /**
+     * Chainable method that defines how to respond to calls to fetch that
+     * don't match any of the defined mocks. It accepts the same types of
+     * response as a normal call to .mock(matcher, response). It can also
+     * take an arbitrary function to completely customise behaviour of
+     * unmatched calls. If .catch() is called without any parameters then
+     * every unmatched call will receive a 200 response.
+     * @param [response] Configures the http response returned by the mock
+     */
+    catch (response?: MockResponse | MockResponseFunction): this;
+
+}
diff --git a/packages/standalone/fragments.js b/packages/standalone/fragments.js
new file mode 100644
index 0000000..3e4196f
--- /dev/null
+++ b/packages/standalone/fragments.js
@@ -0,0 +1,69 @@
+FetchHandler.getNativeFetch = function () {
+    const func = this.realFetch || (this.isSandbox && this.config.fetch);
+    if (!func) {
+        throw new Error(
+            'fetch-mock: Falling back to network only available on global fetch-mock, or by setting config.fetch on sandboxed fetch-mock',
+        );
+    }
+    return func;
+};
+
+
+FetchMock.resetBehavior = function (options = {}) {
+    const removeRoutes = getRouteRemover(options);
+
+    this.routes = removeRoutes(this.routes);
+    this._uncompiledRoutes = removeRoutes(this._uncompiledRoutes);
+
+    if (this.realFetch && !this.routes.length) {
+        globalThis.fetch = this.realFetch;
+        this.realFetch = undefined;
+    }
+
+    this.fallbackResponse = undefined;
+    return this;
+};
+
+FetchMock.resetHistory = function () {
+    this._calls = [];
+    this._holdingPromises = [];
+    this.routes.forEach((route) => route.reset && route.reset());
+    return this;
+};
+
+FetchMock.restore = FetchMock.reset = function (options) {
+    this.resetBehavior(options);
+    this.resetHistory();
+    return this;
+};
+
+FetchMock._mock = function () {
+    if (!this.isSandbox) {
+        // Do this here rather than in the constructor to ensure it's scoped to the test
+        this.realFetch = this.realFetch || globalThis.fetch;
+        globalThis.fetch = this.fetchHandler;
+    }
+    return this;
+};
+
+if (this.getOption('fallbackToNetwork') === 'always') {
+    return {
+        route: { response: this.getNativeFetch(), responseIsFetch: true },
+        // BUG - this callLog never used to get sent. Discovered the bug
+        // but can't fix outside a major release as it will potentially
+        // cause too much disruption
+        //
+        // callLog,
+    };
+}
+
+  if (!this.getOption('fallbackToNetwork')) {
+        throw new Error(
+            `fetch-mock: No fallback response defined for ${(options && options.method) || 'GET'
+            } to ${url}`,
+        );
+    }
+    return {
+        route: { response: this.getNativeFetch(), responseIsFetch: true },
+        callLog,
+    };
\ No newline at end of file
diff --git a/packages/standalone/types.ts b/packages/standalone/types.ts
new file mode 100644
index 0000000..f57bedf
--- /dev/null
+++ b/packages/standalone/types.ts
@@ -0,0 +1,30 @@
+// /**
+//  * Chainable method that records the call history of unmatched calls,
+//  * but instead of responding with a stubbed response, the request is
+//  * passed through to native fetch() and is allowed to communicate
+//  * over the network. Similar to catch().
+//  */
+// spy(response?: MockResponse | MockResponseFunction): this;
+
+// /**
+//  * Restores fetch() to its unstubbed state and clears all data recorded
+//  * for its calls. reset() is an alias for restore().
+//  */
+// restore(): this;
+
+// /**
+//  * Restores fetch() to its unstubbed state and clears all data recorded
+//  * for its calls. reset() is an alias for restore().
+//  */
+// reset(): this;
+
+// /**
+//  * Clears all data recorded for fetch()’s calls. It will not restore
+//  * fetch to its default implementation.
+//  */
+// resetHistory(): this;
+
+// /**
+//  * Removes mocking behaviour without resetting call history.
+//  */
+// resetBehavior(): this;
\ No newline at end of file
diff --git a/packages/wip/generated-types/CallHistory.d.ts b/packages/wip/generated-types/CallHistory.d.ts
new file mode 100644
index 0000000..b4cc425
--- /dev/null
+++ b/packages/wip/generated-types/CallHistory.d.ts
@@ -0,0 +1,11 @@
+export default FetchMock;
+declare namespace FetchMock {
+    export function filterCalls(nameOrMatcher: any, options: any): any;
+    export function calls(nameOrMatcher: any, options: any): any;
+    export function lastCall(nameOrMatcher: any, options: any): any;
+    export function lastUrl(nameOrMatcher: any, options: any): any;
+    export function lastOptions(nameOrMatcher: any, options: any): any;
+    export function lastResponse(nameOrMatcher: any, options: any): any;
+    export function called(nameOrMatcher: any, options: any): boolean;
+    export function done(nameOrMatcher: any): any;
+}
diff --git a/packages/wip/generated-types/FetchHandler.d.ts b/packages/wip/generated-types/FetchHandler.d.ts
new file mode 100644
index 0000000..b345c68
--- /dev/null
+++ b/packages/wip/generated-types/FetchHandler.d.ts
@@ -0,0 +1,17 @@
+export default FetchHandler;
+/**
+ * An object that contains the fetch handler function - used as the mock for
+ * fetch - and various utilities to help it operate
+ * This object will never be accessed as a separate entity by the end user as it
+ * gets munged with Router and CallHistory objects by FetchMockWrapper
+ */
+export type FetchHandler = any;
+declare namespace FetchHandler {
+    export function fetchHandler(url: any, options: any): Promise<any>;
+    export namespace fetchHandler {
+        export const isMock: boolean;
+    }
+    export function generateResponse({ route, url, options, request, callLog, }: {
+        route: any;
+    }): Promise<any>;
+}
diff --git a/packages/wip/generated-types/FetchMockWrapper.d.ts b/packages/wip/generated-types/FetchMockWrapper.d.ts
new file mode 100644
index 0000000..e384567
--- /dev/null
+++ b/packages/wip/generated-types/FetchMockWrapper.d.ts
@@ -0,0 +1,2 @@
+declare var _default: any;
+export default _default;
diff --git a/packages/wip/generated-types/Matchers.d.ts b/packages/wip/generated-types/Matchers.d.ts
new file mode 100644
index 0000000..2197135
--- /dev/null
+++ b/packages/wip/generated-types/Matchers.d.ts
@@ -0,0 +1,16 @@
+declare var _default: ({
+    name: string;
+    matcher: (route: any) => any;
+    usesBody: boolean;
+} | {
+    name: string;
+    matcher: ({ functionMatcher }: {
+        functionMatcher: any;
+    }) => (...args: any[]) => any;
+    usesBody?: undefined;
+} | {
+    name: string;
+    matcher: (route: any) => any;
+    usesBody?: undefined;
+})[];
+export default _default;
diff --git a/packages/wip/generated-types/RequestUtils.d.ts b/packages/wip/generated-types/RequestUtils.d.ts
new file mode 100644
index 0000000..314012f
--- /dev/null
+++ b/packages/wip/generated-types/RequestUtils.d.ts
@@ -0,0 +1,27 @@
+export function normalizeUrl(url: any): any;
+/**
+ *
+ * @param {string|Request} urlOrRequest
+ * @param {Object} options
+ * @param {Class} Request
+ * @returns
+ */
+export function normalizeRequest(urlOrRequest: string | Request, options: Object, Request: any): {
+    url: any;
+    options: {
+        method: any;
+    } & Object;
+    request: RequestInfo;
+    signal: any;
+} | {
+    url: any;
+    options: Object;
+    signal: any;
+};
+export function getPath(url: any): string;
+export function getQuery(url: any): string;
+export namespace headers {
+    export function normalize(headers: any): any;
+    export function toLowerCase(headers: any): {};
+    export function equal(actualHeader: any, expectedHeader: any): any;
+}
diff --git a/packages/wip/generated-types/ResponseBuilder.d.ts b/packages/wip/generated-types/ResponseBuilder.d.ts
new file mode 100644
index 0000000..27e4190
--- /dev/null
+++ b/packages/wip/generated-types/ResponseBuilder.d.ts
@@ -0,0 +1,2 @@
+declare function _default(options: any): any[];
+export default _default;
diff --git a/packages/wip/generated-types/Route.d.ts b/packages/wip/generated-types/Route.d.ts
new file mode 100644
index 0000000..1516d43
--- /dev/null
+++ b/packages/wip/generated-types/Route.d.ts
@@ -0,0 +1,44 @@
+export default Route;
+declare class Route {
+    /**
+     * @param {MatcherDefinition} matcher
+     */
+    static defineMatcher(matcher: any): void;
+    /**
+     * @overload
+     * @param {MockOptions} matcher
+     * @param {undefined} response
+     * @param {undefined} options
+     * @param {FetchMockConfig} globalConfig
+     */
+    /**
+     * @overload
+     * @param {MockMatcher } matcher
+     * @param {MockResponse} response
+     * @param {MockOptions | string} options
+     * @param {FetchMockConfig} globalConfig
+     */
+    /**
+     * @param {MockMatcher | MockOptions} matcher
+     * @param {MockResponse} [response]
+     * @param {MockOptions | string} [options]
+     * @param {FetchMockConfig} [globalConfig]
+     */
+    constructor(matcher: any | any, response?: any, options?: any | string, globalConfig?: any);
+    originalInput: {
+        matcher: any;
+        response: any;
+        options: any;
+    };
+    method: any;
+    url: (url: any, options: {}, request: any) => boolean;
+    functionMatcher: any;
+    usesBody: boolean;
+    matcher: (url: any, options: {}, request: any) => boolean;
+    reset: () => void;
+    response: () => Promise<any>;
+    #private;
+}
+declare namespace Route {
+    export const registeredMatchers: any[];
+}
diff --git a/packages/wip/generated-types/Router.d.ts b/packages/wip/generated-types/Router.d.ts
new file mode 100644
index 0000000..3ec2ae8
--- /dev/null
+++ b/packages/wip/generated-types/Router.d.ts
@@ -0,0 +1,3 @@
+declare var routes: any;
+declare function defineShorthand(methodName: any, underlyingMethod: any, shorthandOptions: any): void;
+declare function defineGreedyShorthand(methodName: any, underlyingMethod: any): void;
diff --git a/packages/wip/generated-types/StatusTextMap.d.ts b/packages/wip/generated-types/StatusTextMap.d.ts
new file mode 100644
index 0000000..b98f5db
--- /dev/null
+++ b/packages/wip/generated-types/StatusTextMap.d.ts
@@ -0,0 +1,7 @@
+export default statusTextMap;
+/**
+ * @type {Object.<number, string>}
+ */
+declare const statusTextMap: {
+    [x: number]: string;
+};
diff --git a/packages/wip/old-types/CallHistory.d.ts b/packages/wip/old-types/CallHistory.d.ts
new file mode 100644
index 0000000..b77845c
--- /dev/null
+++ b/packages/wip/old-types/CallHistory.d.ts
@@ -0,0 +1,159 @@
+export default FetchMock;
+declare namespace FetchMock {
+    export function filterCalls(nameOrMatcher: any, options: any): any;
+    export function calls(nameOrMatcher: any, options: any): any;
+    export function lastCall(nameOrMatcher: any, options: any): any;
+    export function lastUrl(nameOrMatcher: any, options: any): any;
+    export function lastOptions(nameOrMatcher: any, options: any): any;
+    export function lastResponse(nameOrMatcher: any, options: any): any;
+    export function called(nameOrMatcher: any, options: any): boolean;
+    export function done(nameOrMatcher: any): any;
+}
+
+interface MockCall extends Array<string | RequestInit | undefined> {
+    0: string;
+    1: RequestInit | undefined;
+    identifier: string;
+    isUnmatched: boolean | undefined;
+    request: Request | undefined;
+    response: Response | undefined;
+}
+
+
+/**
+ * Returns a promise that resolves once all fetches handled by fetch-mock
+ * have resolved.
+ * @param [waitForBody] Wait for all body parsing methods(res.json(),
+ * res.text(), etc.) to resolve too.
+ */
+flush(waitForBody?: boolean): Promise<MockResponse[]>;
+
+/**
+ * Inspection filter. Can be one of the following:
+ * boolean:
+ *   * true retrieves all calls matched by fetch.
+ *     fetchMock.MATCHED is an alias for true and may be used to make tests
+ *     more readable.
+ *   * false retrieves all calls not matched by fetch (i.e. those handled
+ *     by catch() or spy(). fetchMock.UNMATCHED is an alias for false and
+ *     may be used to make tests more readable.
+ * MockMatcher (routeIdentifier):
+ *   All routes have an identifier:
+ *    * If it’s a named route, the identifier is the route’s name
+ *    * If the route is unnamed, the identifier is the matcher passed in to
+ *      .mock()
+ *   All calls that were handled by the route with the given identifier
+ *   will be retrieved
+ * MockMatcher (matcher):
+ *   Any matcher compatible with the mocking api can be passed in to filter
+ *   the calls arbitrarily.
+ */
+type InspectionFilter = MockMatcher | boolean;
+
+/**
+ * Either an object compatible with the mocking api or a string specifying
+ * a http method to filter by. This will be used to filter the list of
+ * calls further.
+ */
+type InspectionOptions = MockOptions | string;
+
+
+
+// /**
+//  * Returns an array of all calls to fetch matching the given filters.
+//  * Each call is returned as a [url, options] array. If fetch was called
+//  * using a Request instance, this will be available as a request
+//  * property on this array.
+//  * @param [filter] Allows filtering of calls to fetch based on various
+//  * criteria
+//  * @param [options] Either an object compatible with the mocking api or
+//  * a string specifying a http method to filter by. This will be used to
+//  * filter the list of calls further.
+//  */
+// calls(filter?: InspectionFilter, options?: InspectionOptions): MockCall[];
+
+// /**
+//  * Returns a Boolean indicating whether any calls to fetch matched the
+//  * given filter.
+//  * @param [filter] Allows filtering of calls to fetch based on various
+//  * criteria
+//  * @param [options] Either an object compatible with the mocking api or
+//  * a string specifying a http method to filter by. This will be used to
+//  * filter the list of calls further.
+//  */
+// called(filter?: InspectionFilter, options?: InspectionOptions): boolean;
+
+// /**
+//  * Returns a Boolean indicating whether fetch was called the expected
+//  * number of times (or has been called at least once if repeat is
+//  * undefined for the route).
+//  * @param [filter] Rule for matching calls to fetch.
+//  */
+// done(filter?: InspectionFilter): boolean;
+
+// /**
+//  * Returns the arguments for the last call to fetch matching the given
+//  * filter.
+//  * @param [filter] Allows filtering of calls to fetch based on various
+//  * criteria
+//  * @param [options] Either an object compatible with the mocking api or
+//  * a string specifying a http method to filter by. This will be used to
+//  * filter the list of calls further.
+//  */
+// lastCall(
+//     filter?: InspectionFilter,
+//     options?: InspectionOptions,
+// ): MockCall | undefined;
+
+// /**
+//  * Returns the url for the last call to fetch matching the given
+//  * filter. If fetch was last called using a Request instance, the url
+//  * will be extracted from this.
+//  * @param [filter] Allows filtering of calls to fetch based on various
+//  * criteria
+//  * @param [options] Either an object compatible with the mocking api or
+//  * a string specifying a http method to filter by. This will be used to
+//  * filter the list of calls further.
+//  */
+// lastUrl(
+//     filter?: InspectionFilter,
+//     options?: InspectionOptions,
+// ): string | undefined;
+
+// /**
+//  * Returns the options for the call to fetch matching the given filter.
+//  * If fetch was last called using a Request instance, a set of options
+//  * inferred from the Request will be returned.
+//  * @param [filter] Allows filtering of calls to fetch based on various
+//  * criteria
+//  * @param [options] Either an object compatible with the mocking api or
+//  * a string specifying a http method to filter by. This will be used to
+//  * filter the list of calls further.
+//  */
+// lastOptions(
+//     filter?: InspectionFilter,
+//     options?: InspectionOptions,
+// ): MockOptions | undefined;
+
+// /**
+//  * Returns the options for the call to fetch matching the given filter.
+//  * This is an experimental feature, very difficult to implement well given
+//  * fetch’s very private treatment of response bodies.
+//  * When doing all the following:
+//    -  using node-fetch
+//    -  responding with a real network response (using spy() or fallbackToNetwork)
+//    -  using `fetchMock.LastResponse()`
+//    -  awaiting the body content
+//        … the response will hang unless your source code also awaits the response body.
+//        This is an unavoidable consequence of the nodejs implementation of streams.
+//  * @param [filter] Allows filtering of calls to fetch based on various
+//  * criteria
+//  * @param [options] Either an object compatible with the mocking api or
+//  * a string specifying a http method to filter by. This will be used to
+//  * filter the list of calls further.
+//  */
+// lastResponse(
+//     filter?: InspectionFilter,
+//     options?: InspectionOptions,
+// ): Response | undefined;
+
diff --git a/packages/wip/old-types/FetchMockWrapper.d.ts b/packages/wip/old-types/FetchMockWrapper.d.ts
new file mode 100644
index 0000000..0d313fa
--- /dev/null
+++ b/packages/wip/old-types/FetchMockWrapper.d.ts
@@ -0,0 +1,84 @@
+interface FetchMockConfig {
+
+    /**
+     * Convert objects into JSON before delivering as stub responses.
+     * Can be useful to set to false globally if e.g. dealing with a
+     * lot of array buffers. If true, will also add
+     * content-type: application/json header.
+     * @default true
+     */
+    sendAsJson?: boolean;
+
+    /**
+     * Automatically sets a content-length header on each response.
+     * @default true
+     */
+    includeContentLength?: boolean;
+
+    // /**
+    //  * - true: Unhandled calls fall through to the network
+    //  * - false: Unhandled calls throw an error
+    //  * - 'always': All calls fall through to the network, effectively
+    //  * disabling fetch-mock.
+    //  * @default false
+    //  */
+    // fallbackToNetwork?: boolean | 'always';
+
+    /**
+     * Print a warning if any call is caught by a fallback handler (set
+     * using the fallbackToNetwork option or catch())
+     * @default true
+     */
+    warnOnFallback?: boolean;
+
+    /**
+     * Reference to a custom fetch implementation.
+     */
+    fetch?: (
+        input?: string | Request,
+        init?: RequestInit,
+    ) => Promise<Response>;
+
+    /**
+     * Reference to the Headers constructor of a custom fetch
+     * implementation.
+     */
+    Headers?: new () => Headers;
+
+    /**
+     * Reference to the Request constructor of a custom fetch
+     * implementation.
+     */
+    Request?: new (input: string | Request, init?: RequestInit) => Request;
+
+    /**
+     * Reference to the Response constructor of a custom fetch
+     * implementation.
+     */
+    Response?: new () => Response;
+}
+
+
+
+
+interface FetchMockInstance {
+
+    // MATCHED: true;
+    // UNMATCHED: false;
+
+
+
+    /**
+     * Returns a promise that resolves once all fetches handled by fetch-mock
+     * have resolved.
+     * @param [waitForBody] Wait for all body parsing methods(res.json(),
+     * res.text(), etc.) to resolve too.
+     */
+    flush(waitForBody?: boolean): Promise<MockResponse[]>;
+
+    statusTextMap: {
+        [key: number]: string
+    }
+
+    config: FetchMockConfig;
+}
\ No newline at end of file
diff --git a/packages/wip/old-types/RequestUtils.d.ts b/packages/wip/old-types/RequestUtils.d.ts
new file mode 100644
index 0000000..314012f
--- /dev/null
+++ b/packages/wip/old-types/RequestUtils.d.ts
@@ -0,0 +1,27 @@
+export function normalizeUrl(url: any): any;
+/**
+ *
+ * @param {string|Request} urlOrRequest
+ * @param {Object} options
+ * @param {Class} Request
+ * @returns
+ */
+export function normalizeRequest(urlOrRequest: string | Request, options: Object, Request: any): {
+    url: any;
+    options: {
+        method: any;
+    } & Object;
+    request: RequestInfo;
+    signal: any;
+} | {
+    url: any;
+    options: Object;
+    signal: any;
+};
+export function getPath(url: any): string;
+export function getQuery(url: any): string;
+export namespace headers {
+    export function normalize(headers: any): any;
+    export function toLowerCase(headers: any): {};
+    export function equal(actualHeader: any, expectedHeader: any): any;
+}
diff --git a/packages/wip/old-types/ResponseBuilder.d.ts b/packages/wip/old-types/ResponseBuilder.d.ts
new file mode 100644
index 0000000..4f5665d
--- /dev/null
+++ b/packages/wip/old-types/ResponseBuilder.d.ts
@@ -0,0 +1,54 @@
+
+/**
+ * Mock response object
+ */
+interface MockResponseObject {
+    /**
+     * Set the response body
+     */
+    body?: string | {};
+
+    /**
+     * Set the response status
+     * @default 200
+     */
+    status?: number;
+
+    /**
+     * Set the response headers.
+     */
+    headers?: { [key: string]: string };
+
+    /**
+     * If this property is present then a Promise rejected with the value
+     * of throws is returned
+     */
+    throws?: Error;
+
+    /**
+     * The URL the response should be from (to imitate followed redirects
+     *  - will set redirected: true on the response)
+     */
+    redirectUrl?: string;
+}
+
+/**
+ * Response: A Response instance - will be used unaltered
+ * number: Creates a response with this status
+ * string: Creates a 200 response with the string as the response body
+ * object: As long as the object is not a MockResponseObject it is
+ *  converted into a json string and returned as the body of a 200 response
+ * If MockResponseObject was given then it's used to configure response
+ * Function(url, opts): A function that is passed the url and opts fetch()
+ *  is called with and that returns any of the responses listed above
+ */
+type MockResponse = Response | Promise<Response>
+    | number | Promise<number>
+    | string | Promise<string>
+    | {} | Promise<{}>
+    | MockResponseObject | Promise<MockResponseObject>;
+
+/**
+ * Mock response function
+ */
+type MockResponseFunction = (url: string, opts: MockRequest) => MockResponse;
diff --git a/packages/wip/old-types/StatusTextMap.d.ts b/packages/wip/old-types/StatusTextMap.d.ts
new file mode 100644
index 0000000..b98f5db
--- /dev/null
+++ b/packages/wip/old-types/StatusTextMap.d.ts
@@ -0,0 +1,7 @@
+export default statusTextMap;
+/**
+ * @type {Object.<number, string>}
+ */
+declare const statusTextMap: {
+    [x: number]: string;
+};
diff --git a/packages/wip/old-types/index.d.ts b/packages/wip/old-types/index.d.ts
new file mode 100644
index 0000000..3ed4b3f
--- /dev/null
+++ b/packages/wip/old-types/index.d.ts
@@ -0,0 +1,16 @@
+// TypeScript Version: 2.2
+import Route from "./Route";
+import CallHistory from "./CallHistory";
+import FetchHandler from "./FetchHandler";
+import FetchMockWrapper from "./FetchMockWrapper";
+import RequestUtils from "./RequestUtils";
+import ResponseBuilder from "./ResponseBuilder";
+import Router from "./Router";
+import StatusTextMap from "./StatusTextMap";
+
+
+
+
+   
+
+
diff --git a/release-please-config.json b/release-please-config.json
index 3db06c8..71a158d 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -29,7 +29,8 @@
     "node-workspace"
   ],
   "packages": {
-    "packages/fetch-mock": {}
+    "packages/fetch-mock": {},
+    "packages/core": {},
   },
   "bootstrap-sha": "32d6924d0ac2587dc2fd4860aa0e87a4288fb5c1",
   "pull-request-title-pattern": "build${scope}: release${component} ${version}",


Test patch:
diff --git a/packages/core/src/__tests__/CallHistory.test.js b/packages/core/src/__tests__/CallHistory.test.js
new file mode 100644
index 0000000..889e66d
--- /dev/null
+++ b/packages/core/src/__tests__/CallHistory.test.js
@@ -0,0 +1,469 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import fetchMock from '../FetchMock';
+
+describe('CallHistory', () => {
+	let fm;
+	beforeEach(() => {
+		fm = fetchMock.createInstance();
+	});
+
+	const fetchTheseUrls = (...urls) =>
+		Promise.all(urls.map(fm.fetchHandler.bind(fm)));
+
+	describe('helper methods', () => {
+		describe('called()', () => {
+			it('returns a suitable boolean', () => {
+				fm.catch();
+				expect(fm.callHistory.called()).toBe(false);
+				fm.fetchHandler('http://a.com');
+				expect(fm.callHistory.called()).toBe(true);
+			});
+
+			it('passes filters through to calls()', () => {
+				fm.catch();
+				vi.spyOn(fm.callHistory, 'calls');
+				fm.fetchHandler('http://a.com');
+				expect(fm.callHistory.called('http://a.com')).toBe(true);
+				expect(fm.callHistory.calls).toHaveBeenCalledWith(
+					'http://a.com',
+					undefined,
+				);
+				expect(fm.callHistory.called('http://b.com')).toBe(false);
+				expect(fm.callHistory.calls).toHaveBeenCalledWith(
+					'http://b.com',
+					undefined,
+				);
+				expect(fm.callHistory.called('http://a.com', { method: 'get' })).toBe(
+					true,
+				);
+				expect(fm.callHistory.calls).toHaveBeenCalledWith('http://a.com', {
+					method: 'get',
+				});
+				expect(fm.callHistory.called('http://a.com', { method: 'post' })).toBe(
+					false,
+				);
+				expect(fm.callHistory.calls).toHaveBeenCalledWith('http://a.com', {
+					method: 'post',
+				});
+			});
+		});
+		describe('lastCall()', () => {
+			it('returns the call log for the last call', () => {
+				fm.catch();
+				fm.fetchHandler('http://a.com');
+				fm.fetchHandler('http://b.com');
+				expect(fm.callHistory.lastCall().url).toEqual('http://b.com/');
+			});
+			it('passes filters through to calls()', () => {
+				fm.catch();
+				vi.spyOn(fm.callHistory, 'calls');
+				fm.fetchHandler('http://a.com');
+				expect(fm.callHistory.lastCall('http://a.com').url).toEqual(
+					'http://a.com/',
+				);
+				expect(fm.callHistory.calls).toHaveBeenCalledWith(
+					'http://a.com',
+					undefined,
+				);
+				expect(fm.callHistory.lastCall('http://b.com')).toBe(undefined);
+				expect(fm.callHistory.calls).toHaveBeenCalledWith(
+					'http://b.com',
+					undefined,
+				);
+				expect(
+					fm.callHistory.lastCall('http://a.com', { method: 'get' }).url,
+				).toEqual('http://a.com/');
+				expect(fm.callHistory.calls).toHaveBeenCalledWith('http://a.com', {
+					method: 'get',
+				});
+				expect(
+					fm.callHistory.lastCall('http://a.com', { method: 'post' }),
+				).toBe(undefined);
+				expect(fm.callHistory.calls).toHaveBeenCalledWith('http://a.com', {
+					method: 'post',
+				});
+			});
+		});
+
+		describe('calls()', () => {
+			it('retrieves all calls by default', async () => {
+				fm.route('http://a.com/', 200).catch();
+
+				await fetchTheseUrls('http://a.com/', 'http://b.com/');
+				expect(fm.callHistory.calls().length).toEqual(2);
+			});
+
+			it('retrieves calls in correct order', () => {
+				fm.catch();
+
+				fm.fetchHandler('http://a.com/');
+				fm.fetchHandler('http://b.com/');
+				fm.fetchHandler('http://c.com/');
+				expect(fm.callHistory.calls()[0].url).toEqual('http://a.com/');
+				expect(fm.callHistory.calls()[1].url).toEqual('http://b.com/');
+				expect(fm.callHistory.calls()[2].url).toEqual('http://c.com/');
+			});
+
+			describe('returned values', () => {
+				it('returns call log objects', async () => {
+					fm.catch();
+
+					await fm.fetchHandler('http://a.com/', { method: 'get' });
+
+					expect(fm.callHistory.calls()[0]).toEqual(
+						expect.objectContaining({
+							url: 'http://a.com/',
+							options: { method: 'get' },
+						}),
+					);
+				});
+
+				it('when called with Request instance', () => {
+					fm.catch();
+					const req = new Request('http://a.com/', {
+						method: 'post',
+					});
+					fm.fetchHandler(req);
+					expect(fm.callHistory.calls()[0]).toEqual(
+						expect.objectContaining({
+							url: 'http://a.com/',
+							options: expect.objectContaining({ method: 'POST' }),
+							request: req,
+						}),
+					);
+				});
+				it('when called with Request instance and arbitrary option', () => {
+					fm.catch();
+					const req = new Request('http://a.com/', {
+						method: 'POST',
+					});
+					fm.fetchHandler(req, { arbitraryOption: true });
+					expect(fm.callHistory.calls()[0]).toEqual(
+						expect.objectContaining({
+							url: 'http://a.com/',
+							options: expect.objectContaining({
+								method: 'POST',
+								arbitraryOption: true,
+							}),
+							request: req,
+						}),
+					);
+				});
+				// Not sure why this was in the old test suite
+				it.skip('Not make default signal available in options when called with Request instance using signal', () => {
+					fm.catch();
+					const req = new Request('http://a.com/', {
+						method: 'POST',
+					});
+					fm.fetchHandler(req);
+					console.log(fm.callHistory.calls()[0]);
+					expect(fm.callHistory.calls()[0].signal).toBeUndefined();
+				});
+
+				describe('retrieving responses', () => {
+					it('exposes responses', async () => {
+						fm.once('*', 200).once('*', 201);
+
+						await fm.fetchHandler('http://a.com/');
+						await fm.fetchHandler('http://a.com/');
+						expect(fm.callHistory.calls()[0].response.status).toEqual(200);
+						expect(fm.callHistory.calls()[1].response.status).toEqual(201);
+					});
+
+					it('exposes Responses', async () => {
+						fm.once('*', new fm.config.Response('blah'));
+
+						await fm.fetchHandler('http://a.com/');
+						expect(fm.callHistory.calls()[0].response.status).toEqual(200);
+						await expect(
+							fm.callHistory.calls()[0].response.text(),
+						).resolves.toEqual('blah');
+					});
+
+					// functionality deliberately not implemented yet
+					it.skip('has readable response when response already read if using lastResponse', async () => {
+						const respBody = { foo: 'bar' };
+						fm.once('*', { status: 200, body: respBody }).once('*', 201, {
+							overwriteRoutes: false,
+						});
+
+						const resp = await fm.fetchHandler('http://a.com/');
+
+						await resp.json();
+						expect(await fm.lastResponse().json()).toEqual(respBody);
+					});
+				});
+			});
+
+			describe('filters', () => {
+				const expectSingleUrl =
+					(...filter) =>
+					(url) => {
+						const filteredCalls = fm.callHistory.calls(...filter);
+						expect(filteredCalls.length).toEqual(1);
+						expect(filteredCalls[0].url).toEqual(url);
+					};
+
+				describe('boolean and named route filters', () => {
+					it('can retrieve calls matched by non-fallback routes', async () => {
+						fm.route('http://a.com/', 200).catch();
+
+						await fetchTheseUrls('http://a.com/', 'http://b.com/');
+						expectSingleUrl(true)('http://a.com/');
+						expectSingleUrl('matched')('http://a.com/');
+					});
+
+					it('can retrieve calls matched by the fallback route', async () => {
+						fm.route('http://a.com/', 200).catch();
+
+						await fetchTheseUrls('http://a.com/', 'http://b.com/');
+						expectSingleUrl(false)('http://b.com/');
+						expectSingleUrl('unmatched')('http://b.com/');
+					});
+
+					it('can retrieve only calls handled by a named route', async () => {
+						fm.route('http://a.com/', 200, { name: 'a' }).catch();
+
+						await fetchTheseUrls('http://a.com/', 'http://b.com/');
+						expectSingleUrl('a')('http://a.com/');
+					});
+				});
+
+				describe('filtering with a matcher', () => {
+					//TODO write a test that just makes it clear this is contracted out to Route
+					// spy on route constructor, and then on matcher for that route
+					it('should be able to filter with a url matcher', async () => {
+						fm.catch();
+						await fm.fetchHandler('http://a.com/');
+						await fm.fetchHandler('http://b.com/');
+						expectSingleUrl('begin:http://a')('http://a.com/');
+					});
+					it('should be able to filter with a method', async () => {
+						fm.catch();
+						await fm.fetchHandler('http://a.com/', { method: 'get' });
+						await fm.fetchHandler('http://b.com/', { method: 'post' });
+						expectSingleUrl({ method: 'GET' })('http://a.com/');
+					});
+					it('should be able to filter with headers', async () => {
+						fm.catch();
+						await fm.fetchHandler('http://a.com/', { headers: { a: 'val' } });
+						await fm.fetchHandler('http://b.com/', { headers: { b: 'val' } });
+						expectSingleUrl({ headers: { a: 'val' } })('http://a.com/');
+					});
+					it('should be able to combine with options object', async () => {
+						fm.catch();
+						await fm.fetchHandler('http://a.com/', { headers: { a: 'val' } });
+						await fm.fetchHandler('http://a.com/', { headers: { b: 'val' } });
+						await fm.fetchHandler('http://b.com/', { headers: { b: 'val' } });
+						expectSingleUrl('http://a.com/', { headers: { a: 'val' } })(
+							'http://a.com/',
+						);
+					});
+				});
+
+				describe('filtering with options', () => {
+					it('can retrieve all calls', async () => {
+						fm.route('http://a.com/', 200).catch();
+
+						await fm.fetchHandler('http://a.com/', {
+							headers: { a: 'z' },
+						});
+						await fm.fetchHandler('http://a.com/');
+						await fm.fetchHandler('http://b.com/', {
+							headers: { a: 'z' },
+						});
+						await fm.fetchHandler('http://b.com/');
+						const filteredCalls = fm.callHistory.calls(undefined, {
+							headers: { a: 'z' },
+						});
+						expect(filteredCalls.length).toEqual(2);
+						filteredCalls.forEach(({ options }) =>
+							expect(options.headers.a).toEqual('z'),
+						);
+					});
+
+					it('can retrieve calls matched by non-fallback routes', async () => {
+						fm.route('http://a.com/', 200).catch();
+
+						await fm.fetchHandler('http://a.com/', {
+							headers: { a: 'z' },
+						});
+						await fm.fetchHandler('http://a.com/');
+						await fm.fetchHandler('http://b.com/', {
+							headers: { a: 'z' },
+						});
+						await fm.fetchHandler('http://b.com/');
+						const filteredCalls = fm.callHistory.calls(true, {
+							headers: { a: 'z' },
+						});
+						expect(filteredCalls.length).toEqual(1);
+						expect(filteredCalls[0]).toMatchObject(
+							expect.objectContaining({
+								url: 'http://a.com/',
+								options: {
+									headers: { a: 'z' },
+								},
+							}),
+						);
+					});
+
+					it('can retrieve calls matched by the fallback route', async () => {
+						fm.route('http://a.com/', 200).catch();
+
+						await fm.fetchHandler('http://a.com/', {
+							headers: { a: 'z' },
+						});
+						await fm.fetchHandler('http://a.com/');
+						await fm.fetchHandler('http://b.com/', {
+							headers: { a: 'z' },
+						});
+						await fm.fetchHandler('http://b.com/');
+						const filteredCalls = fm.callHistory.calls(false, {
+							headers: { a: 'z' },
+						});
+						expect(filteredCalls.length).toEqual(1);
+						expect(filteredCalls[0]).toMatchObject(
+							expect.objectContaining({
+								url: 'http://b.com/',
+								options: {
+									headers: { a: 'z' },
+								},
+							}),
+						);
+					});
+
+					it('can retrieve only calls handled by a named route', async () => {
+						fm.route('http://a.com/', 200, { name: 'here' }).catch();
+
+						await fm.fetchHandler('http://a.com/', {
+							headers: { a: 'z' },
+						});
+						await fm.fetchHandler('http://a.com/');
+						const filteredCalls = fm.callHistory.calls('here', {
+							headers: { a: 'z' },
+						});
+						expect(filteredCalls.length).toEqual(1);
+						expect(filteredCalls[0]).toMatchObject(
+							expect.objectContaining({
+								url: 'http://a.com/',
+								options: {
+									headers: { a: 'z' },
+								},
+							}),
+						);
+					});
+				});
+			});
+
+			describe('done()', () => {
+				let fm;
+
+				it('clearHistory() resets count done-ness', async () => {
+					fm = fetchMock.createInstance().route('http://a.com/', 200);
+					await fm.fetchHandler('http://a.com/');
+					expect(fm.done()).toBe(true);
+					fm.clearHistory();
+					expect(fm.done()).toBe(false);
+					await fm.fetchHandler('http://a.com/');
+					expect(fm.done()).toBe(true);
+				});
+
+				describe('where number of expected calls is not specified', () => {
+					beforeEach(() => {
+						fm = fetchMock.createInstance();
+						// Note that the c route is unnamed, mainly for verifying that the presence of unnamed routtes does not lead to errors
+						fm.route('http://a.com/', 200, 'a')
+							.route('http://b.com/', 200, 'b')
+							.route('http://c.com/', 200);
+					});
+
+					it('can expect at least one call to have been made to every defined route', () => {
+						expect(fm.done()).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done()).toBe(false);
+						fm.fetchHandler('http://b.com/');
+						expect(fm.done()).toBe(false);
+						fm.fetchHandler('http://c.com/');
+						expect(fm.done()).toBe(true);
+					});
+
+					it('can expect a named route to be called at least once', () => {
+						expect(fm.done('a')).toBe(false);
+						expect(fm.done('b')).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done('a')).toBe(true);
+						expect(fm.done('b')).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done('a')).toBe(true);
+						expect(fm.done('b')).toBe(false);
+					});
+
+					it('can expect multiple named routes to be called at least once each', () => {
+						expect(fm.done(['a', 'b'])).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done(['a', 'b'])).toBe(false);
+						fm.fetchHandler('http://b.com/');
+						expect(fm.done(['a', 'b'])).toBe(true);
+					});
+				});
+				describe('where number of expected calls is specified', () => {
+					beforeEach(() => {
+						fm = fetchMock.createInstance();
+						// Note that the c route is unnamed, mainly for verifying that the presence of unnamed routtes does not lead to errors
+						fm.route('http://a.com/', 200, { name: 'a', repeat: 2 })
+							.route('http://b.com/', 200, { name: 'b', repeat: 1 })
+							.route('http://c.com/', 200, { repeat: 2 });
+					});
+
+					it('can expect a named route to be called specified number of times', () => {
+						expect(fm.done('a')).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done('a')).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done('a')).toBe(true);
+					});
+
+					it('can expect multiple named routes to be called specified number of times', () => {
+						expect(fm.done(['a', 'b'])).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						fm.fetchHandler('http://b.com/');
+						expect(fm.done(['a', 'b'])).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done(['a', 'b'])).toBe(true);
+					});
+
+					it('can expect specific number of calls to have been made to every defined route', () => {
+						expect(fm.done()).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						fm.fetchHandler('http://b.com/');
+						fm.fetchHandler('http://c.com/');
+						expect(fm.done()).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done()).toBe(false);
+						fm.fetchHandler('http://c.com/');
+						expect(fm.done()).toBe(true);
+					});
+
+					it('can combine with routes where specific number of calls is unspecified', () => {
+						fm.removeRoutes();
+						fm.clearHistory();
+						fm.route('http://a.com/', 200, { name: 'a', repeat: 2 })
+							.route('http://b.com/', 200, { name: 'b' })
+							.route('http://c.com/', 200);
+
+						expect(fm.done()).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						fm.fetchHandler('http://b.com/');
+						fm.fetchHandler('http://c.com/');
+						expect(fm.done()).toBe(false);
+						expect(fm.done(['a', 'b'])).toBe(false);
+						fm.fetchHandler('http://a.com/');
+						expect(fm.done()).toBe(true);
+						expect(fm.done(['a', 'b'])).toBe(true);
+					});
+				});
+			});
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/FetchMock/flush.test.js b/packages/core/src/__tests__/FetchMock/flush.test.js
new file mode 100644
index 0000000..6c7b426
--- /dev/null
+++ b/packages/core/src/__tests__/FetchMock/flush.test.js
@@ -0,0 +1,93 @@
+import { describe, expect, it, beforeEach } from 'vitest';
+
+import fetchMock from '../../FetchMock';
+describe('FetchMockWrapper.js', () => {
+	describe('flushing pending calls', () => {
+		let fm;
+		beforeEach(() => {
+			fm = fetchMock.createInstance();
+		});
+
+		it('flush resolves if all fetches have resolved', async () => {
+			fm.route('http://one.com/', 200).route('http://two.com/', 200);
+			// no expectation, but if it doesn't work then the promises will hang
+			// or reject and the test will timeout
+			await fm.flush();
+			fm.fetchHandler('http://one.com');
+			await fm.flush();
+			fm.fetchHandler('http://two.com');
+			await fm.flush();
+		});
+
+		it('should resolve after fetches', async () => {
+			fm.route('http://example', 'working!');
+			let data;
+			fm.fetchHandler('http://example').then(() => {
+				data = 'done';
+			});
+			await fm.flush();
+			expect(data).toEqual('done');
+		});
+
+		describe('response methods', () => {
+			it('should resolve after .text() if waitForResponseMethods option passed', async () => {
+				fm.route('http://example/', 'working!');
+				let data = 'not set';
+				fm.fetchHandler('http://example/').then(async (res) => {
+					await res.text();
+					data = 'done';
+				});
+
+				await fm.flush(true);
+				expect(data).toEqual('done');
+			});
+			it('should resolve after .json() if waitForResponseMethods option passed', async () => {
+				fm.route('http://example/', { a: 'ok' });
+				let data;
+				fm.fetchHandler('http://example/')
+					.then((res) => res.json())
+					.then(() => {
+						data = 'done';
+					});
+
+				await fm.flush(true);
+				expect(data).toEqual('done');
+			});
+
+			it('should resolve after .json() if waitForResponseMethods option passed, but contains invalid json', async () => {
+				fm.route('http://example/', 'bleurgh');
+				let data;
+				fm.fetchHandler('http://example/')
+					.then((res) => res.json())
+					.catch(() => {
+						data = 'done';
+					});
+
+				await fm.flush(true);
+				expect(data).toEqual('done');
+			});
+		});
+
+		it('flush waits for unresolved promises', async () => {
+			fm.route('http://one.com/', 200).route(
+				'http://two.com/',
+				() => new Promise((res) => setTimeout(() => res(200), 50)),
+			);
+
+			const orderedResults = [];
+			fm.fetchHandler('http://one.com/');
+			fm.fetchHandler('http://two.com/');
+
+			setTimeout(() => orderedResults.push('not flush'), 25);
+
+			await fm.flush();
+			orderedResults.push('flush');
+			expect(orderedResults).toEqual(['not flush', 'flush']);
+		});
+
+		it('flush resolves on expected error', async () => {
+			fm.route('http://one.com/', { throws: 'Problem in space' });
+			await fm.flush();
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/FetchMock/instance-management.test.js b/packages/core/src/__tests__/FetchMock/instance-management.test.js
new file mode 100644
index 0000000..cdd6b8c
--- /dev/null
+++ b/packages/core/src/__tests__/FetchMock/instance-management.test.js
@@ -0,0 +1,159 @@
+import { describe, expect, it, beforeEach } from 'vitest';
+import fetchMock from '../../FetchMock';
+
+describe('instance management', () => {
+	describe('instance isolation', () => {
+		it('not be the parent', () => {
+			const child = fetchMock.createInstance();
+			expect(child).not.toBe(fetchMock);
+		});
+		it('inherit settings from parent instance', () => {
+			const parent = fetchMock.createInstance();
+			parent.config.Headers = { example: true };
+			const child = parent.createInstance();
+			expect(child.config.Headers).toEqual({ example: true });
+		});
+
+		it('implement full fetch-mock api', () => {
+			const child = fetchMock.createInstance();
+			//eslint-disable-next-line guard-for-in
+			for (const key in fetchMock) {
+				expect(typeof child[key]).toEqual(typeof fetchMock[key]);
+			}
+		});
+
+		it('has an isolated router', async () => {
+			const parent = fetchMock.createInstance();
+			const child = parent.createInstance();
+
+			parent.route('http://a.com', 200).catch(404);
+			child.route('http://b.com', 200).catch(404);
+
+			await expect(parent.fetchHandler('http://a.com')).resolves.toMatchObject({
+				status: 200,
+			});
+			await expect(parent.fetchHandler('http://b.com')).resolves.toMatchObject({
+				status: 404,
+			});
+			await expect(child.fetchHandler('http://a.com')).resolves.toMatchObject({
+				status: 404,
+			});
+			await expect(child.fetchHandler('http://b.com')).resolves.toMatchObject({
+				status: 200,
+			});
+		});
+
+		it('can extend a router', async () => {
+			const parent = fetchMock.createInstance();
+			parent.route('http://a.com', 200);
+			const child = parent.createInstance();
+			child.route('http://b.com', 200);
+			await expect(parent.fetchHandler('http://a.com')).resolves.toMatchObject({
+				status: 200,
+			});
+			await expect(parent.fetchHandler('http://b.com')).rejects.toThrow();
+			await expect(child.fetchHandler('http://a.com')).resolves.toMatchObject({
+				status: 200,
+			});
+			await expect(child.fetchHandler('http://b.com')).resolves.toMatchObject({
+				status: 200,
+			});
+		});
+
+		it('inherits fallback routes', async () => {
+			const parent = fetchMock.createInstance().catch(404);
+			const child = parent.createInstance();
+			await expect((await child.fetchHandler('http://a.com')).status).toBe(404);
+		});
+
+		it('has an isolated call history', async () => {
+			const parent = fetchMock.createInstance().route('http://a.com', 200);
+			const child = parent.createInstance();
+
+			await parent.fetchHandler('http://a.com');
+			expect(parent.callHistory.callLogs.length).toBe(1);
+			expect(child.callHistory.callLogs.length).toBe(0);
+			await child.fetchHandler('http://a.com');
+			expect(parent.callHistory.callLogs.length).toBe(1);
+			expect(child.callHistory.callLogs.length).toBe(1);
+		});
+		it('does not inherit call history', async () => {
+			const parent = fetchMock.createInstance().route('http://a.com', 200);
+			await parent.fetchHandler('http://a.com');
+			const child = parent.createInstance();
+			expect(parent.callHistory.callLogs.length).toBe(1);
+			expect(child.callHistory.callLogs.length).toBe(0);
+		});
+	});
+	describe('resetting', () => {
+		it('can have all its routes removed', async () => {
+			const fm = fetchMock
+				.createInstance()
+				.route('http://a.com', 200)
+				.route('http://b.com', 200);
+
+			await fm.fetchHandler('http://a.com');
+			fm.removeRoutes();
+			expect(fm.router.routes.length).toBe(0);
+			// should have no effect on call history - this is a low level API
+			// and in user APIs provided wrappers these will probably be combined
+			// but in here keeping them separate gives more flexibilty
+			expect(fm.callHistory.callLogs.length).toBe(1);
+		});
+
+		it('can have selected named routes removed', () => {
+			const fm = fetchMock
+				.createInstance()
+				.route('http://a.com', 200, 'george')
+				.route('http://b.com', 200, 'best');
+			// TODO overload to also support 'george' or ['george']'
+			// Probably do the normalization at the FM level
+			fm.removeRoutes({ names: ['george'] });
+			expect(fm.router.routes[0].config.name).toBe('best');
+		});
+
+		it('can retain the fallback route', () => {
+			const fm = fetchMock.createInstance().catch(404);
+			fm.removeRoutes({ includeFallback: false });
+			expect(fm.router.fallbackRoute).toBeDefined();
+			fm.removeRoutes();
+			expect(fm.router.fallbackRoute).toBeUndefined();
+		});
+
+		it('can force removal of sticky routes', () => {
+			const fm = fetchMock
+				.createInstance()
+				.route('http://a.com', 200)
+				.route('http://b.com', 200, { sticky: true, name: 'sticky' });
+			fm.removeRoutes();
+			expect(fm.router.routes[0].config.name).toBe('sticky');
+			fm.removeRoutes({ includeSticky: true });
+			expect(fm.router.routes.length).toBe(0);
+		});
+
+		it('can have its call history wiped', async () => {
+			const fm = fetchMock.createInstance().route('http://a.com', 200);
+			await fm.fetchHandler('http://a.com');
+			fm.clearHistory();
+			expect(fm.callHistory.callLogs.length).toBe(0);
+		});
+	});
+
+	describe('sticky routes', () => {
+		let fm;
+		beforeEach(() => {
+			fm = fetchMock.createInstance();
+		});
+		it('do not get removed by default', () => {
+			fm.route('*', 200, { sticky: true }).removeRoutes();
+			expect(fm.router.routes.length).toEqual(1);
+		});
+
+		it('get removed when forced', () => {
+			fm.route('*', 200, { sticky: true }).removeRoutes({
+				includeSticky: true,
+			});
+			expect(fm.router.routes.length).toEqual(0);
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js
new file mode 100644
index 0000000..c4bb565
--- /dev/null
+++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js
@@ -0,0 +1,292 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import fetchMock from '../../FetchMock';
+
+describe('response generation', () => {
+	let fm;
+	beforeEach(() => {
+		fm = fetchMock.createInstance();
+		fm.config.warnOnUnmatched = false;
+	});
+
+	describe('status', () => {
+		it('respond with a status', async () => {
+			fm.route('*', 300);
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.status).toEqual(300);
+			expect(res.statusText).toEqual('Multiple Choices');
+		});
+
+		it('should error on invalid statuses', async () => {
+			fm.route('*', { status: 'not number' });
+			try {
+				await fm.fetchHandler('http://a.com');
+				expect.unreachable('Line above should throw');
+			} catch (err) {
+				expect(err.message).toMatch(
+					/Invalid status not number passed on response object/,
+				);
+			}
+		});
+	});
+
+	describe('string', () => {
+		it('respond with a string', async () => {
+			fm.route('*', 'a string');
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.status).toEqual(200);
+			expect(res.statusText).toEqual('OK');
+			expect(await res.text()).toEqual('a string');
+		});
+
+		it('respond with an empty string', async () => {
+			fm.route('*', '');
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.status).toEqual(200);
+			expect(res.statusText).toEqual('OK');
+			expect(await res.text()).toEqual('');
+		});
+	});
+
+	describe('json', () => {
+		it('respond with a json', async () => {
+			fm.route('*', { an: 'object' });
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.status).toEqual(200);
+			expect(res.statusText).toEqual('OK');
+			expect(res.headers.get('content-type')).toEqual('application/json');
+			expect(await res.json()).toEqual({ an: 'object' });
+		});
+
+		it('convert body properties to json', async () => {
+			fm.route('*', {
+				body: { an: 'object' },
+			});
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-type')).toEqual('application/json');
+			expect(await res.json()).toEqual({ an: 'object' });
+		});
+
+		it('not overide existing content-type-header', async () => {
+			fm.route('*', {
+				body: { an: 'object' },
+				headers: {
+					'content-type': 'text/html',
+				},
+			});
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-type')).toEqual('text/html');
+			expect(await res.json()).toEqual({ an: 'object' });
+		});
+
+		it('not convert if `body` property exists', async () => {
+			fm.route('*', { body: 'exists' });
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-type')).not.toEqual('application/json');
+		});
+
+		it('not convert if `headers` property exists', async () => {
+			fm.route('*', { headers: {} });
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-type')).toBeNull();
+		});
+
+		it('not convert if `status` property exists', async () => {
+			fm.route('*', { status: 300 });
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-type')).toBeNull();
+		});
+
+		it('convert if non-whitelisted property exists', async () => {
+			fm.route('*', { status: 300, weird: true });
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-type')).toEqual('application/json');
+		});
+
+		describe('sendAsJson option', () => {
+			it('convert object responses to json by default', async () => {
+				fm.route('*', { an: 'object' });
+				const res = await fm.fetchHandler('http://it.at.there');
+				expect(res.headers.get('content-type')).toEqual('application/json');
+			});
+
+			it("don't convert when configured false", async () => {
+				fm.config.sendAsJson = false;
+				fm.route('*', { an: 'object' });
+				const res = await fm.fetchHandler('http://it.at.there');
+				// can't check for existence as the spec says, in the browser, that
+				// a default value should be set
+				expect(res.headers.get('content-type')).not.toEqual('application/json');
+			});
+
+			it('local setting can override to true', async () => {
+				fm.config.sendAsJson = false;
+				fm.route('*', { an: 'object' }, { sendAsJson: true });
+				const res = await fm.fetchHandler('http://it.at.there');
+				expect(res.headers.get('content-type')).toEqual('application/json');
+			});
+
+			it('local setting can override to false', async () => {
+				fm.config.sendAsJson = true;
+				fm.route('*', { an: 'object' }, { sendAsJson: false });
+				const res = await fm.fetchHandler('http://it.at.there');
+				// can't check for existence as the spec says, in the browser, that
+				// a default value should be set
+				expect(res.headers.get('content-type')).not.toEqual('application/json');
+			});
+		});
+	});
+
+	it('respond with a complex response, including headers', async () => {
+		fm.route('*', {
+			status: 202,
+			body: { an: 'object' },
+			headers: {
+				header: 'val',
+			},
+		});
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(202);
+		expect(res.headers.get('header')).toEqual('val');
+		expect(await res.json()).toEqual({ an: 'object' });
+	});
+
+	if (typeof Buffer !== 'undefined') {
+		it('can respond with a buffer', () => {
+			fm.route(/a/, new Buffer('buffer'), { sendAsJson: false });
+			return fm
+				.fetchHandler('http://a.com')
+				.then((res) => res.text())
+				.then((txt) => {
+					expect(txt).to.equal('buffer');
+				});
+		});
+	}
+
+	it('respond with blob', async () => {
+		const blob = new Blob();
+		fm.route('*', blob, { sendAsJson: false });
+		const res = await fm.fetchHandler('http://a.com');
+		expect(res.status).to.equal(200);
+		const blobData = await res.blob();
+		expect(blobData).to.eql(blob);
+	});
+
+	it('should set the url property on responses', async () => {
+		fm.route('begin:http://foo.com', 200);
+		const res = await fm.fetchHandler('http://foo.com/path?query=string');
+		expect(res.url).toEqual('http://foo.com/path?query=string');
+	});
+
+	it('should set the url property on responses when called with a Request object', async () => {
+		fm.route('begin:http://foo.com', 200);
+		const res = await fm.fetchHandler(
+			new Request('http://foo.com/path?query=string'),
+		);
+		expect(res.url).toEqual('http://foo.com/path?query=string');
+	});
+	it('respond with a redirected response', async () => {
+		fm.route('*', {
+			redirectUrl: 'http://b.com',
+			body: 'I am a redirect',
+		});
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.redirected).toEqual(true);
+		expect(res.url).toEqual('http://b.com');
+		expect(await res.text()).toEqual('I am a redirect');
+	});
+
+	it('construct a response based on the request', async () => {
+		fm.route('*', (url, opts) => url + opts.headers.header);
+		const res = await fm.fetchHandler('http://a.com/', {
+			headers: { header: 'val' },
+		});
+		expect(res.status).toEqual(200);
+		expect(await res.text()).toEqual('http://a.com/val');
+	});
+
+	it('construct a response based on a Request instance', async () => {
+		fm.route('*', (url, opts, request) => request.json().then(({ a }) => a));
+		const res = await fm.fetchHandler(
+			new fm.config.Request('http://a.com', {
+				body: JSON.stringify({ a: 'b' }),
+				method: 'post',
+			}),
+		);
+		expect(res.status).toEqual(200);
+		expect(await res.text()).toEqual('b');
+	});
+
+	describe('content-length', () => {
+		it('should work on body of type string', async () => {
+			fm.route('*', 'content');
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-length')).toEqual('7');
+		});
+
+		it('should work on body of type object', async () => {
+			fm.route('*', { hello: 'world' });
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-length')).toEqual('17');
+		});
+
+		it('should not overrule explicit mocked content-length header', async () => {
+			fm.route('*', {
+				body: {
+					hello: 'world',
+				},
+				headers: {
+					'Content-Length': '100',
+				},
+			});
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-length')).toEqual('100');
+		});
+
+		it('should be case-insensitive when checking for explicit content-length header', async () => {
+			fm.route('*', {
+				body: {
+					hello: 'world',
+				},
+				headers: {
+					'CoNtEnT-LeNgTh': '100',
+				},
+			});
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.headers.get('content-length')).toEqual('100');
+		});
+
+		describe('includeContentLength option', () => {
+			let fm;
+			beforeEach(() => {
+				fm = fetchMock.createInstance();
+			});
+			it('include content-length header by default', async () => {
+				fm.route('*', 'content');
+				const res = await fm.fetchHandler('http://it.at.there');
+				expect(res.headers.get('content-length')).toEqual('7');
+			});
+
+			it("don't include when configured false", async () => {
+				fm.config.includeContentLength = false;
+				fm.route('*', 'content');
+				const res = await fm.fetchHandler('http://it.at.there');
+				expect(res.headers.get('content-length')).toBeNull();
+			});
+
+			it('local setting can override to true', async () => {
+				fm.config.includeContentLength = false;
+				fm.route('*', 'content', { includeContentLength: true });
+				const res = await fm.fetchHandler('http://it.at.there');
+				expect(res.headers.get('content-length')).toEqual('7');
+			});
+
+			it('local setting can override to false', async () => {
+				fm.config.includeContentLength = true;
+				fm.route('*', 'content', { includeContentLength: false });
+				const res = await fm.fetchHandler('http://it.at.there');
+				expect(res.headers.get('content-length')).toBeNull();
+			});
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/FetchMock/response-negotiation.test.js b/packages/core/src/__tests__/FetchMock/response-negotiation.test.js
new file mode 100644
index 0000000..8c6b250
--- /dev/null
+++ b/packages/core/src/__tests__/FetchMock/response-negotiation.test.js
@@ -0,0 +1,225 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import fetchMock from '../../FetchMock';
+
+describe('response negotiation', () => {
+	let fm;
+	beforeEach(() => {
+		fm = fetchMock.createInstance();
+		fm.config.warnOnUnmatched = false;
+	});
+
+	it('function', async () => {
+		fm.route('*', (url) => url);
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(200);
+		expect(await res.text()).toEqual('http://a.com/');
+	});
+
+	it('Promise', async () => {
+		fm.route('*', Promise.resolve(200));
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(200);
+	});
+
+	it('function that returns a Promise for a status', async () => {
+		fm.route('*', () => Promise.resolve(300));
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(300);
+	});
+	it('function that returns a Promise for a body', async () => {
+		fm.route('*', (url) => Promise.resolve(`test: ${url}`));
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(200);
+		expect(await res.text()).toEqual('test: http://a.com/');
+	});
+
+	it('Promise for a function that returns a response', async () => {
+		fm.route(
+			'http://a.com/',
+			Promise.resolve((url) => `test: ${url}`),
+		);
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(200);
+		expect(await res.text()).toEqual('test: http://a.com/');
+	});
+
+	it('delay', async () => {
+		fm.route('*', 200, { delay: 20 });
+		const req = fm.fetchHandler('http://a.com/');
+		let resolved = false;
+		req.then(() => {
+			resolved = true;
+		});
+		await new Promise((res) => setTimeout(res, 10));
+		expect(resolved).toBe(false);
+		await new Promise((res) => setTimeout(res, 11));
+		expect(resolved).toBe(true);
+		const res = await req;
+		expect(res.status).toEqual(200);
+	});
+
+	it("delay a function response's execution", async () => {
+		const startTimestamp = new Date().getTime();
+		fm.route('http://a.com/', () => ({ timestamp: new Date().getTime() }), {
+			delay: 20,
+		});
+		const req = fm.fetchHandler('http://a.com/');
+		let resolved = false;
+		req.then(() => {
+			resolved = true;
+		});
+		await new Promise((res) => setTimeout(res, 10));
+		expect(resolved).toBe(false);
+		await new Promise((res) => setTimeout(res, 11));
+		expect(resolved).toBe(true);
+		const res = await req;
+		expect(res.status).toEqual(200);
+		const responseTimestamp = (await res.json()).timestamp;
+		expect(responseTimestamp - startTimestamp).toBeGreaterThanOrEqual(20);
+	});
+
+	it('pass values to delayed function', async () => {
+		fm.route('*', (url) => `delayed: ${url}`, {
+			delay: 10,
+		});
+		const req = fm.fetchHandler('http://a.com/');
+		await new Promise((res) => setTimeout(res, 11));
+		const res = await req;
+		expect(res.status).toEqual(200);
+		expect(await res.text()).toEqual('delayed: http://a.com/');
+	});
+
+	it('call delayed response multiple times, each with the same delay', async () => {
+		fm.route('*', 200, { delay: 20 });
+		const req1 = fm.fetchHandler('http://a.com/');
+		let resolved = false;
+		req1.then(() => {
+			resolved = true;
+		});
+		await new Promise((res) => setTimeout(res, 10));
+		expect(resolved).toBe(false);
+		await new Promise((res) => setTimeout(res, 11));
+		expect(resolved).toBe(true);
+		const res1 = await req1;
+		expect(res1.status).toEqual(200);
+		const req2 = fm.fetchHandler('http://a.com/');
+		resolved = false;
+		req2.then(() => {
+			resolved = true;
+		});
+		await new Promise((res) => setTimeout(res, 10));
+		expect(resolved).toBe(false);
+		await new Promise((res) => setTimeout(res, 11));
+		expect(resolved).toBe(true);
+		const res2 = await req2;
+		expect(res2.status).toEqual(200);
+	});
+
+	it('Response', async () => {
+		fm.route(
+			'http://a.com/',
+			new fm.config.Response('http://a.com/', { status: 200 }),
+		);
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(200);
+	});
+
+	it('function that returns a Response', async () => {
+		fm.route(
+			'http://a.com/',
+			() => new fm.config.Response('http://a.com/', { status: 200 }),
+		);
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(200);
+	});
+
+	it('Promise that returns a Response', async () => {
+		fm.route(
+			'http://a.com/',
+			Promise.resolve(new fm.config.Response('http://a.com/', { status: 200 })),
+		);
+		const res = await fm.fetchHandler('http://a.com/');
+		expect(res.status).toEqual(200);
+	});
+
+	describe('rejecting', () => {
+		it('reject if object with `throws` property', async () => {
+			fm.route('*', { throws: 'as expected' });
+
+			await expect(fm.fetchHandler('http://a.com/')).rejects.toThrowError(
+				'as expected',
+			);
+		});
+
+		it('reject if function that returns object with `throws` property', async () => {
+			fm.route('*', () => ({ throws: 'as expected' }));
+
+			await expect(fm.fetchHandler('http://a.com/')).rejects.toThrowError(
+				'as expected',
+			);
+		});
+	});
+
+	describe('abortable fetch', () => {
+		const RESPONSE_DELAY = 50;
+		const ABORT_DELAY = 10;
+
+		const getDelayedOk = () =>
+			new Promise((res) => setTimeout(() => res(200), RESPONSE_DELAY));
+
+		const getDelayedAbortController = () => {
+			const controller = new AbortController();
+			setTimeout(() => controller.abort(), ABORT_DELAY);
+			return controller;
+		};
+		const expectAbortError = async (...fetchArgs) => {
+			const result = fm.fetchHandler(...fetchArgs);
+			await expect(result).rejects.toThrowError(
+				new DOMException('The operation was aborted.', 'ABortError'),
+			);
+		};
+
+		it('error on signal abort', () => {
+			fm.route('*', getDelayedOk());
+			return expectAbortError('http://a.com', {
+				signal: getDelayedAbortController().signal,
+			});
+		});
+
+		it('error on signal abort for request object', () => {
+			fm.route('*', getDelayedOk());
+			return expectAbortError(
+				new fm.config.Request('http://a.com', {
+					signal: getDelayedAbortController().signal,
+				}),
+			);
+		});
+
+		it('error when signal already aborted', () => {
+			fm.route('*', 200);
+			const controller = new AbortController();
+			controller.abort();
+			return expectAbortError('http://a.com', {
+				signal: controller.signal,
+			});
+		});
+
+		it('go into `done` state even when aborted', async () => {
+			fm.once('http://a.com', getDelayedOk());
+			await expectAbortError('http://a.com', {
+				signal: getDelayedAbortController().signal,
+			});
+			expect(fm.done()).toBe(true);
+		});
+
+		it('will flush even when aborted', async () => {
+			fm.route('http://a.com', getDelayedOk());
+
+			await expectAbortError('http://a.com', {
+				signal: getDelayedAbortController().signal,
+			});
+			await fm.flush();
+			expect(fm.done()).toBe(true);
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/FetchMock/routing.test.js b/packages/core/src/__tests__/FetchMock/routing.test.js
new file mode 100644
index 0000000..264ce12
--- /dev/null
+++ b/packages/core/src/__tests__/FetchMock/routing.test.js
@@ -0,0 +1,258 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import fetchMock from '../../FetchMock.js';
+
+describe('Routing', () => {
+	let fm;
+	beforeEach(() => {
+		fm = fetchMock.createInstance();
+	});
+
+	const testChainableMethod = (method, ...args) => {
+		it(`${method}() is chainable`, () => {
+			expect(fm[method](...args)).toEqual(fm);
+		});
+
+		it(`${method}() has "this"`, () => {
+			vi.spyOn(fm, method).mockReturnThis();
+			expect(fm[method](...args)).toBe(fm);
+			fm[method].mockRestore();
+		});
+	};
+
+	const testChainableRoutingMethod = (method, ...args) => {
+		args = fetchMock[method].length === 3 ? ['*', 200] : [200];
+
+		it(`${method}() is chainable`, () => {
+			expect(fm[method](...args)).toEqual(fm);
+		});
+
+		it(`${method}() has "this"`, () => {
+			vi.spyOn(fm, method).mockReturnThis();
+			fm[method](...args);
+			expect(fm[method](...args)).toEqual(fm);
+			fm[method].mockRestore();
+		});
+	};
+
+	describe('naming routes', () => {
+		it('property on first parameter', () => {
+			fm.route({ url: 'http://a.com', name: 'my-name' }, 200);
+			expect(fm.router.routes[0].config.name).toBe('my-name');
+		});
+
+		it('property on first parameter when only one parameter supplied', () => {
+			fm.route({ name: 'my-name', url: 'http://a.com', response: 200 });
+			expect(fm.router.routes[0].config.name).toBe('my-name');
+		});
+
+		it('property on third parameter', () => {
+			fm.route('http://a.com', 200, { name: 'my-name' });
+			expect(fm.router.routes[0].config.name).toBe('my-name');
+		});
+
+		it('string in third parameter', () => {
+			fm.route('http://a.com', 200, 'my-name');
+			expect(fm.router.routes[0].config.name).toBe('my-name');
+		});
+		it('reserved names', () => {
+			expect(() => fm.route('http://a.com', 200, 'matched')).toThrow(
+				'fetch-mock: Routes cannot use the reserved name `matched`',
+			);
+			expect(() => fm.route('http://a.com', 200, 'unmatched')).toThrow(
+				'fetch-mock: Routes cannot use the reserved name `unmatched`',
+			);
+		});
+		it('error on repeated names names', () => {
+			fm.route('http://a.com', 200, 'route 1');
+			expect(() => fm.route('http://a.com', 200, 'route 1')).toThrow(
+				'fetch-mock: Adding route with same name as existing route.',
+			);
+		});
+	});
+	describe('routing methods', () => {
+		beforeEach(() => {
+			fm = fetchMock.createInstance();
+			vi.spyOn(fm.router, 'addRoute');
+		});
+		describe('FetchMock.route()', () => {
+			testChainableRoutingMethod('route');
+			it("won't mock if route already matched enough times", async () => {
+				fm.route('http://a.com/', 200, { repeat: 1 });
+
+				await fm.fetchHandler('http://a.com/');
+				try {
+					await fm.fetchHandler('http://a.com/');
+					expect.unreachable('Previous line should throw');
+				} catch (err) {}
+			});
+		});
+
+		describe('FetchMock.catch()', () => {
+			testChainableMethod('catch');
+			describe('unmatched calls', () => {
+				it('throws if any calls unmatched by default', async () => {
+					fm.route('http://a.com', 200);
+					await expect(fm.fetchHandler('http://b.com')).rejects.toThrow();
+				});
+
+				it('catch unmatched calls with empty 200 by default', async () => {
+					fm.catch();
+					await expect(fm.fetchHandler('http://a.com')).resolves.toMatchObject({
+						status: 200,
+					});
+				});
+
+				it('can catch unmatched calls with custom response', async () => {
+					fm.catch(300);
+					await expect(fm.fetchHandler('http://a.com')).resolves.toMatchObject({
+						status: 300,
+					});
+				});
+
+				it('can catch unmatched calls with function', async () => {
+					fm.catch(() => new fm.config.Response('i am text', { status: 400 }));
+					const response = await fm.fetchHandler('http://a.com');
+					expect(response).toMatchObject({ status: 400 });
+					await expect(response.text()).resolves.toEqual('i am text');
+				});
+			});
+		});
+
+		describe('FetchMock.sticky()', () => {
+			it('has sticky() shorthand method', () => {
+				fm.sticky('a', 'b');
+				fm.sticky('c', 'd', { opt: 'e' });
+				expect(fm.router.addRoute).toHaveBeenCalledWith('a', 'b', {
+					sticky: true,
+				});
+				expect(fm.router.addRoute).toHaveBeenCalledWith('c', 'd', {
+					opt: 'e',
+					sticky: true,
+				});
+			});
+
+			testChainableRoutingMethod('sticky');
+		});
+		describe('FetchMock.once()', () => {
+			testChainableRoutingMethod('once');
+			it('has once() shorthand method', () => {
+				fm.once('a', 'b');
+				fm.once('c', 'd', { opt: 'e' });
+				expect(fm.router.addRoute).toHaveBeenCalledWith('a', 'b', {
+					repeat: 1,
+				});
+				expect(fm.router.addRoute).toHaveBeenCalledWith('c', 'd', {
+					opt: 'e',
+					repeat: 1,
+				});
+			});
+			it('clearHistory() resets repeat setting on routes', async () => {
+				fm.once('http://a.com/', 200);
+				await fm.fetchHandler('http://a.com/');
+				fm.clearHistory();
+				await expect(fm.fetchHandler('http://a.com/')).resolves.not.toThrow();
+			});
+		});
+
+		describe('FetchMock.any()', () => {
+			it('has any() shorthand method', () => {
+				fm.any('a', { opt: 'b' });
+				expect(fm.router.addRoute).toHaveBeenCalledWith('*', 'a', {
+					opt: 'b',
+				});
+			});
+			it('match protocol-relative urls', async () => {
+				fm.any(200);
+				await expect(fm.fetchHandler('//a.com/path')).resolves.not.toThrow();
+			});
+			testChainableRoutingMethod('any');
+		});
+
+		describe('FetchMock.anyOnce()', () => {
+			it('has anyOnce() shorthand method', () => {
+				fm.anyOnce('a', { opt: 'b' });
+				expect(fm.router.addRoute).toHaveBeenCalledWith('*', 'a', {
+					opt: 'b',
+					repeat: 1,
+				});
+			});
+
+			testChainableRoutingMethod('anyOnce');
+		});
+
+		describe('method shorthands', () => {
+			['get', 'post', 'put', 'delete', 'head', 'patch'].forEach((method) => {
+				describe(method.toUpperCase(), () => {
+					it(`has ${method}() shorthand`, () => {
+						fm[method]('a', 'b');
+						fm[method]('c', 'd', { opt: 'e' });
+						expect(fm.router.addRoute).toHaveBeenCalledWith('a', 'b', {
+							method,
+						});
+						expect(fm.router.addRoute).toHaveBeenCalledWith('c', 'd', {
+							opt: 'e',
+							method,
+						});
+					});
+
+					testChainableRoutingMethod(method);
+
+					it(`has ${method}Once() shorthand`, () => {
+						fm[`${method}Once`]('a', 'b');
+						fm[`${method}Once`]('c', 'd', { opt: 'e' });
+						expect(fm.router.addRoute).toHaveBeenCalledWith('a', 'b', {
+							method,
+							repeat: 1,
+						});
+						expect(fm.router.addRoute).toHaveBeenCalledWith('c', 'd', {
+							opt: 'e',
+							method,
+							repeat: 1,
+						});
+					});
+
+					testChainableRoutingMethod(`${method}Once`);
+
+					it(`has ${method}Any() shorthand`, () => {
+						fm[`${method}Any`]('a', { opt: 'b' });
+						expect(fm.router.addRoute).toHaveBeenCalledWith('*', 'a', {
+							opt: 'b',
+							method,
+						});
+					});
+
+					testChainableRoutingMethod(`${method}Any`);
+
+					it(`has ${method}AnyOnce() shorthand`, () => {
+						fm[`${method}AnyOnce`]('a', { opt: 'b' });
+						expect(fm.router.addRoute).toHaveBeenCalledWith('*', 'a', {
+							opt: 'b',
+							method,
+							repeat: 1,
+						});
+					});
+
+					testChainableRoutingMethod(`${method}Any`);
+				});
+			});
+		});
+	});
+
+	describe('multiple routes', () => {
+		it('match several routes with one instance', async () => {
+			fm.route('http://a.com/', 200).route('http://b.com/', 201);
+
+			const res1 = await fm.fetchHandler('http://a.com/');
+			expect(res1.status).toEqual(200);
+			const res2 = await fm.fetchHandler('http://b.com/');
+			expect(res2.status).toEqual(201);
+		});
+
+		it('match first route that matches', async () => {
+			fm.route('http://a.com/', 200).route('begin:http://a.com/', 201);
+
+			const res = await fm.fetchHandler('http://a.com/');
+			expect(res.status).toEqual(200);
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/body.test.js b/packages/core/src/__tests__/Matchers/body.test.js
new file mode 100644
index 0000000..911434b
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/body.test.js
@@ -0,0 +1,185 @@
+import { describe, expect, it } from 'vitest';
+import Route from '../../Route.js';
+
+describe('body matching', () => {
+	//TODO add a test for matching an asynchronous body
+	it('should not match if no body provided in request', () => {
+		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+
+		expect(
+			route.matcher('http://a.com/', {
+				method: 'POST',
+			}),
+		).toBe(false);
+	});
+
+	it('should match if no content type is specified', () => {
+		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+
+		expect(
+			route.matcher('http://a.com/', {
+				method: 'POST',
+				body: JSON.stringify({ foo: 'bar' }),
+			}),
+		).toBe(true);
+	});
+
+	it('should match when using Request', () => {
+		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+
+		expect(
+			route.matcher(
+				new Request('http://a.com/', {
+					method: 'POST',
+					body: JSON.stringify({ foo: 'bar' }),
+				}),
+			),
+		).toBe(true);
+	});
+
+	it('should match if body sent matches expected body', () => {
+		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+
+		expect(
+			route.matcher('http://a.com/', {
+				method: 'POST',
+				body: JSON.stringify({ foo: 'bar' }),
+				headers: { 'Content-Type': 'application/json' },
+			}),
+		).toBe(true);
+	});
+
+	it('should not match if body sent doesn’t match expected body', () => {
+		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+
+		expect(
+			route.matcher('http://a.com/', {
+				method: 'POST',
+				body: JSON.stringify({ foo: 'woah!!!' }),
+				headers: { 'Content-Type': 'application/json' },
+			}),
+		).toBe(false);
+	});
+
+	it('should not match if body sent isn’t JSON', () => {
+		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+
+		expect(
+			route.matcher('http://a.com/', {
+				method: 'POST',
+				body: new ArrayBuffer(8),
+				headers: { 'Content-Type': 'application/json' },
+			}),
+		).toBe(false);
+	});
+
+	it('should ignore the order of the keys in the body', () => {
+		const route = new Route({
+			body: {
+				foo: 'bar',
+				baz: 'qux',
+			},
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				method: 'POST',
+				body: JSON.stringify({
+					baz: 'qux',
+					foo: 'bar',
+				}),
+				headers: { 'Content-Type': 'application/json' },
+			}),
+		).toBe(true);
+	});
+
+	// TODO - I think this shoudl actually throw
+	it('should ignore the body option matcher if request was GET', () => {
+		const route = new Route({
+			body: {
+				foo: 'bar',
+				baz: 'qux',
+			},
+
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com/')).toBe(true);
+	});
+
+	describe('partial body matching', () => {
+		it('match when missing properties', () => {
+			const route = new Route({
+				body: { ham: 'sandwich' },
+				matchPartialBody: true,
+				response: 200,
+			});
+			expect(
+				route.matcher('http://a.com', {
+					method: 'POST',
+					body: JSON.stringify({ ham: 'sandwich', egg: 'mayonaise' }),
+				}),
+			).toBe(true);
+		});
+
+		it('match when missing nested properties', () => {
+			const route = new Route({
+				body: { meal: { ham: 'sandwich' } },
+				matchPartialBody: true,
+				response: 200,
+			});
+			expect(
+				route.matcher('http://a.com', {
+					method: 'POST',
+					body: JSON.stringify({
+						meal: { ham: 'sandwich', egg: 'mayonaise' },
+					}),
+				}),
+			).toBe(true);
+		});
+
+		it('not match when properties at wrong depth', () => {
+			const route = new Route({
+				body: { ham: 'sandwich' },
+				matchPartialBody: true,
+				response: 200,
+			});
+			expect(
+				route.matcher('http://a.com', {
+					method: 'POST',
+					body: JSON.stringify({ meal: { ham: 'sandwich' } }),
+				}),
+			).toBe(false);
+		});
+
+		it('match when starting subset of array', () => {
+			const route = new Route({
+				body: { ham: [1, 2] },
+				matchPartialBody: true,
+				response: 200,
+			});
+			expect(
+				route.matcher('http://a.com', {
+					method: 'POST',
+					body: JSON.stringify({ ham: [1, 2, 3] }),
+				}),
+			).toBe(true);
+		});
+
+		it('not match when not starting subset of array', () => {
+			const route = new Route({
+				body: { ham: [1, 3] },
+				matchPartialBody: true,
+				response: 200,
+			});
+			expect(
+				route.matcher('http://a.com', {
+					method: 'POST',
+					body: JSON.stringify({ ham: [1, 2, 3] }),
+				}),
+			).toBe(false);
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/express.test.js b/packages/core/src/__tests__/Matchers/express.test.js
new file mode 100644
index 0000000..4973d6d
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/express.test.js
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import Route from '../../Route.js';
+
+describe('express path parameter matching', () => {
+	it('can match a path parameters', () => {
+		const route = new Route({
+			matcher: 'express:/type/:instance',
+			response: 200,
+			params: { instance: 'b' },
+		});
+		expect(route.matcher('/')).toBe(false);
+		expect(route.matcher('/type/a')).toBe(false);
+		expect(route.matcher('/type/b')).toBe(true);
+	});
+
+	it('can match multiple path parameters', () => {
+		const route = new Route({
+			matcher: 'express:/:type/:instance',
+			response: 200,
+			params: { instance: 'b', type: 'cat' },
+		});
+		expect(route.matcher('/')).toBe(false);
+		expect(route.matcher('/dog/a')).toBe(false);
+		expect(route.matcher('/cat/a')).toBe(false);
+		expect(route.matcher('/dog/b')).toBe(false);
+		expect(route.matcher('/cat/b')).toBe(true);
+	});
+
+	it('can match a path parameter on a full url', () => {
+		const route = new Route({
+			matcher: 'express:/type/:instance',
+			response: 200,
+			params: { instance: 'b' },
+		});
+		expect(route.matcher('http://site.com/')).toBe(false);
+		expect(route.matcher('http://site.com/type/a')).toBe(false);
+		expect(route.matcher('http://site.com/type/b')).toBe(true);
+	});
+
+	it('can match fully qualified url', () => {
+		const route = new Route({ matcher: 'express:/apps/:id', response: 200 });
+
+		expect(route.matcher('https://api.example.com/apps/abc')).toBe(true);
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/function.test.js b/packages/core/src/__tests__/Matchers/function.test.js
new file mode 100644
index 0000000..0bc6d4f
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/function.test.js
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import Route from '../../Route.js';
+
+describe('function matching', () => {
+	it('match using custom function', () => {
+		const route = new Route({
+			matcher: (url, opts) =>
+				url.indexOf('logged-in') > -1 &&
+				opts &&
+				opts.headers &&
+				opts.headers.authorized === true,
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/12345', {
+				headers: { authorized: true },
+			}),
+		).toBe(false);
+		expect(route.matcher('http://a.com/logged-in')).toBe(false);
+		expect(
+			route.matcher('http://a.com/logged-in', {
+				headers: { authorized: true },
+			}),
+		).toBe(true);
+	});
+
+	it('match using custom function using request body', () => {
+		const route = new Route({
+			matcher: (url, opts) => opts.body === 'a string',
+			response: 200,
+		});
+		expect(route.matcher('http://a.com/logged-in')).toBe(false);
+		expect(
+			route.matcher('http://a.com/logged-in', {
+				method: 'post',
+				body: 'a string',
+			}),
+		).toBe(true);
+	});
+
+	it('match using custom function alongside other matchers', () => {
+		const route = new Route({
+			matcher: 'end:profile',
+			response: 200,
+			functionMatcher: (url, opts) =>
+				opts && opts.headers && opts.headers.authorized === true,
+		});
+
+		expect(route.matcher('http://a.com/profile')).toBe(false);
+		expect(
+			route.matcher('http://a.com/not', {
+				headers: { authorized: true },
+			}),
+		).toBe(false);
+		expect(
+			route.matcher('http://a.com/profile', {
+				headers: { authorized: true },
+			}),
+		).toBe(true);
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/header.js b/packages/core/src/__tests__/Matchers/header.js
new file mode 100644
index 0000000..a95dc27
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/header.js
@@ -0,0 +1,170 @@
+import { describe, expect, it } from 'vitest';
+import Route from '../../Route.js';
+
+describe('header matching', () => {
+	it('not match when headers not present', () => {
+		const route = new Route({
+			headers: { a: 'b' },
+
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com/')).toBe(true);
+	});
+
+	it("not match when headers don't match", () => {
+		const route = new Route({
+			headers: { a: 'b' },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: { a: 'c' },
+			}),
+		).toBe(false);
+	});
+
+	it('match simple headers', () => {
+		const route = new Route({
+			headers: { a: 'b' },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: { a: 'b' },
+			}),
+		).toBe(true);
+	});
+
+	it('be case insensitive', () => {
+		const route = new Route({
+			headers: { a: 'b' },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: { A: 'b' },
+			}),
+		).toBe(true);
+	});
+	// TODO Are these gonna be supported?
+	// Should we support it in the fetch-mock matcher API, even though Headers are basically sytrings
+	it('match multivalue headers', () => {
+		const route = new Route({
+			headers: { a: ['b', 'c'] },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: { a: ['b', 'c'] },
+			}),
+		).toBe(true);
+	});
+
+	it('not match partially satisfied multivalue headers', () => {
+		const route = new Route({
+			headers: { a: ['b', 'c', 'd'] },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: { a: ['b', 'c'] },
+			}),
+		).toBe(false);
+	});
+
+	it('match multiple headers', () => {
+		const route = new Route({
+			headers: { a: 'b', c: 'd' },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: { a: 'b', c: 'd' },
+			}),
+		).toBe(true);
+	});
+
+	it('not match unsatisfied multiple headers', () => {
+		const route = new Route({
+			headers: { a: 'b', c: 'd' },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: { a: 'b' },
+			}),
+		).toBe(false);
+	});
+
+	it('match Headers instance', () => {
+		const route = new Route({
+			headers: { a: 'b' },
+
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com/', {
+				headers: new Headers({ a: 'b' }),
+			}),
+		).toBe(true);
+	});
+
+	it('can be used alongside function matchers', () => {
+		const route = new Route({
+			matcher: (url) => /person/.test(url),
+			response: 200,
+			headers: { a: 'b' },
+		});
+
+		expect(route.matcher('http://domain.com/person')).toBe(false);
+		expect(
+			route.matcher('http://domain.com/person', {
+				headers: { a: 'b' },
+			}),
+		).toBe(true);
+	});
+
+	it('match custom Headers instance', () => {
+		const MyHeaders = class {
+			constructor(obj) {
+				this.obj = obj;
+			}
+			// eslint-disable-next-line class-methods-use-this
+			*[Symbol.iterator]() {
+				yield ['a', 'b'];
+			}
+			// eslint-disable-next-line class-methods-use-this
+			has() {
+				return true;
+			}
+		};
+
+		const route = new Route({
+			response: 200,
+			headers: { a: 'b' },
+			config: { Headers: MyHeaders },
+		});
+
+		expect(
+			route.matcher('http://a.com', {
+				headers: new MyHeaders({ a: 'b' }),
+			}),
+		).toBe(true);
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/method.test.js b/packages/core/src/__tests__/Matchers/method.test.js
new file mode 100644
index 0000000..92eb6c8
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/method.test.js
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+
+import Route from '../../Route.js';
+
+describe('method matching', () => {
+	it('match any method by default', () => {
+		const route = new Route({ matcher: '*', response: 200 });
+
+		expect(route.matcher('http://a.com/', { method: 'GET' })).toBe(true);
+		expect(route.matcher('http://a.com/', { method: 'POST' })).toBe(true);
+	});
+
+	it('configure an exact method to match', () => {
+		const route = new Route({ method: 'POST', response: 200 });
+
+		expect(route.matcher('http://a.com/', { method: 'GET' })).toBe(false);
+		expect(route.matcher('http://a.com/', { method: 'POST' })).toBe(true);
+	});
+
+	it('match implicit GET', () => {
+		const route = new Route({ method: 'GET', response: 200 });
+		expect(route.matcher('http://a.com/')).toBe(true);
+	});
+
+	it('be case insensitive', () => {
+		const upperCaseRoute = new Route({ method: 'POST', response: 200 });
+		const lowerCaseRoute = new Route({ method: 'post', response: 200 });
+
+		expect(upperCaseRoute.matcher('http://a.com/', { method: 'post' })).toBe(
+			true,
+		);
+		expect(upperCaseRoute.matcher('http://a.com/', { method: 'POST' })).toBe(
+			true,
+		);
+		expect(lowerCaseRoute.matcher('http://a.com/', { method: 'post' })).toBe(
+			true,
+		);
+		expect(lowerCaseRoute.matcher('http://a.com/', { method: 'POST' })).toBe(
+			true,
+		);
+	});
+
+	it('can be used alongside function matchers', () => {
+		const route = new Route({
+			method: 'POST',
+			matcher: (url) => /a\.com/.test(url),
+
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com')).toBe(false);
+		expect(route.matcher('http://b.com', { method: 'POST' })).toBe(false);
+		expect(route.matcher('http://a.com', { method: 'POST' })).toBe(true);
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/query-string.test.js b/packages/core/src/__tests__/Matchers/query-string.test.js
new file mode 100644
index 0000000..127a374
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/query-string.test.js
@@ -0,0 +1,225 @@
+import { describe, expect, it } from 'vitest';
+import Route from '../../Route.js';
+
+describe('query string matching', () => {
+	it('match a query string', () => {
+		const route = new Route({
+			query: { a: 'b', c: 'd' },
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com')).toBe(false);
+		expect(route.matcher('http://a.com?a=b&c=d')).toBe(true);
+	});
+
+	it('match a query string against a URL object', () => {
+		const route = new Route({
+			query: { a: 'b', c: 'd' },
+			response: 200,
+		});
+		const url = new URL('http://a.com/path');
+		url.searchParams.append('a', 'b');
+		url.searchParams.append('c', 'd');
+		expect(route.matcher(url)).toBe(true);
+	});
+
+	it('match a query string against a relative path', () => {
+		const route = new Route({
+			query: { a: 'b' },
+			response: 200,
+		});
+		const url = '/path?a=b';
+		expect(route.matcher(url)).toBe(true);
+	});
+
+	it('match multiple query strings', () => {
+		const route = new Route({
+			query: { a: 'b', c: 'd' },
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com')).toBe(false);
+		expect(route.matcher('http://a.com?a=b')).toBe(false);
+		expect(route.matcher('http://a.com?a=b&c=d')).toBe(true);
+		expect(route.matcher('http://a.com?c=d&a=b')).toBe(true);
+	});
+
+	it('ignore irrelevant query strings', () => {
+		const route = new Route({
+			query: { a: 'b', c: 'd' },
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com?a=b&c=d&e=f')).toBe(true);
+	});
+	it('match an empty query string', () => {
+		const route = new Route({
+			query: { a: '' },
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com')).toBe(false);
+		expect(route.matcher('http://a.com?a=')).toBe(true);
+	});
+
+	describe('value coercion', () => {
+		it('coerce integers to strings and match', () => {
+			const route = new Route({
+				query: {
+					a: 1,
+				},
+				response: 200,
+			});
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=1')).toBe(true);
+		});
+
+		it('coerce floats to strings and match', () => {
+			const route = new Route({
+				query: {
+					a: 1.2,
+				},
+				response: 200,
+			});
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=1.2')).toBe(true);
+		});
+
+		it('coerce booleans to strings and match', () => {
+			const trueRoute = new Route({
+				query: {
+					a: true,
+				},
+				response: 200,
+			});
+			const falseRoute = new Route({
+				query: {
+					b: false,
+				},
+				response: 200,
+			});
+
+			expect(trueRoute.matcher('http://a.com')).toBe(false);
+			expect(falseRoute.matcher('http://a.com')).toBe(false);
+			expect(trueRoute.matcher('http://a.com?a=true')).toBe(true);
+			expect(falseRoute.matcher('http://a.com?b=false')).toBe(true);
+		});
+
+		it('coerce undefined to an empty string and match', () => {
+			const route = new Route({
+				query: {
+					a: undefined,
+				},
+				response: 200,
+			});
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=')).toBe(true);
+		});
+
+		it('coerce null to an empty string and match', () => {
+			const route = new Route({
+				query: {
+					a: null,
+				},
+				response: 200,
+			});
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=')).toBe(true);
+		});
+
+		it('coerce an object to an empty string and match', () => {
+			const route = new Route({
+				query: {
+					a: { b: 'c' },
+				},
+				response: 200,
+			});
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=')).toBe(true);
+		});
+
+		it('can match a query string with different value types', () => {
+			const route = new Route({
+				response: 200,
+				query: {
+					t: true,
+					f: false,
+					u: undefined,
+					num: 1,
+					arr: ['a', undefined],
+				},
+			});
+
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(
+				route.matcher('http://a.com?t=true&f=false&u=&num=1&arr=a&arr='),
+			).toBe(true);
+		});
+	});
+
+	// TODO may need reform
+	describe('repeated query strings', () => {
+		it('match repeated query strings', () => {
+			const route = new Route({ query: { a: ['b', 'c'] }, response: 200 });
+
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=b')).toBe(false);
+			expect(route.matcher('http://a.com?a=b&a=c')).toBe(true);
+			expect(route.matcher('http://a.com?a=b&a=c&a=d')).toBe(false);
+		});
+
+		it('match repeated query strings in any order', () => {
+			const route = new Route({ query: { a: ['b', 'c'] }, response: 200 });
+
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=b&a=c')).toBe(true);
+			expect(route.matcher('http://a.com?a=c&a=b')).toBe(true);
+		});
+
+		it('match a query string array of length 1', () => {
+			const route = new Route({ query: { a: ['b'] }, response: 200 });
+
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=b')).toBe(true);
+			expect(route.matcher('http://a.com?a=b&a=c')).toBe(false);
+		});
+
+		it('match a repeated query string with an empty value', () => {
+			const route = new Route({
+				query: { a: ['b', undefined] },
+				response: 200,
+			});
+
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=b')).toBe(false);
+			expect(route.matcher('http://a.com?a=b&a=')).toBe(true);
+		});
+	});
+
+	describe('interoperability', () => {
+		// TODO - this should probably throw when creating the route... or should it?
+		it.skip('can be used alongside query strings expressed in the url', () => {
+			const route = new Route({
+				url: 'http://a.com/?c=d',
+				response: 200,
+				query: { a: 'b' },
+			});
+
+			expect(route.matcher('http://a.com?c=d')).toBe(false);
+			expect(route.matcher('http://a.com?a=b')).toBe(false);
+			expect(route.matcher('http://a.com?c=d&a=b')).toBe(true);
+			expect(route.matcher('http://a.com?a=b&c=d')).toBe(true);
+		});
+
+		it('can be used alongside function matchers', () => {
+			const route = new Route({
+				matcher: (url) => /a\.com/.test(url),
+				response: 200,
+				query: { a: 'b' },
+			});
+
+			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher('http://a.com?a=b')).toBe(true);
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/route-config-object.test.js b/packages/core/src/__tests__/Matchers/route-config-object.test.js
new file mode 100644
index 0000000..b43877c
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/route-config-object.test.js
@@ -0,0 +1,134 @@
+import { describe, expect, it } from 'vitest';
+import Route from '../../Route.js';
+
+// TODO should this whole thing be integration tests on router
+// as it's mainly about the shape of optiosn passed into to addRoute
+describe('matcher object', () => {
+	it('use matcher object with matcher property', () => {
+		const route = new Route({ matcher: 'http://a.com', response: 200 });
+		expect(route.matcher('http://a.com')).toBe(true);
+	});
+
+	it('use matcher object with url property', () => {
+		const route = new Route({ url: 'http://a.com', response: 200 });
+		expect(route.matcher('http://a.com')).toBe(true);
+	});
+
+	it('can use matcher and url simultaneously', () => {
+		const route = new Route({
+			url: 'end:path',
+			matcher: (url, opts) =>
+				opts && opts.headers && opts.headers.authorized === true,
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com/path')).toBe(false);
+		expect(
+			route.matcher('http://a.com', {
+				headers: { authorized: true },
+			}),
+		).toBe(false);
+		expect(
+			route.matcher('http://a.com/path', {
+				headers: { authorized: true },
+			}),
+		).toBe(true);
+	});
+
+	// TODO this shoudl probably be an error
+	it.skip('if no url provided, match any url', () => {
+		const route = new Route({ response: 200 });
+		expect(route.matcher('http://a.com')).toBe(true);
+	});
+
+	//TODO be strionger on discouraging this
+	it.skip('deprecated message on using functionMatcher (prefer matcher)', () => {
+		new Route({
+			url: 'end:profile',
+			functionMatcher: (url, opts) =>
+				opts && opts.headers && opts.headers.authorized === true,
+			response: 200,
+		});
+	});
+
+	it('can match Headers', () => {
+		const route = new Route({
+			url: 'http://a.com',
+			headers: { a: 'b' },
+			response: 200,
+		});
+
+		expect(
+			route.matcher('http://a.com', {
+				headers: { a: 'c' },
+			}),
+		).toBe(false);
+		expect(
+			route.matcher('http://a.com', {
+				headers: { a: 'b' },
+			}),
+		).toBe(true);
+	});
+
+	it('can match query string', () => {
+		const route = new Route({
+			url: 'http://a.com',
+			query: { a: 'b' },
+			response: 200,
+		});
+
+		expect(route.matcher('http://a.com')).toBe(false);
+		expect(route.matcher('http://a.com?a=b')).toBe(true);
+	});
+
+	it('can match path parameter', () => {
+		const route = new Route({
+			url: 'express:/type/:var',
+			params: { var: 'b' },
+			response: 200,
+		});
+		expect(route.matcher('/')).toBe(false);
+		expect(route.matcher('/type/a')).toBe(false);
+		expect(route.matcher('/type/b')).toBe(true);
+	});
+
+	it('can match method', () => {
+		const route = new Route({ method: 'POST', response: 200 });
+		expect(route.matcher('http://a.com', { method: 'GET' })).toBe(false);
+		expect(route.matcher('http://a.com', { method: 'POST' })).toBe(true);
+	});
+
+	it('can match body', () => {
+		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+
+		expect(
+			route.matcher('http://a.com', {
+				method: 'POST',
+			}),
+		).toBe(false);
+		expect(
+			route.matcher('http://a.com', {
+				method: 'POST',
+				body: JSON.stringify({ foo: 'bar' }),
+				headers: { 'Content-Type': 'application/json' },
+			}),
+		).toBe(true);
+	});
+
+	// TODO new tests for how multiple routes that match can be addeed
+	it.skip('support setting overwrite routes on matcher parameter', () => {});
+
+	it('support setting matchPartialBody on matcher parameter', () => {
+		const route = new Route({
+			body: { a: 1 },
+			matchPartialBody: true,
+			response: 200,
+		});
+		expect(
+			route.matcher('http://a.com', {
+				method: 'POST',
+				body: JSON.stringify({ a: 1, b: 2 }),
+			}),
+		).toBe(true);
+	});
+});
diff --git a/packages/core/src/__tests__/Matchers/url.test.js b/packages/core/src/__tests__/Matchers/url.test.js
new file mode 100644
index 0000000..f49bcfa
--- /dev/null
+++ b/packages/core/src/__tests__/Matchers/url.test.js
@@ -0,0 +1,186 @@
+import { describe, expect, it } from 'vitest';
+import Route from '../../Route.js';
+
+describe('url matching', () => {
+	it('match exact strings', () => {
+		const route = new Route({ matcher: 'http://a.com/path', response: 200 });
+		expect(route.matcher('http://a.com/pat')).toBe(false);
+		expect(route.matcher('http://a.com/paths')).toBe(false);
+		expect(route.matcher('http://a.co/path')).toBe(false);
+		expect(route.matcher('http://a.com/path')).toBe(true);
+		expect(route.matcher('//a.com/path')).toBe(true);
+	});
+
+	it('match string objects', () => {
+		const route = new Route({ matcher: 'http://a.com/path', response: 200 });
+		expect(route.matcher(new String('http://a.com/path'))).toBe(true); // eslint-disable-line no-new-wrappers
+	});
+
+	it('match exact strings with relative url', () => {
+		const route = new Route({ matcher: '/path', response: 200 });
+		expect(route.matcher('/pat')).toBe(false);
+		expect(route.matcher('/paths')).toBe(false);
+		expect(route.matcher('/path')).toBe(true);
+	});
+
+	it('match exact string against URL object', () => {
+		const route = new Route({ matcher: 'http://a.com/path', response: 200 });
+		const url = new URL('http://a.com/path');
+		expect(route.matcher(url)).toBe(true);
+	});
+
+	it('match using URL object as matcher', () => {
+		const url = new URL('http://a.com/path');
+		const route = new Route({ matcher: url, response: 200 });
+		expect(route.matcher('http://a.com/path')).toBe(true);
+	});
+
+	it('match begin: keyword', () => {
+		const route = new Route({
+			matcher: 'begin:http://a.com/path',
+			response: 200,
+		});
+
+		expect(route.matcher('http://b.com/path')).toBe(false);
+		expect(route.matcher('http://a.com/pat')).toBe(false);
+		expect(route.matcher('http://a.com/path')).toBe(true);
+		expect(route.matcher('http://a.com/paths')).toBe(true);
+	});
+
+	it('match end: keyword', () => {
+		const route = new Route({ matcher: 'end:com/path', response: 200 });
+		expect(route.matcher('http://a.com/paths')).toBe(false);
+		expect(route.matcher('http://a.com/pat')).toBe(false);
+		expect(route.matcher('http://a.com/path')).toBe(true);
+		expect(route.matcher('http://b.com/path')).toBe(true);
+	});
+
+	it('match glob: keyword', () => {
+		const route = new Route({ matcher: 'glob:/its/*/*', response: 200 });
+		expect(route.matcher('/its/alive')).toBe(false);
+		expect(route.matcher('/its/a/boy')).toBe(true);
+		expect(route.matcher('/its/a/girl')).toBe(true);
+	});
+
+	it('match express: keyword', () => {
+		const route = new Route({ matcher: 'express:/its/:word', response: 200 });
+
+		expect(route.matcher('/its')).toBe(false);
+		expect(route.matcher('/its/')).toBe(false);
+		expect(route.matcher('/its/a/girl')).toBe(false);
+		expect(route.matcher('/its/alive')).toBe(true);
+	});
+
+	it('match path: keyword', () => {
+		const route = new Route({ matcher: 'path:/its/:word', response: 200 });
+
+		expect(route.matcher('/its/boy')).toBe(false);
+		expect(route.matcher('/its/:word/still')).toBe(false);
+		expect(route.matcher('/its/:word')).toBe(true);
+		expect(route.matcher('/its/:word?brain=false')).toBe(true);
+	});
+
+	it('match wildcard string', () => {
+		const route = new Route({ matcher: '*', response: 200 });
+
+		expect(route.matcher('http://a.com')).toBe(true);
+	});
+
+	it('match regular expressions', () => {
+		const rx = /http\:\/\/a\.com\/\d+/;
+		const route = new Route({ matcher: rx, response: 200 });
+
+		expect(route.matcher('http://a.com/')).toBe(false);
+		expect(route.matcher('http://a.com/abcde')).toBe(false);
+		expect(route.matcher('http://a.com/12345')).toBe(true);
+	});
+
+	it('match relative urls', () => {
+		const route = new Route({ matcher: '/a.com/', response: 200 });
+		expect(route.matcher('/a.com/')).toBe(true);
+	});
+
+	it('match relative urls with dots', () => {
+		const route = new Route({ matcher: '/it.at/there/', response: 200 });
+		expect(route.matcher('/it.at/not/../there/')).toBe(true);
+		expect(route.matcher('./it.at/there/')).toBe(true);
+	});
+
+	it('match absolute urls with dots', () => {
+		const route = new Route({ matcher: 'http://it.at/there/', response: 200 });
+		expect(route.matcher('http://it.at/not/../there/')).toBe(true);
+	});
+
+	describe('host normalisation', () => {
+		it('match exact pathless urls regardless of trailing slash', () => {
+			const route = new Route({ matcher: 'http://a.com/', response: 200 });
+
+			expect(route.matcher('http://a.com/')).toBe(true);
+			expect(route.matcher('http://a.com')).toBe(true);
+
+			const route2 = new Route({ matcher: 'http://b.com', response: 200 });
+			expect(route2.matcher('http://b.com/')).toBe(true);
+			expect(route2.matcher('http://b.com')).toBe(true);
+		});
+	});
+
+	describe('data: URLs', () => {
+		it('match exact strings', () => {
+			const route = new Route({
+				matcher: 'data:text/plain,path',
+				response: 200,
+			});
+			expect(route.matcher('data:text/plain,pat')).toBe(false);
+			expect(route.matcher('data:text/plain,paths')).toBe(false);
+			expect(route.matcher('data:text/html,path')).toBe(false);
+			expect(route.matcher('data:text/plain,path')).toBe(true);
+		});
+		it('match exact string against URL object', () => {
+			const route = new Route({
+				matcher: 'data:text/plain,path',
+				response: 200,
+			});
+			const url = new URL('data:text/plain,path');
+			expect(route.matcher(url)).toBe(true);
+		});
+		it('match using URL object as matcher', () => {
+			const url = new URL('data:text/plain,path');
+			const route = new Route({ matcher: url, response: 200 });
+			expect(route.matcher('data:text/plain,path')).toBe(true);
+		});
+		it('match begin: keyword', () => {
+			const route = new Route({
+				matcher: 'begin:data:text/plain',
+				response: 200,
+			});
+			expect(route.matcher('http://a.com/path')).toBe(false);
+			expect(route.matcher('data:text/html,path')).toBe(false);
+			expect(route.matcher('data:text/plain,path')).toBe(true);
+			expect(route.matcher('data:text/plain;base64,cGF0aA')).toBe(true);
+		});
+		it('match end: keyword', () => {
+			const route = new Route({ matcher: 'end:sky', response: 200 });
+			expect(route.matcher('data:text/plain,blue lake')).toBe(false);
+			expect(route.matcher('data:text/plain,blue sky research')).toBe(false);
+			expect(route.matcher('data:text/plain,blue sky')).toBe(true);
+			expect(route.matcher('data:text/plain,grey sky')).toBe(true);
+		});
+		it('match glob: keyword', () => {
+			const route = new Route({ matcher: 'glob:data:* sky', response: 200 });
+			expect(route.matcher('data:text/plain,blue lake')).toBe(false);
+			expect(route.matcher('data:text/plain,blue sky')).toBe(true);
+			expect(route.matcher('data:text/plain,grey sky')).toBe(true);
+		});
+		it('match wildcard string', () => {
+			const route = new Route({ matcher: '*', response: 200 });
+			expect(route.matcher('data:text/plain,path')).toBe(true);
+		});
+		it('match regular expressions', () => {
+			const rx = /data\:text\/plain,\d+/;
+			const route = new Route({ matcher: rx, response: 200 });
+			expect(route.matcher('data:text/html,12345')).toBe(false);
+			expect(route.matcher('data:text/plain,path')).toBe(false);
+			expect(route.matcher('data:text/plain,12345')).toBe(true);
+		});
+	});
+});
diff --git a/packages/core/src/__tests__/router-integration.test.js b/packages/core/src/__tests__/router-integration.test.js
new file mode 100644
index 0000000..bf6a430
--- /dev/null
+++ b/packages/core/src/__tests__/router-integration.test.js
@@ -0,0 +1,137 @@
+import { describe, expect, it } from 'vitest';
+import fetchMock from '../FetchMock';
+describe('Router', () => {
+	describe('router integration', () => {
+		it('matchurls  when called with Request', async () => {
+			const fm = fetchMock.createInstance();
+			fm.post('http://a.com/', 200).catch();
+
+			await expect(
+				fm.fetchHandler(
+					new fm.config.Request('http://a.com/', { method: 'POST' }),
+				),
+			).resolves.not.toThrow();
+		});
+
+		it('match using custom function with Request', async () => {
+			const fm = fetchMock.createInstance();
+			fm.route((url, options) => {
+				return url.indexOf('logged-in') > -1 && options.headers.authorized;
+			}, 200);
+
+			await expect(
+				fm.fetchHandler(
+					new Request('http://a.com/logged-in', {
+						headers: { authorized: 'true' },
+					}),
+				),
+			).resolves.not.toThrow();
+		});
+
+		it('match using custom function with Request with unusual options', async () => {
+			// as node-fetch does not try to emulate all the WHATWG standards, we can't check for the
+			// same properties in the browser and nodejs
+			const propertyToCheck = new Request('http://example.com').cache
+				? 'credentials'
+				: 'compress';
+			const valueToSet = propertyToCheck === 'credentials' ? 'include' : false;
+
+			const fm = fetchMock.createInstance();
+			fm.route(
+				(url, options, request) => request[propertyToCheck] === valueToSet,
+				200,
+			);
+
+			await expect(
+				fm.fetchHandler(new Request('http://a.com/logged-in')),
+			).rejects.toThrow();
+			expect(
+				fm.fetchHandler(
+					new Request('http://a.com/logged-in', {
+						[propertyToCheck]: valueToSet,
+					}),
+				),
+			).resolves.not.toThrow();
+		});
+	});
+	describe('user defined matchers', () => {
+		it('match on sync property', async () => {
+			const fm = fetchMock.createInstance();
+			fm.defineMatcher({
+				name: 'syncMatcher',
+				matcher: (route) => (url) => url.indexOf(route.syncMatcher) > -1,
+			});
+			fm.route(
+				{
+					syncMatcher: 'a',
+				},
+				200,
+			).catch(404);
+			const miss = await fm.fetchHandler('http://b.com');
+			expect(miss.status).toEqual(404);
+			const hit = await fm.fetchHandler('http://a.com');
+			expect(hit.status).toEqual(200);
+		});
+
+		it('match on async body property', async () => {
+			const fm = fetchMock.createInstance();
+			fm.defineMatcher({
+				name: 'bodyMatcher',
+				matcher: (route) => (url, options) =>
+					JSON.parse(options.body)[route.bodyMatcher] === true,
+				usesBody: true,
+			});
+			fm.route(
+				{
+					bodyMatcher: 'a',
+				},
+				200,
+			).catch(404);
+			const miss = await fm.fetchHandler(
+				new fm.config.Request('http://a.com', {
+					method: 'POST',
+					body: JSON.stringify({ b: true }),
+				}),
+			);
+			expect(miss.status).toEqual(404);
+			const hit1 = await fm.fetchHandler(
+				new fm.config.Request('http://a.com', {
+					method: 'POST',
+					body: JSON.stringify({ a: true }),
+				}),
+			);
+			expect(hit1.status).toEqual(200);
+			const hit2 = await fm.fetchHandler('http://a.com', {
+				method: 'POST',
+				body: JSON.stringify({ a: true }),
+			});
+			expect(hit2.status).toEqual(200);
+		});
+
+		// TODO This test hangs
+		// Need to decide what the actual behaviour should be when trying to access body
+		// prematurely - should it throw early somehow when options.body is accessed?
+		it.skip('not match on async body property without passing `usesBody: true`', async () => {
+			const fm = fetchMock.createInstance();
+			fm.defineMatcher({
+				name: 'asyncBodyMatcher',
+				matcher: (route) => (url, options) =>
+					JSON.parse(options.body)[route.asyncBodyMatcher] === true,
+			});
+			fm.route(
+				{
+					asyncBodyMatcher: 'a',
+				},
+				200,
+			);
+			await expect(
+				fm.fetchHandler(
+					new fm.config.Request('http://a.com', {
+						method: 'POST',
+						body: JSON.stringify({ a: true }),
+					}),
+				),
+			).rejects.toThrow();
+		});
+	});
+});
diff --git a/packages/standalone/fallbackToNetwork.test.js b/packages/standalone/fallbackToNetwork.test.js
new file mode 100644
index 0000000..5bef3e8
--- /dev/null
+++ b/packages/standalone/fallbackToNetwork.test.js
@@ -0,0 +1,214 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+const { fetchMock } = testGlobals;
+
+describe('fallbackToNetwork', () => {
+	let fm;
+	beforeEach(() => {
+		fm = fetchMock.createInstance();
+	});
+	it('error by default', () => {
+		expect(() => fm.fetchHandler('http://unmocked.com')).toThrow();
+	});
+
+	it('not error when configured globally', () => {
+		globalThis.fetch = async () => ({ status: 202 }); //eslint-disable-line require-await
+		fm.config.fallbackToNetwork = true;
+		fm.mock('http://mocked.com', 201);
+		expect(() => fm.fetchHandler('http://unmocked.com')).not.toThrow();
+		delete globalThis.fetch;
+	});
+
+	it('actually falls back to network when configured globally', async () => {
+		globalThis.fetch = async () => ({ status: 202 }); //eslint-disable-line require-await
+		fetchMock.config.fallbackToNetwork = true;
+		fetchMock.mock('http://mocked.com', 201);
+		const res = await fetchMock.fetchHandler('http://unmocked.com');
+		expect(res.status).toEqual(202);
+		fetchMock.restore();
+		fetchMock.config.fallbackToNetwork = false;
+		delete globalThis.fetch;
+	});
+
+	it('actually falls back to network when configured in a sandbox properly', async () => {
+		const sbx = fm.sandbox();
+		sbx.config.fetch = async () => ({ status: 202 }); //eslint-disable-line require-await
+		sbx.config.fallbackToNetwork = true;
+		sbx.mock('http://mocked.com', 201);
+		const res = await sbx('http://unmocked.com');
+		expect(res.status).toEqual(202);
+	});
+
+	it('calls fetch with original Request object', async () => {
+		const sbx = fm.sandbox();
+		let calledWith;
+		//eslint-disable-next-line require-await
+		sbx.config.fetch = async (req) => {
+			calledWith = req;
+			return { status: 202 };
+		};
+		sbx.config.fallbackToNetwork = true;
+		sbx.mock('http://mocked.com', 201);
+		const req = new sbx.config.Request('http://unmocked.com');
+		await sbx(req);
+		expect(calledWith).toEqual(req);
+	});
+
+	describe('always', () => {
+		it('ignores routes that are matched', async () => {
+			fm.realFetch = async () => ({ status: 202 }); //eslint-disable-line require-await
+			fm.config.fallbackToNetwork = 'always';
+
+			fm.mock('http://mocked.com', 201);
+			const res = await fm.fetchHandler('http://unmocked.com');
+			expect(res.status).toEqual(202);
+		});
+
+		it('ignores routes that are not matched', async () => {
+			fm.realFetch = async () => ({ status: 202 }); //eslint-disable-line require-await
+
+			fm.config.fallbackToNetwork = 'always';
+
+			fm.mock('http://mocked.com', 201);
+			const res = await fm.fetchHandler('http://unmocked.com');
+			expect(res.status).toEqual(202);
+		});
+	});
+
+	describe.skip('warnOnFallback', () => {
+		it('warn on fallback response by default', () => {}); //eslint-disable-line no-empty-function
+		it("don't warn on fallback response when configured false", () => {}); //eslint-disable-line no-empty-function
+	});
+});
+
+
+
+// import { Readable, Writable } from 'stream';
+// describe('nodejs only tests', () => {
+//     describe('support for nodejs body types', () => {
+
+
+
+//         // only works in node-fetch@2
+//         it.skip('can respond with a readable stream', () =>
+//             new Promise((res) => {
+//                 const readable = new Readable();
+//                 const write = vi.fn().mockImplementation((chunk, enc, cb) => {
+//                     cb();
+//                 });
+//                 const writable = new Writable({
+//                     write,
+//                 });
+//                 readable.push('response string');
+//                 readable.push(null);
+
+//                 fetchMock.route(/a/, readable, { sendAsJson: false });
+//                 fetchMock.fetchHandler('http://a.com').then((res) => {
+//                     res.body.pipe(writable);
+//                 });
+
+//                 writable.on('finish', () => {
+//                     expect(write.args[0][0].toString('utf8')).to.equal('response string');
+//                     res();
+//                 });
+//             }));
+
+//         // See https://github.com/wheresrhys/fetch-mock/issues/575
+//         it('can respond with large bodies from the interweb', async () => {
+//             const fm = fetchMock.sandbox();
+//             fm.config.fallbackToNetwork = true;
+//             fm.route();
+//             // this is an adequate test because the response hangs if the
+//             // bug referenced above creeps back in
+//             await fm
+//                 .fetchHandler('http://www.wheresrhys.co.uk/assets/img/chaffinch.jpg')
+//                 .then((res) => res.blob());
+//         });
+
+
+
+
+// describe.skip('client-side only tests', () => {
+//     it('not throw when passing unmatched calls through to native fetch', () => {
+//         fetchMock.config.fallbackToNetwork = true;
+//         fetchMock.route();
+//         expect(() => fetch('http://a.com')).not.to.throw();
+//         fetchMock.config.fallbackToNetwork = false;
+//     });
+
+//     // this is because we read the body once when normalising the request and
+//     // want to make sure fetch can still use the sullied request
+//     it.skip('can send a body on a Request instance when spying ', async () => {
+//         fetchMock.spy();
+//         const req = new fetchMock.config.Request('http://example.com', {
+//             method: 'post',
+//             body: JSON.stringify({ prop: 'val' }),
+//         });
+//         try {
+//             await fetch(req);
+//         } catch (err) {
+//             console.log(err);
+//             expect.unreachable('Fetch should not throw or reject');
+//         }
+//     });
+
+//     // in the browser the fetch spec disallows invoking res.headers on an
+//     // object that inherits from a response, thus breaking the ability to
+//     // read headers of a fake redirected response.
+//     if (typeof window === 'undefined') {
+//         it('not convert if `redirectUrl` property exists', async () => {
+//             fm.route('*', {
+//                 redirectUrl: 'http://url.to.hit',
+//             });
+//             const res = await fm.fetchHandler('http://a.com/');
+//             expect(res.headers.get('content-type')).toBeNull();
+//         });
+//     }
+
+
+
+//     it.skip('should cope when there is no global fetch defined', () => {
+//         const originalFetch = globalThis.fetch;
+//         delete globalThis.fetch;
+//         const originalRealFetch = fetchMock.realFetch;
+//         delete fetchMock.realFetch;
+//         fetchMock.route('*', 200);
+//         expect(() => {
+//             fetch('http://a.com');
+//         }).not.to.throw();
+
+//         expect(() => {
+//             fetchMock.calls();
+//         }).not.to.throw();
+//         fetchMock.restore();
+//         fetchMock.realFetch = originalRealFetch;
+//         globalThis.fetch = originalFetch;
+//     });
+
+//     if (globalThis.navigator?.serviceWorker) {
+//         it('should work within a service worker', async () => {
+//             const registration =
+//                 await globalThis.navigator.serviceWorker.register('__sw.js');
+//             await new Promise((resolve, reject) => {
+//                 if (registration.installing) {
+//                     registration.installing.onstatechange = function () {
+//                         if (this.state === 'activated') {
+//                             resolve();
+//                         }
+//                     };
+//                 } else {
+//                     reject('No idea what happened');
+//                 }
+//             });
+
+//             await registration.unregister();
+//         });
+//     }
+
+// });
+
+
+
+
+
+
diff --git a/packages/standalone/global-fetch.test.js b/packages/standalone/global-fetch.test.js
new file mode 100644
index 0000000..e6c7bb2
--- /dev/null
+++ b/packages/standalone/global-fetch.test.js
@@ -0,0 +1,136 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { fetchMock } = testGlobals;
+
+describe('use with global fetch', () => {
+	let originalFetch;
+
+	const expectToBeStubbed = (yes = true) => {
+		expect(globalThis.fetch).toEqual(
+			yes ? fetchMock.fetchHandler : originalFetch,
+		);
+		expect(globalThis.fetch).not.toEqual(
+			yes ? originalFetch : fetchMock.fetchHandler,
+		);
+	};
+
+	beforeEach(() => {
+		originalFetch = globalThis.fetch = vi.fn().mockResolvedValue();
+	});
+	afterEach(fetchMock.restore);
+
+	it('replaces global fetch when mock called', () => {
+		fetchMock.mock('*', 200);
+		expectToBeStubbed();
+	});
+
+	it('replaces global fetch when catch called', () => {
+		fetchMock.catch(200);
+		expectToBeStubbed();
+	});
+
+	it('replaces global fetch when spy called', () => {
+		fetchMock.spy();
+		expectToBeStubbed();
+	});
+
+	it('restores global fetch after a mock', () => {
+		fetchMock.mock('*', 200).restore();
+		expectToBeStubbed(false);
+	});
+
+	it('restores global fetch after a complex mock', () => {
+		fetchMock.mock('a', 200).mock('b', 200).spy().catch(404).restore();
+		expectToBeStubbed(false);
+	});
+
+	it('not call default fetch when in mocked mode', async () => {
+		fetchMock.mock('*', 200);
+
+		await globalThis.fetch('http://a.com');
+		expect(originalFetch).not.toHaveBeenCalled();
+	});
+});
+let originalFetch;
+
+beforeAll(() => {
+	originalFetch = globalThis.fetch = vi.fn().mockResolvedValue('dummy');
+});
+
+it('return function', () => {
+	const sbx = fetchMock.sandbox();
+	expect(typeof sbx).toEqual('function');
+});
+
+
+
+it("don't interfere with global fetch", () => {
+	const sbx = fetchMock.sandbox().route('http://a.com', 200);
+
+	expect(globalThis.fetch).toEqual(originalFetch);
+	expect(globalThis.fetch).not.toEqual(sbx);
+});
+
+it("don't interfere with global fetch-mock", async () => {
+	const sbx = fetchMock.sandbox().route('http://a.com', 200).catch(302);
+
+	fetchMock.route('http://b.com', 200).catch(301);
+
+	expect(globalThis.fetch).toEqual(fetchMock.fetchHandler);
+	expect(fetchMock.fetchHandler).not.toEqual(sbx);
+	expect(fetchMock.fallbackResponse).not.toEqual(sbx.fallbackResponse);
+	expect(fetchMock.routes).not.toEqual(sbx.routes);
+
+	const [sandboxed, globally] = await Promise.all([
+		sbx('http://a.com'),
+		fetch('http://b.com'),
+	]);
+
+	expect(sandboxed.status).toEqual(200);
+	expect(globally.status).toEqual(200);
+	expect(sbx.called('http://a.com')).toBe(true);
+	expect(sbx.called('http://b.com')).toBe(false);
+	expect(fetchMock.called('http://b.com')).toBe(true);
+	expect(fetchMock.called('http://a.com')).toBe(false);
+	expect(sbx.called('http://a.com')).toBe(true);
+	fetchMock.restore();
+});
+
+describe('global mocking', () => {
+	let originalFetch;
+	beforeAll(() => {
+		originalFetch = globalThis.fetch = vi.fn().mockResolvedValue();
+	});
+	afterEach(() => fetchMock.restore({ sticky: true }));
+
+	it('global mocking resists resetBehavior calls', () => {
+		fetchMock.route('*', 200, { sticky: true }).resetBehavior();
+		expect(globalThis.fetch).not.toEqual(originalFetch);
+	});
+
+	it('global mocking does not resist resetBehavior calls when sent `sticky: true`', () => {
+		fetchMock
+			.route('*', 200, { sticky: true })
+			.resetBehavior({ sticky: true });
+		expect(globalThis.fetch).toEqual(originalFetch);
+	});
+});
+
+describe('sandboxes', () => {
+	it('sandboxed instances should inherit stickiness', () => {
+		const sbx1 = fetchMock
+			.sandbox()
+			.route('*', 200, { sticky: true })
+			.catch(300);
+
+		const sbx2 = sbx1.sandbox().resetBehavior();
+
+		expect(sbx1.routes.length).toEqual(1);
+		expect(sbx2.routes.length).toEqual(1);
+
+		sbx2.resetBehavior({ sticky: true });
+
+		expect(sbx1.routes.length).toEqual(1);
+		expect(sbx2.routes.length).toEqual(0);
+	});
+});
\ No newline at end of file
diff --git a/packages/standalone/set-up-and-tear-down.test.js b/packages/standalone/set-up-and-tear-down.test.js
new file mode 100644
index 0000000..a4223a3
--- /dev/null
+++ b/packages/standalone/set-up-and-tear-down.test.js
@@ -0,0 +1,198 @@
+import {
+	afterEach,
+	beforeEach,
+	describe,
+	expect,
+	it,
+	beforeAll,
+	vi,
+} from 'vitest';
+
+const { fetchMock } = testGlobals;
+describe('Set up and tear down', () => {
+	let fm;
+	beforeAll(() => {
+		fm = fetchMock.createInstance();
+		fm.config.warnOnUnmatched = false;
+	});
+	afterEach(() => fm.restore());
+
+	const testChainableMethod = (method, ...args) => {
+		it(`${method}() is chainable`, () => {
+			expect(fm[method](...args)).toEqual(fm);
+		});
+
+		it(`${method}() has "this"`, () => {
+			vi.spyOn(fm, method).mockReturnThis();
+			expect(fm[method](...args)).toBe(fm);
+			fm[method].mockRestore();
+		});
+	};
+
+	describe('mock', () => {
+		testChainableMethod('mock', '*', 200);
+
+		it('can be called multiple times', () => {
+			expect(() => {
+				fm.mock('http://a.com', 200).mock('http://b.com', 200);
+			}).not.toThrow();
+		});
+
+		it('can be called after fetchMock is restored', () => {
+			expect(() => {
+				fm.mock('*', 200).restore().mock('*', 200);
+			}).not.toThrow();
+		});
+
+		describe('parameters', () => {
+			beforeEach(() => {
+				vi.spyOn(fm, 'compileRoute');
+				vi.spyOn(fm, '_mock').mockReturnValue(fm);
+			});
+
+			afterEach(() => {
+				fm.compileRoute.mockRestore();
+				fm._mock.mockRestore();
+			});
+
+			it('accepts single config object', () => {
+				const config = {
+					url: '*',
+					response: 200,
+				};
+				expect(() => fm.mock(config)).not.toThrow();
+				expect(fm.compileRoute).toHaveBeenCalledWith([config]);
+				expect(fm._mock).toHaveBeenCalled();
+			});
+
+			it('accepts matcher, route pairs', () => {
+				expect(() => fm.mock('*', 200)).not.toThrow();
+				expect(fm.compileRoute).toHaveBeenCalledWith(['*', 200]);
+				expect(fm._mock).toHaveBeenCalled();
+			});
+
+			it('accepts matcher, response, config triples', () => {
+				expect(() =>
+					fm.mock('*', 'ok', {
+						method: 'PUT',
+						some: 'prop',
+					}),
+				).not.toThrow();
+				expect(fm.compileRoute).toHaveBeenCalledWith([
+					'*',
+					'ok',
+					{
+						method: 'PUT',
+						some: 'prop',
+					},
+				]);
+				expect(fm._mock).toHaveBeenCalled();
+			});
+
+			it('expects a matcher', () => {
+				expect(() => fm.mock(null, 'ok')).toThrow();
+			});
+
+			it('expects a response', () => {
+				expect(() => fm.mock('*')).toThrow();
+			});
+
+			it('can be called with no parameters', () => {
+				expect(() => fm.mock()).not.toThrow();
+				expect(fm.compileRoute).not.toHaveBeenCalled();
+				expect(fm._mock).toHaveBeenCalled();
+			});
+
+			it('should accept object responses when also passing options', () => {
+				expect(() =>
+					fm.mock('*', { foo: 'bar' }, { method: 'GET' }),
+				).not.toThrow();
+			});
+		});
+	});
+
+	describe('reset', () => {
+		testChainableMethod('reset');
+
+		it('can be called even if no mocks set', () => {
+			expect(() => fm.restore()).not.toThrow();
+		});
+
+		it('calls resetHistory', () => {
+			vi.spyOn(fm, 'resetHistory');
+			fm.restore();
+			expect(fm.resetHistory).toHaveBeenCalledTimes(1);
+			fm.resetHistory.mockRestore();
+		});
+
+		it('removes all routing', () => {
+			fm.mock('*', 200).catch(200);
+
+			expect(fm.routes.length).toEqual(1);
+			expect(fm.fallbackResponse).toBeDefined();
+
+			fm.restore();
+
+			expect(fm.routes.length).toEqual(0);
+			expect(fm.fallbackResponse).toBeUndefined();
+		});
+
+		it('restore is an alias for reset', () => {
+			expect(fm.restore).toEqual(fm.reset);
+		});
+	});
+
+	describe('resetBehavior', () => {
+		testChainableMethod('resetBehavior');
+
+		it('can be called even if no mocks set', () => {
+			expect(() => fm.resetBehavior()).not.toThrow();
+		});
+
+		it('removes all routing', () => {
+			fm.mock('*', 200).catch(200);
+
+			expect(fm.routes.length).toEqual(1);
+			expect(fm.fallbackResponse).toBeDefined();
+
+			fm.resetBehavior();
+
+			expect(fm.routes.length).toEqual(0);
+			expect(fm.fallbackResponse).toBeUndefined();
+		});
+	});
+
+	describe('resetHistory', () => {
+		testChainableMethod('resetHistory');
+
+		it('can be called even if no mocks set', () => {
+			expect(() => fm.resetHistory()).not.toThrow();
+		});
+
+		it('resets call history', async () => {
+			fm.mock('*', 200).catch(200);
+			await fm.fetchHandler('a');
+			await fm.fetchHandler('b');
+			expect(fm.called()).toBe(true);
+
+			fm.resetHistory();
+			expect(fm.called()).toBe(false);
+			expect(fm.called('*')).toBe(false);
+			expect(fm.calls('*').length).toEqual(0);
+			expect(fm.calls(true).length).toEqual(0);
+			expect(fm.calls(false).length).toEqual(0);
+			expect(fm.calls().length).toEqual(0);
+		});
+	});
+
+	describe('spy', () => {
+		testChainableMethod('spy');
+
+		it('calls catch()', () => {
+			vi.spyOn(fm, 'catch');
+			fm.spy();
+			expect(fm.catch).toHaveBeenCalledTimes(1);
+			fm.catch.mockRestore();
+		});
+	});
+});
diff --git a/packages/standalone/spy.test.js b/packages/standalone/spy.test.js
new file mode 100644
index 0000000..f8fa633
--- /dev/null
+++ b/packages/standalone/spy.test.js
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const { fetchMock } = testGlobals;
+describe('spy()', () => {
+	it('when mocking globally, spy falls through to global fetch', async () => {
+		const originalFetch = globalThis.fetch;
+		const fetchSpy = vi.fn().mockResolvedValue('example');
+
+		globalThis.fetch = fetchSpy;
+
+		fetchMock.spy();
+
+		await globalThis.fetch('http://a.com/', { method: 'get' });
+		expect(fetchSpy).toHaveBeenCalledWith(
+			'http://a.com/',
+			{ method: 'get' },
+			undefined,
+		);
+		fetchMock.restore();
+		globalThis.fetch = originalFetch;
+	});
+
+	it('when mocking locally, spy falls through to configured fetch', async () => {
+		const fetchSpy = vi.fn().mockResolvedValue('dummy');
+
+		const fm = fetchMock.sandbox();
+		fm.config.fetch = fetchSpy;
+
+		fm.spy();
+		await fm.fetchHandler('http://a.com/', { method: 'get' });
+		expect(fetchSpy).toHaveBeenCalledWith(
+			'http://a.com/',
+			{ method: 'get' },
+			undefined,
+		);
+		fm.restore();
+	});
+
+	it('can restrict spying to a route', async () => {
+		const fetchSpy = vi.fn().mockResolvedValue('dummy');
+
+		const fm = fetchMock.sandbox();
+		fm.config.fetch = fetchSpy;
+
+		fm.spy({ url: 'http://a.com/', method: 'get' });
+		await fm.fetchHandler('http://a.com/', { method: 'get' });
+		expect(fetchSpy).toHaveBeenCalledWith(
+			'http://a.com/',
+			{ method: 'get' },
+			undefined,
+		);
+
+		expect(() => fm.fetchHandler('http://b.com/', { method: 'get' })).toThrow();
+		expect(() =>
+			fm.fetchHandler('http://a.com/', { method: 'post' }),
+		).toThrow();
+		fm.restore();
+	});
+});
+
+
+it('error if spy() is called and no fetch defined in config', () => {
+	const fm = fetchMock.sandbox();
+	delete fm.config.fetch;
+	expect(() => fm.spy()).toThrow();
+});
+
+it("don't error if spy() is called and fetch defined in config", () => {
+	const fm = fetchMock.sandbox();
+	fm.config.fetch = originalFetch;
+	expect(() => fm.spy()).not.toThrow();
+});
+
+it('exports a properly mocked node-fetch module shape', () => {
+	// uses node-fetch default require pattern
+	const {
+		default: fetch,
+		Headers,
+		Request,
+		Response,
+	} = fetchMock.sandbox();
+
+	expect(fetch.name).toEqual('fetchMockProxy');
+	expect(new Headers()).toBeInstanceOf(fetchMock.config.Headers);
+	expect(new Request('http://a.com')).toBeInstanceOf(
+		fetchMock.config.Request,
+	);
+	expect(new Response()).toBeInstanceOf(fetchMock.config.Response);
+});
\ No newline at end of file
diff --git a/packages/wip/generated-types/__tests__/CallHistory.test.d.ts b/packages/wip/generated-types/__tests__/CallHistory.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/CallHistory.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/FetchHandler.test.d.ts b/packages/wip/generated-types/__tests__/FetchHandler.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/FetchHandler.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/FetchMockWrapper.test.d.ts b/packages/wip/generated-types/__tests__/FetchMockWrapper.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/FetchMockWrapper.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Matchers.test.d.ts b/packages/wip/generated-types/__tests__/Matchers.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Matchers.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/ResponseBuilder.test.d.ts b/packages/wip/generated-types/__tests__/ResponseBuilder.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/ResponseBuilder.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/Router.test.d.ts b/packages/wip/generated-types/__tests__/Router/Router.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/Router.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/body-matching.test.d.ts b/packages/wip/generated-types/__tests__/Router/body-matching.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/body-matching.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/edge-cases.test.d.ts b/packages/wip/generated-types/__tests__/Router/edge-cases.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/edge-cases.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/function-matching.test.d.ts b/packages/wip/generated-types/__tests__/Router/function-matching.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/function-matching.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/header-matching.test.d.ts b/packages/wip/generated-types/__tests__/Router/header-matching.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/header-matching.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/matchPartialBody.test.d.ts b/packages/wip/generated-types/__tests__/Router/matchPartialBody.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/matchPartialBody.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/matcher-object.test.d.ts b/packages/wip/generated-types/__tests__/Router/matcher-object.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/matcher-object.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/method-matching.test.d.ts b/packages/wip/generated-types/__tests__/Router/method-matching.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/method-matching.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/multiple-routes.test.d.ts b/packages/wip/generated-types/__tests__/Router/multiple-routes.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/multiple-routes.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/naming-routes.test.d.ts b/packages/wip/generated-types/__tests__/Router/naming-routes.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/naming-routes.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/path-parameter-matching.test.d.ts b/packages/wip/generated-types/__tests__/Router/path-parameter-matching.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/path-parameter-matching.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/query-string-matching.test.d.ts b/packages/wip/generated-types/__tests__/Router/query-string-matching.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/query-string-matching.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/sticky-routes.test.d.ts b/packages/wip/generated-types/__tests__/Router/sticky-routes.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/sticky-routes.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/unmatched-calls.test.d.ts b/packages/wip/generated-types/__tests__/Router/unmatched-calls.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/unmatched-calls.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/wip/generated-types/__tests__/Router/url-matching.test.d.ts b/packages/wip/generated-types/__tests__/Router/url-matching.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/packages/wip/generated-types/__tests__/Router/url-matching.test.d.ts
@@ -0,0 +1,1 @@
+export {};
diff --git a/packages/fetch-mock/types/tslint.json b/tslint.json
similarity index 100%
rename from packages/fetch-mock/types/tslint.json
rename to tslint.json
diff --git a/vitest.config.js b/vitest.config.js
index b911103..ad2507e 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -30,8 +30,24 @@ const configs = {
 	commonjs: {
 		setupFiles: './packages/fetch-mock/test/setup/commonjs.cjs',
 	},
+	// packages: {
+	// 	reporters: ['default', 'html'],
+	// 	coverage: {
+	// 		reporter: ['text', 'html', 'clover', 'json'],
+	// 		provider: 'v8',
+	// 		reportOnFailure: true,
+	// 		enabled: true,
+	// 	},
+	// },
 };
 
 export default defineConfig({
-	test: configs[process.env.TESTING_ENV || 'server'],
+	test: {
+		...configs[process.env.TESTING_ENV || 'server'],
+		exclude: [
+			'packages/standalone/*.test.js',
+			'packages/fetch-mock/test/framework-compat/jest.spec.js',
+			'**/node_modules/**',
+		],
+	},
 });