├── .babelrc
├── .eslintrc.js
├── .github
└── workflows
│ ├── main.yml
│ └── size.yml
├── .gitignore
├── LICENSE
├── README.md
├── nitrous-oxide.svg
├── package.json
├── src
└── index.ts
├── test
└── blah.test.ts
├── tsconfig.json
├── tsdx.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "esmodules": true
8 | },
9 | "bugfixes": true
10 | }
11 | ]
12 | ],
13 | "plugins": [
14 | ["@babel/plugin-proposal-object-rest-spread", { "loose": true, "useBuiltIns": true }]
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "extends": [
3 | "react-app",
4 | "prettier/@typescript-eslint",
5 | "plugin:prettier/recommended"
6 | ],
7 | "settings": {
8 | "react": {
9 | "version": "999.999.999"
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
6 |
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | node: ['10.x', '12.x', '14.x']
11 | os: [ubuntu-latest, windows-latest, macOS-latest]
12 |
13 | steps:
14 | - name: Checkout repo
15 | uses: actions/checkout@v2
16 |
17 | - name: Use Node ${{ matrix.node }}
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: ${{ matrix.node }}
21 |
22 | - name: Install deps and build (with cache)
23 | uses: bahmutov/npm-install@v1
24 |
25 | - name: Lint
26 | run: yarn lint
27 |
28 | - name: Test
29 | run: yarn test --ci --coverage --maxWorkers=2
30 |
31 | - name: Build
32 | run: yarn build
33 |
--------------------------------------------------------------------------------
/.github/workflows/size.yml:
--------------------------------------------------------------------------------
1 | name: size
2 | on: [pull_request]
3 | jobs:
4 | size:
5 | runs-on: ubuntu-latest
6 | env:
7 | CI_JOB_NUMBER: 1
8 | steps:
9 | - uses: actions/checkout@v1
10 | - uses: andresz1/size-limit-action@v1
11 | with:
12 | github_token: ${{ secrets.GITHUB_TOKEN }}
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | dist
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jonathan Fleckenstein
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Nitrous Oxide 
2 |
3 | Don't let turbo lag get you down! Add a shot of nitrous to make your sites blazing fast!
4 |
5 | A companion library for https://github.com/hotwired/turbo.
6 |
7 | ```bash
8 | yarn add nitrous-oxide
9 | # or
10 | npm i nitrous-oxide
11 | ```
12 |
13 | ```js
14 | import { start } from "@hotwired/turbo";
15 | import { goFast } from "nitrous-oxide";
16 | start();
17 | goFast();
18 | ```
19 |
20 | ## Why?
21 | Even fetches to well tuned origin servers have a decent amount of latency. Pre-fetching is a simple way to overcome this. You'll want to make sure your HTML responses have a `max-age=30` or similar cache control header set for this to work properly.
22 |
--------------------------------------------------------------------------------
/nitrous-oxide.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.15.0",
3 | "license": "MIT",
4 | "main": "dist/index.js",
5 | "typings": "dist/index.d.ts",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/fleck/nitrous-oxide"
9 | },
10 | "files": [
11 | "dist",
12 | "src"
13 | ],
14 | "engines": {
15 | "node": ">=10"
16 | },
17 | "scripts": {
18 | "start": "tsdx watch",
19 | "build": "tsdx build --format cjs,esm,umd",
20 | "test": "tsdx test",
21 | "lint": "tsdx lint",
22 | "prepare": "tsdx build --format cjs,esm,umd",
23 | "size": "size-limit",
24 | "analyze": "size-limit --why"
25 | },
26 | "peerDependencies": {},
27 | "husky": {
28 | "hooks": {
29 | "pre-commit": "tsdx lint"
30 | }
31 | },
32 | "prettier": {
33 | "trailingComma": "all"
34 | },
35 | "name": "nitrous-oxide",
36 | "author": "Jonathan Fleckenstein",
37 | "module": "dist/nitrous-oxide.esm.js",
38 | "size-limit": [
39 | {
40 | "path": "dist/nitrous-oxide.cjs.production.min.js",
41 | "limit": "10 KB"
42 | },
43 | {
44 | "path": "dist/nitrous-oxide.esm.js",
45 | "limit": "10 KB"
46 | }
47 | ],
48 | "devDependencies": {
49 | "@size-limit/preset-small-lib": "^4.9.1",
50 | "husky": "^4.3.6",
51 | "size-limit": "^4.9.1",
52 | "tsdx": "^0.14.1",
53 | "tslib": "^2.0.3"
54 | },
55 | "dependencies": {
56 | "@hotwired/turbo": "^7.0.0-beta.4",
57 | "@types/lodash": "^4.14.166",
58 | "delegate-it": "^2.0.2",
59 | "lodash": "^4.17.20",
60 | "lodash-es": "^4.17.20",
61 | "mem": "^8.0.0"
62 | },
63 | "resolutions": {
64 | "prettier": "2.2.1",
65 | "typescript": "^4.1.3"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import delegate from "delegate-it";
2 | import { visit, navigator } from "@hotwired/turbo";
3 | import mem from "mem";
4 | import debounce from "lodash/debounce";
5 | import type { Visit } from "@hotwired/turbo/dist/types/core/drive/visit";
6 | import type { FetchRequest } from "@hotwired/turbo/dist/types/http/fetch_request";
7 | import type { FetchResponse } from "@hotwired/turbo/dist/types/http/fetch_response";
8 | import type { FrameController } from "@hotwired/turbo/dist/types/core/frames/frame_controller";
9 |
10 | export type FrameElement = { delegate: FrameController } & Element;
11 |
12 | const inflight = new Map>();
13 |
14 | export const visitFrame = (response: Response, frame: FrameElement) =>
15 | frame.delegate.requestSucceededWithResponse(
16 | {} as FetchRequest,
17 | {
18 | get responseHTML() {
19 | return response.text();
20 | },
21 | } as FetchResponse,
22 | );
23 |
24 | export const goFast = ({
25 | keyupDebounce = 150,
26 | onkeyup = true,
27 | idempotentFormSelector = 'form:not([method="post"])',
28 | anchorSelector = "a",
29 | } = {}) => {
30 | (["mouseover", "touchstart"] as const).forEach((event) =>
31 | delegate(
32 | document,
33 | `${anchorSelector}, ${idempotentFormSelector}`,
34 | event,
35 | prefetch,
36 | ),
37 | );
38 |
39 | delegate(document, anchorSelector, "click", startVisit);
40 |
41 | delegate(document, idempotentFormSelector, "submit", startVisit);
42 |
43 | if (onkeyup) {
44 | delegate(
45 | document,
46 | idempotentFormSelector,
47 | "keyup",
48 | debounce(prefetch, keyupDebounce),
49 | );
50 | }
51 |
52 | /** IDEA: Preload images and SVG files on mouse down and touch start? */
53 | };
54 |
55 | const startVisit = (event: delegate.Event) => {
56 | if (disabled(event)) return;
57 |
58 | const url = extractURLFrom(event.delegateTarget);
59 | if (!url) return;
60 |
61 | const inflightRequest = inflight.get(url);
62 | if (!inflightRequest) return;
63 |
64 | event.preventDefault();
65 |
66 | const turboFrame = document.querySelector(
67 | `turbo-frame[id="${event.delegateTarget.getAttribute(
68 | "data-turbo-frame",
69 | )}"]`,
70 | );
71 |
72 | if (turboFrame) {
73 | turboFrame.delegate.requestStarted({} as FetchRequest);
74 | } else {
75 | navigator.adapter.visitRequestStarted({
76 | hasCachedSnapshot: () => false,
77 | } as Visit);
78 | }
79 |
80 | inflightRequest.then((response) => {
81 | if (turboFrame) {
82 | visitFrame(response, turboFrame);
83 | turboFrame.delegate.requestFinished({} as FetchRequest);
84 |
85 | return;
86 | }
87 |
88 | navigator.adapter.visitRequestFinished({} as Visit);
89 |
90 | response.text().then((responseHTML) => {
91 | visit(url, {
92 | response: { statusCode: response.status, responseHTML },
93 | });
94 | });
95 | });
96 | };
97 |
98 | const memoizedFetch = mem(fetch, { maxAge: 3000, cacheKey: JSON.stringify });
99 |
100 | export const turboFetch = (url: string, frameId: string | null) =>
101 | memoizedFetch(url, {
102 | credentials: "include",
103 | headers: {
104 | accept: "text/html, application/xhtml+xml",
105 | ...(frameId ? { "turbo-frame": frameId } : {}),
106 | },
107 | });
108 |
109 | const disabled = (event: Event) =>
110 | event.target instanceof HTMLElement &&
111 | event.target.dataset.nitrous === "false";
112 |
113 | const prefetch = (event: delegate.Event) => {
114 | if (disabled(event)) {
115 | return;
116 | }
117 |
118 | const fullURL = extractURLFrom(event.delegateTarget);
119 |
120 | if (!fullURL) return;
121 |
122 | const newURL = new URL(fullURL);
123 |
124 | if (newURL.hostname !== window.location.hostname) {
125 | return;
126 | }
127 |
128 | const urlWithoutHash =
129 | window.location.origin + newURL.pathname + newURL.search;
130 |
131 | if (inflight.has(urlWithoutHash)) return;
132 |
133 | const turboFrameId = event.delegateTarget.getAttribute("data-turbo-frame");
134 |
135 | const request = turboFetch(urlWithoutHash, turboFrameId);
136 |
137 | request.then((response) => {
138 | if (!response.headers.get("Cache-Control")?.includes("max-age")) {
139 | console.warn(
140 | `Pre-fetched response from ${response.url} should include max-age.`,
141 | );
142 | }
143 | });
144 |
145 | inflight.set(
146 | urlWithoutHash,
147 | request.finally(() => {
148 | inflight.delete(urlWithoutHash);
149 | }),
150 | );
151 | };
152 |
153 | export const extractURLFrom = (target: EventTarget | null) => {
154 | if (target instanceof HTMLAnchorElement) {
155 | return target.href;
156 | } else if (target instanceof HTMLFormElement) {
157 | const url = new URL(target.action);
158 |
159 | url.search = new URLSearchParams(new FormData(target) as any).toString();
160 |
161 | return url.toString();
162 | }
163 | };
164 |
--------------------------------------------------------------------------------
/test/blah.test.ts:
--------------------------------------------------------------------------------
1 | describe("blah", () => {
2 | it("works", () => {
3 | expect(2).toEqual(2);
4 | });
5 | });
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src", "types"],
4 | "compilerOptions": {
5 | "module": "esnext",
6 | "target": "esnext",
7 | "lib": ["dom", "esnext"],
8 | "importHelpers": true,
9 | // output .d.ts declaration files for consumers
10 | "declaration": true,
11 | // output .js.map sourcemap files for consumers
12 | "sourceMap": true,
13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
14 | "rootDir": "./src",
15 | // stricter type-checking for stronger correctness. Recommended by TS
16 | "strict": true,
17 | // linter checks for common issues
18 | "noFallthroughCasesInSwitch": true,
19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | // use Node's module resolution algorithm, instead of the legacy TS one
23 | "moduleResolution": "node",
24 | // transpile JSX to React.createElement
25 | "jsx": "react",
26 | // interop between ESM and CJS modules. Recommended by TS
27 | "esModuleInterop": true,
28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
29 | "skipLibCheck": true,
30 | // error out if import and file system have a casing mismatch. Recommended by TS
31 | "forceConsistentCasingInFileNames": true,
32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc`
33 | "noEmit": true,
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tsdx.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | rollup(config) {
3 | if (config.output.format === "umd") {
4 | config.external = (id) => {
5 | // bundle in polyfills as TSDX can't (yet) ensure they're installed as deps
6 | console.log({ id });
7 | if (id.includes("@hotwired/turbo")) {
8 | return true;
9 | }
10 |
11 | return false;
12 | };
13 |
14 | config.output.globals["@hotwired/turbo"] = "Turbo";
15 | }
16 | return config;
17 | },
18 | };
19 |
--------------------------------------------------------------------------------