mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Vite-based frontend builds (#1900)
* Remove image conversion, add gzip * Add MacOS Environment options
This commit is contained in:
committed by
GitHub
parent
23b7d63417
commit
a4e52d3130
12
Makefile
12
Makefile
@@ -165,16 +165,16 @@ pre-ui:
|
||||
|
||||
.PHONY: ui
|
||||
ui: pre-build
|
||||
$(SET) REACT_APP_DATE="$(BUILD_DATE)" $(SEPARATOR) \
|
||||
$(SET) REACT_APP_GITHASH=$(GITHASH) $(SEPARATOR) \
|
||||
$(SET) REACT_APP_STASH_VERSION=$(STASH_VERSION) $(SEPARATOR) \
|
||||
$(SET) VITE_APP_DATE="$(BUILD_DATE)" $(SEPARATOR) \
|
||||
$(SET) VITE_APP_GITHASH=$(GITHASH) $(SEPARATOR) \
|
||||
$(SET) VITE_APP_STASH_VERSION=$(STASH_VERSION) $(SEPARATOR) \
|
||||
cd ui/v2.5 && yarn build
|
||||
|
||||
.PHONY: ui-start
|
||||
ui-start: pre-build
|
||||
$(SET) REACT_APP_DATE="$(BUILD_DATE)" $(SEPARATOR) \
|
||||
$(SET) REACT_APP_GITHASH=$(GITHASH) $(SEPARATOR) \
|
||||
$(SET) REACT_APP_STASH_VERSION=$(STASH_VERSION) $(SEPARATOR) \
|
||||
$(SET) VITE_APP_DATE="$(BUILD_DATE)" $(SEPARATOR) \
|
||||
$(SET) VITE_APP_GITHASH=$(GITHASH) $(SEPARATOR) \
|
||||
$(SET) VITE_APP_STASH_VERSION=$(STASH_VERSION) $(SEPARATOR) \
|
||||
cd ui/v2.5 && yarn start
|
||||
|
||||
.PHONY: fmt-ui
|
||||
|
||||
@@ -24,22 +24,22 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MingW.
|
||||
|
||||
### macOS
|
||||
|
||||
TODO
|
||||
|
||||
1. If you don't have it already, install the [Homebrew package manager](https://brew.sh).
|
||||
2. Install dependencies: `brew install go git yarn gcc make`
|
||||
|
||||
## Commands
|
||||
|
||||
* `make generate` - Generate Go and UI GraphQL files
|
||||
* `make build` - Builds the binary (make sure to build the UI as well... see below)
|
||||
* `make docker-build` - Locally builds and tags a complete 'stash/build' docker image
|
||||
* `make pre-ui` - Installs the UI dependencies. Only needs to be run once before building the UI for the first time, or if the dependencies are updated
|
||||
* `make generate` - Generate Go and UI GraphQL files
|
||||
* `make fmt-ui` - Formats the UI source code
|
||||
* `make ui` - Builds the frontend
|
||||
* `make build` - Builds the binary (make sure to build the UI as well... see below)
|
||||
* `make docker-build` - Locally builds and tags a complete 'stash/build' docker image
|
||||
* `make lint` - Run the linter on the backend
|
||||
* `make fmt` - Run `go fmt`
|
||||
* `make it` - Run the unit and integration tests
|
||||
* `make validate` - Run all of the tests and checks required to submit a PR
|
||||
* `make ui-start` - Runs the UI in development mode. Requires a running stash server to connect to. Stash port can be changed from the default of `9999` with environment variable `REACT_APP_PLATFORM_PORT`.
|
||||
* `make ui-start` - Runs the UI in development mode. Requires a running stash server to connect to. Stash server port can be changed from the default of `9999` using environment variable `VITE_APP_PLATFORM_PORT`. UI runs on port `3000` or the next available port.
|
||||
|
||||
## Building a release
|
||||
|
||||
|
||||
5
go.mod
5
go.mod
@@ -46,7 +46,10 @@ require (
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require github.com/vektah/gqlparser/v2 v2.0.1
|
||||
require (
|
||||
github.com/vearutop/statigz v1.1.5
|
||||
github.com/vektah/gqlparser/v2 v2.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.1.0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -79,6 +79,8 @@ github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xop
|
||||
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM=
|
||||
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/antchfx/htmlquery v1.2.3 h1:sP3NFDneHx2stfNXCKbhHFo8XgNjCACnU/4AO5gWz6M=
|
||||
github.com/antchfx/htmlquery v1.2.3/go.mod h1:B0ABL+F5irhhMWg54ymEZinzMSi0Kt3I2if0BLYa3V0=
|
||||
github.com/antchfx/xpath v1.1.6 h1:6sVh6hB5T6phw1pFpHRQ+C4bd8sNI+O58flqtg7h0R0=
|
||||
@@ -126,6 +128,8 @@ github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCS
|
||||
github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4=
|
||||
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/bool64/dev v0.1.41 h1:L554LCQZc3d7mtcdPUgDbSrCVbr48/30zgu0VuC/FTA=
|
||||
github.com/bool64/dev v0.1.41/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
|
||||
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||
github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
|
||||
@@ -706,6 +710,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
|
||||
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||
github.com/vearutop/statigz v1.1.5 h1:qWvRgXFsseWVTFCkIvwHQPpaLNf9WI0+dDJE7I9432o=
|
||||
github.com/vearutop/statigz v1.1.5/go.mod h1:czAv7iXgPv/s+xsgXpVEhhD0NSOQ4wZPgmM/n7LANDI=
|
||||
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
|
||||
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
||||
github.com/vektah/gqlparser v1.3.1 h1:8b0IcD3qZKWJQHSzynbDlrtP3IxVydZ2DZepCGofqfU=
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"github.com/vearutop/statigz"
|
||||
)
|
||||
|
||||
var version string
|
||||
@@ -213,11 +214,8 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
||||
if isStatic {
|
||||
w.Header().Add("Cache-Control", "max-age=604800000")
|
||||
}
|
||||
uiRoot, err := fs.Sub(uiBox, uiRootDir)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
http.FileServer(http.FS(uiRoot)).ServeHTTP(w, r)
|
||||
r.URL.Path = uiRootDir + r.URL.Path
|
||||
statigz.FileServer(uiBox).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"presets": ["react-app"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
const CracoEsbuildPlugin = require('craco-esbuild');
|
||||
module.exports = {
|
||||
plugins: [{ plugin: CracoEsbuildPlugin }],
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<base href="/">
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
@@ -13,16 +13,7 @@
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Stash</title>
|
||||
<script>window.STASH_BASE_URL = "%BASE_URL%"</script>
|
||||
</head>
|
||||
@@ -39,5 +30,6 @@
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,12 +5,10 @@
|
||||
"homepage": "./",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "craco build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"build-ci": "yarn validate && yarn build",
|
||||
"validate": "yarn lint && yarn format-check",
|
||||
"validate": "yarn lint && yarn format-check && tsc --noEmit",
|
||||
"lint": "yarn lint:css && yarn lint:js",
|
||||
"lint:js": "eslint --cache src/**/*.{ts,tsx}",
|
||||
"lint:css": "stylelint \"src/**/*.scss\"",
|
||||
@@ -49,7 +47,6 @@
|
||||
"hamming-distance": "^1.0.0",
|
||||
"i18n-iso-countries": "^6.4.0",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"jimp": "^0.16.1",
|
||||
"localforage": "1.9.0",
|
||||
"lodash": "^4.17.20",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -61,7 +58,7 @@
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "^5.10.16",
|
||||
"react-jw-player": "1.19.1",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-markdown": "^7.1.0",
|
||||
"react-router-bootstrap": "^0.25.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-hash-link": "^2.3.1",
|
||||
@@ -72,10 +69,12 @@
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"thehandy": "^0.2.7",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"vite": "^2.6.11",
|
||||
"vite-plugin-compression": "^0.3.5",
|
||||
"vite-tsconfig-paths": "^3.3.17",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^6.3.0",
|
||||
"@graphql-codegen/add": "^2.0.2",
|
||||
"@graphql-codegen/cli": "^1.20.0",
|
||||
"@graphql-codegen/time": "^2.0.2",
|
||||
@@ -96,7 +95,6 @@
|
||||
"@types/react-router-hash-link": "^1.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.33.0",
|
||||
"craco-esbuild": "^0.4.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-airbnb-typescript": "^14.0.1",
|
||||
@@ -108,7 +106,6 @@
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"postcss-safe-parser": "^5.0.2",
|
||||
"prettier": "2.2.1",
|
||||
"react-scripts": "^4.0.3",
|
||||
"stylelint": "^13.9.0",
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
"stylelint-order": "^4.1.0",
|
||||
|
||||
@@ -23,8 +23,8 @@ type Module = typeof V010;
|
||||
const Changelog: React.FC = () => {
|
||||
const [{ data, loading }, setOpenState] = useChangelogStorage();
|
||||
|
||||
const stashVersion = process.env.REACT_APP_STASH_VERSION;
|
||||
const buildTime = process.env.REACT_APP_DATE;
|
||||
const stashVersion = process.env.VITE_APP_STASH_VERSION;
|
||||
const buildTime = process.env.VITE_APP_DATE;
|
||||
|
||||
let buildDate;
|
||||
if (buildTime) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import { LoadingIndicator } from "src/components/Shared";
|
||||
import { useLatestVersion } from "src/core/StashService";
|
||||
|
||||
export const SettingsAboutPanel: React.FC = () => {
|
||||
const gitHash = process.env.REACT_APP_GITHASH;
|
||||
const stashVersion = process.env.REACT_APP_STASH_VERSION;
|
||||
const buildTime = process.env.REACT_APP_DATE;
|
||||
const gitHash = process.env.VITE_APP_GITHASH;
|
||||
const stashVersion = process.env.VITE_APP_STASH_VERSION;
|
||||
const buildTime = process.env.VITE_APP_DATE;
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export const MarkdownPage: React.FC<IPageProps> = ({ page }) => {
|
||||
}, [page, markdown]);
|
||||
|
||||
return (
|
||||
<ReactMarkdown className="markdown" source={markdown} plugins={[gfm]} />
|
||||
<ReactMarkdown className="markdown" plugins={[gfm]}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,9 +91,9 @@ export const getPlatformURL = (ws?: boolean) => {
|
||||
const platformUrl = new URL(window.location.origin + getBaseURL());
|
||||
|
||||
if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
|
||||
platformUrl.port = process.env.REACT_APP_PLATFORM_PORT ?? "9999";
|
||||
platformUrl.port = process.env.VITE_APP_PLATFORM_PORT ?? "9999";
|
||||
|
||||
if (process.env.REACT_APP_HTTPS === "true") {
|
||||
if (process.env.VITE_APP_HTTPS === "true") {
|
||||
platformUrl.protocol = "https:";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Jimp from "jimp";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
const readImage = (file: File, onLoadEnd: (imageData: string) => void) => {
|
||||
const reader: FileReader = new FileReader();
|
||||
@@ -35,17 +34,9 @@ const usePasteImage = (
|
||||
onLoadEnd: (imageData: string) => void,
|
||||
isActive: boolean = true
|
||||
) => {
|
||||
const [isEncoding, setIsEncoding] = useState(false);
|
||||
|
||||
const encodeImage = useCallback(
|
||||
(data: string) => {
|
||||
setIsEncoding(true);
|
||||
Jimp.read(data).then((image) =>
|
||||
image.quality(75).getBase64(Jimp.MIME_JPEG, (err, buffer) => {
|
||||
setIsEncoding(false);
|
||||
onLoadEnd(err ? "" : buffer);
|
||||
})
|
||||
);
|
||||
onLoadEnd(data);
|
||||
},
|
||||
[onLoadEnd]
|
||||
);
|
||||
@@ -59,7 +50,7 @@ const usePasteImage = (
|
||||
return () => document.removeEventListener("paste", paste);
|
||||
}, [isActive, encodeImage]);
|
||||
|
||||
return isEncoding;
|
||||
return false;
|
||||
};
|
||||
|
||||
const Image = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
|
||||
28
ui/v2.5/vite.config.js
Normal file
28
ui/v2.5/vite.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'build',
|
||||
},
|
||||
optimizeDeps: {
|
||||
entries: "src/index.tsx"
|
||||
},
|
||||
server: {
|
||||
cors: false
|
||||
},
|
||||
publicDir: 'public',
|
||||
assetsInclude: ['**/*.md'],
|
||||
plugins: [tsconfigPaths(), viteCompression({
|
||||
algorithm: 'gzip',
|
||||
disable: false,
|
||||
deleteOriginFile: true,
|
||||
filter: /\.(js|json|css|svg|md)$/i
|
||||
})],
|
||||
define: {
|
||||
'process.versions': {},
|
||||
'process.env': {}
|
||||
}
|
||||
})
|
||||
7161
ui/v2.5/yarn.lock
7161
ui/v2.5/yarn.lock
File diff suppressed because it is too large
Load Diff
21
vendor/github.com/vearutop/statigz/LICENSE
generated
vendored
Normal file
21
vendor/github.com/vearutop/statigz/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Viacheslav Poturaev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
135
vendor/github.com/vearutop/statigz/README.md
generated
vendored
Normal file
135
vendor/github.com/vearutop/statigz/README.md
generated
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
# statigz
|
||||
|
||||
[](https://github.com/vearutop/statigz/actions?query=branch%3Amaster+workflow%3Atest-unit)
|
||||
[](https://codecov.io/gh/vearutop/statigz)
|
||||
[](https://pkg.go.dev/github.com/vearutop/statigz)
|
||||
[](https://wakatime.com/badge/github/vearutop/statigz)
|
||||

|
||||

|
||||
|
||||
`statigz` serves pre-compressed embedded files with http in Go 1.16 and later.
|
||||
|
||||
## Why?
|
||||
|
||||
Since version 1.16 Go provides [standard way](https://tip.golang.org/pkg/embed/) to embed static assets. This API has
|
||||
advantages over previous solutions:
|
||||
|
||||
* assets are processed during build, so there is no need for manual generation step,
|
||||
* embedded data does not need to be kept in residential memory (as opposed to previous solutions that kept data in
|
||||
regular byte slices).
|
||||
|
||||
A common case for embedding is to serve static assets of a web application. In order to save bandwidth and improve
|
||||
latency, those assets are often served compressed. Compression concerns are out of `embed` responsibilities, yet they
|
||||
are quite important. Previous solutions (for example [`vfsgen`](https://github.com/shurcooL/vfsgen)
|
||||
with [`httpgzip`](https://github.com/shurcooL/httpgzip)) can optimize performance by storing compressed assets and
|
||||
serving them directly to capable user agents. This library implements such functionality for embedded file systems.
|
||||
|
||||
Read more in a [blog post](https://dev.to/vearutop/serving-compressed-static-assets-with-http-in-go-1-16-55bb).
|
||||
|
||||
> **_NOTE:_** Guarding new api (`embed`) with build tags is not a viable option, since it imposes
|
||||
> [issue](https://github.com/golang/go/issues/40067) in older versions of Go.
|
||||
|
||||
## Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/vearutop/statigz"
|
||||
"github.com/vearutop/statigz/brotli"
|
||||
)
|
||||
|
||||
// Declare your embedded assets.
|
||||
|
||||
//go:embed static/*
|
||||
var st embed.FS
|
||||
|
||||
func main() {
|
||||
// Plug static assets handler to your server or router.
|
||||
err := http.ListenAndServe(":80", statigz.FileServer(st, brotli.AddEncoding))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Behavior is based on [nginx gzip static module](http://nginx.org/en/docs/http/ngx_http_gzip_static_module.html) and
|
||||
[`github.com/lpar/gzipped`](https://github.com/lpar/gzipped).
|
||||
|
||||
Static assets have to be manually compressed with additional file extension, e.g. `bundle.js` would
|
||||
become `bundle.js.gz` (compressed with gzip) or `index.html` would become `index.html.br` (compressed with brotli).
|
||||
|
||||
> **_NOTE:_** [`zopfli`](https://github.com/google/zopfli) provides better compression than `gzip` while being
|
||||
> backwards compatible with it.
|
||||
|
||||
Upon request server checks if there is a compressed file matching `Accept-Encoding` and serves it directly.
|
||||
|
||||
If user agent does not support available compressed data, server uses an uncompressed file if it is available (
|
||||
e.g. `bundle.js`). If uncompressed file is not available, then server would decompress a compressed file into response.
|
||||
|
||||
Responses have `ETag` headers (64-bit FNV-1 hash of file contents) to enable caching. Responses that are not dynamically
|
||||
decompressed are served with [`http.ServeContent`](https://golang.org/pkg/net/http/#ServeContent) for ranges support.
|
||||
|
||||
### Brotli support
|
||||
|
||||
Support for `brotli` is optional. Using `brotli` adds about 260 KB to binary size, that's why it is moved to a separate
|
||||
package.
|
||||
|
||||
> **_NOTE:_** Although [`brotli`](https://github.com/google/brotli) has better compression than `gzip` and already
|
||||
> has wide support in browsers, it has limitations for non-https servers,
|
||||
> see [this](https://bugs.chromium.org/p/chromium/issues/detail?id=452335)
|
||||
> and [this](https://bugzilla.mozilla.org/show_bug.cgi?id=1218924).
|
||||
|
||||
### Runtime encoding
|
||||
|
||||
Recommended way of embedding assets is to compress assets before the build, so that binary includes `*.gz` or `*.br`
|
||||
files. This can be inconvenient in some cases, there is `EncodeOnInit` option to compress assets in runtime when
|
||||
creating file server. Once compressed, assets will be served directly without additional dynamic compression.
|
||||
|
||||
Files with extensions ".gz", ".br", ".gif", ".jpg", ".png", ".webp" are excluded from runtime encoding by default.
|
||||
|
||||
> **_NOTE:_** Compressing assets in runtime can degrade startup performance and increase memory usage to prepare and store compressed data.
|
||||
|
||||
### Mounting a subdirectory
|
||||
|
||||
It may be convenient to strip leading directory from an embedded file system, you can do that with `fs.Sub` and a type
|
||||
assertion.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/vearutop/statigz"
|
||||
"github.com/vearutop/statigz/brotli"
|
||||
)
|
||||
|
||||
// Declare your embedded assets.
|
||||
|
||||
//go:embed static/*
|
||||
var st embed.FS
|
||||
|
||||
func main() {
|
||||
// Retrieve sub directory.
|
||||
sub, err := fs.Sub(st, "static")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Plug static assets handler to your server or router.
|
||||
err = http.ListenAndServe(":80", statigz.FileServer(sub.(fs.ReadDirFS), brotli.AddEncoding))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
39
vendor/github.com/vearutop/statigz/brotli/encoding.go
generated
vendored
Normal file
39
vendor/github.com/vearutop/statigz/brotli/encoding.go
generated
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
// Package brotli provides encoding for statigz.Server.
|
||||
package brotli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/vearutop/statigz"
|
||||
)
|
||||
|
||||
// AddEncoding is an option that prepends brotli to encodings of statigz.Server.
|
||||
//
|
||||
// It is located in a separate package to allow better control of imports graph.
|
||||
func AddEncoding(server *statigz.Server) {
|
||||
enc := statigz.Encoding{
|
||||
FileExt: ".br",
|
||||
ContentEncoding: "br",
|
||||
Decoder: func(r io.Reader) (io.Reader, error) {
|
||||
return brotli.NewReader(r), nil
|
||||
},
|
||||
Encoder: func(r io.Reader) ([]byte, error) {
|
||||
res := bytes.NewBuffer(nil)
|
||||
w := brotli.NewWriterLevel(res, 8)
|
||||
|
||||
if _, err := io.Copy(w, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Bytes(), nil
|
||||
},
|
||||
}
|
||||
|
||||
server.Encodings = append([]statigz.Encoding{enc}, server.Encodings...)
|
||||
}
|
||||
27
vendor/github.com/vearutop/statigz/brotli/encoding_test.go
generated
vendored
Normal file
27
vendor/github.com/vearutop/statigz/brotli/encoding_test.go
generated
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
package brotli_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/vearutop/statigz"
|
||||
"github.com/vearutop/statigz/brotli"
|
||||
)
|
||||
|
||||
func TestAddEncoding(t *testing.T) {
|
||||
s := &statigz.Server{}
|
||||
s.Encodings = append(s.Encodings, statigz.GzipEncoding())
|
||||
brotli.AddEncoding(s)
|
||||
|
||||
assert.Equal(t, ".br", s.Encodings[0].FileExt)
|
||||
assert.Equal(t, ".gz", s.Encodings[1].FileExt)
|
||||
d, err := s.Encodings[0].Decoder(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, d)
|
||||
|
||||
e, err := s.Encodings[0].Encoder(strings.NewReader(strings.Repeat("A", 10000)))
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, e)
|
||||
assert.Less(t, len(e), 100)
|
||||
}
|
||||
9
vendor/github.com/vearutop/statigz/go.mod
generated
vendored
Normal file
9
vendor/github.com/vearutop/statigz/go.mod
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
module github.com/vearutop/statigz
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.3
|
||||
github.com/bool64/dev v0.1.41
|
||||
github.com/stretchr/testify v1.4.0
|
||||
)
|
||||
15
vendor/github.com/vearutop/statigz/go.sum
generated
vendored
Normal file
15
vendor/github.com/vearutop/statigz/go.sum
generated
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM=
|
||||
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/bool64/dev v0.1.41 h1:L554LCQZc3d7mtcdPUgDbSrCVbr48/30zgu0VuC/FTA=
|
||||
github.com/bool64/dev v0.1.41/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
437
vendor/github.com/vearutop/statigz/server.go
generated
vendored
Normal file
437
vendor/github.com/vearutop/statigz/server.go
generated
vendored
Normal file
@@ -0,0 +1,437 @@
|
||||
// Package statigz serves pre-compressed embedded files with http.
|
||||
package statigz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is a http.Handler that directly serves compressed files from file system to capable agents.
|
||||
//
|
||||
// Please use FileServer to create an instance of Server.
|
||||
//
|
||||
// If agent does not accept encoding and uncompressed file is not available in file system,
|
||||
// it would decompress the file before serving.
|
||||
//
|
||||
// Compressed files should have an additional extension to indicate their encoding,
|
||||
// for example "style.css.gz" or "bundle.js.br".
|
||||
//
|
||||
// Caching is implemented with ETag and If-None-Match headers. Range requests are supported
|
||||
// with help of http.ServeContent.
|
||||
//
|
||||
// Behavior is similar to http://nginx.org/en/docs/http/ngx_http_gzip_static_module.html and
|
||||
// https://github.com/lpar/gzipped, except compressed data can be decompressed for an incapable agent.
|
||||
type Server struct {
|
||||
// OnError controls error handling during Serve.
|
||||
OnError func(rw http.ResponseWriter, r *http.Request, err error)
|
||||
|
||||
// Encodings contains supported encodings, default GzipEncoding.
|
||||
Encodings []Encoding
|
||||
|
||||
// EncodeOnInit encodes files that does not have encoded version on Server init.
|
||||
// This allows embedding uncompressed files and still leverage one time compression
|
||||
// for multiple requests.
|
||||
// Enabling this option can degrade startup performance and memory usage in case
|
||||
// of large embeddings, use with caution.
|
||||
EncodeOnInit bool
|
||||
|
||||
info map[string]fileInfo
|
||||
fs fs.ReadDirFS
|
||||
}
|
||||
|
||||
const (
|
||||
// minSizeToEncode is minimal file size to apply encoding in runtime, 1KiB.
|
||||
minSizeToEncode = 1024
|
||||
|
||||
// minCompressionRatio is a minimal compression ratio to serve encoded data, 97%.
|
||||
minCompressionRatio = 0.97
|
||||
)
|
||||
|
||||
// SkipCompressionExt lists file extensions of data that is already compressed.
|
||||
var SkipCompressionExt = []string{".gz", ".br", ".gif", ".jpg", ".png", ".webp"}
|
||||
|
||||
// FileServer creates an instance of Server from file system.
|
||||
//
|
||||
// Typically file system would be an embed.FS.
|
||||
//
|
||||
// //go:embed *.png *.br
|
||||
// var FS embed.FS
|
||||
//
|
||||
// Brotli support is optionally available with brotli.AddEncoding.
|
||||
func FileServer(fs fs.ReadDirFS, options ...func(server *Server)) *Server {
|
||||
s := Server{
|
||||
fs: fs,
|
||||
info: make(map[string]fileInfo),
|
||||
OnError: func(rw http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
|
||||
},
|
||||
Encodings: []Encoding{GzipEncoding()},
|
||||
}
|
||||
|
||||
for _, o := range options {
|
||||
o(&s)
|
||||
}
|
||||
|
||||
// Reading from "." is not expected to fail.
|
||||
if err := s.hashDir("."); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if s.EncodeOnInit {
|
||||
err := s.encodeFiles()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
return &s
|
||||
}
|
||||
|
||||
func (s *Server) encodeFiles() error {
|
||||
for _, enc := range s.Encodings {
|
||||
if enc.Encoder == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for fn, i := range s.info {
|
||||
isEncoded := false
|
||||
|
||||
for _, ext := range SkipCompressionExt {
|
||||
if strings.HasSuffix(fn, ext) {
|
||||
isEncoded = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isEncoded {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, found := s.info[fn+enc.FileExt]; found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip encoding of small data.
|
||||
if i.size < minSizeToEncode {
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := s.fs.Open(fn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, err := enc.Encoder(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip encoding for non-compressible data.
|
||||
if float64(len(b))/float64(i.size) > minCompressionRatio {
|
||||
continue
|
||||
}
|
||||
|
||||
s.info[fn+enc.FileExt] = fileInfo{
|
||||
hash: i.hash + enc.FileExt,
|
||||
size: len(b),
|
||||
content: b[0:len(b):len(b)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) hashDir(p string) error {
|
||||
files, err := s.fs.ReadDir(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
fn := path.Join(p, f.Name())
|
||||
|
||||
if f.IsDir() {
|
||||
s.info[path.Clean(fn)] = fileInfo{
|
||||
isDir: true,
|
||||
}
|
||||
|
||||
if err = s.hashDir(fn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
h := fnv.New64()
|
||||
|
||||
f, err := s.fs.Open(fn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.info[path.Clean(fn)] = fileInfo{
|
||||
hash: strconv.FormatUint(h.Sum64(), 36),
|
||||
size: int(n),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) reader(fn string, info fileInfo) (io.Reader, error) {
|
||||
if info.content != nil {
|
||||
return bytes.NewReader(info.content), nil
|
||||
}
|
||||
|
||||
return s.fs.Open(fn)
|
||||
}
|
||||
|
||||
func (s *Server) serve(rw http.ResponseWriter, req *http.Request, fn, suf, enc string, info fileInfo,
|
||||
decompress func(r io.Reader) (io.Reader, error)) {
|
||||
if m := req.Header.Get("If-None-Match"); m == info.hash {
|
||||
rw.WriteHeader(http.StatusNotModified)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctype := mime.TypeByExtension(filepath.Ext(fn))
|
||||
if ctype == "" {
|
||||
ctype = "application/octet-stream" // Prevent unreliable Content-Type detection on compressed data.
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", ctype)
|
||||
rw.Header().Set("Etag", info.hash)
|
||||
|
||||
if enc != "" {
|
||||
rw.Header().Set("Content-Encoding", enc)
|
||||
}
|
||||
|
||||
if info.size > 0 {
|
||||
rw.Header().Set("Content-Length", strconv.Itoa(info.size))
|
||||
}
|
||||
|
||||
if req.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
|
||||
r, err := s.reader(fn+suf, info)
|
||||
if err != nil {
|
||||
s.OnError(rw, req, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if decompress != nil {
|
||||
r, err = decompress(r)
|
||||
if err != nil {
|
||||
rw.Header().Del("Etag")
|
||||
s.OnError(rw, req, err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if rs, ok := r.(io.ReadSeeker); ok {
|
||||
http.ServeContent(rw, req, fn, time.Time{}, rs)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(rw, r)
|
||||
if err != nil {
|
||||
s.OnError(rw, req, err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) minEnc(accessEncoding string, fn string) (fileInfo, Encoding) {
|
||||
var (
|
||||
minEnc Encoding
|
||||
minInfo = fileInfo{size: -1}
|
||||
)
|
||||
|
||||
for _, enc := range s.Encodings {
|
||||
if !strings.Contains(accessEncoding, enc.ContentEncoding) {
|
||||
continue
|
||||
}
|
||||
|
||||
info, found := s.info[fn+enc.FileExt]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
if minInfo.size == -1 || info.size < minInfo.size {
|
||||
minEnc = enc
|
||||
minInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
return minInfo, minEnc
|
||||
}
|
||||
|
||||
// ServeHTTP serves static files.
|
||||
//
|
||||
// For compatibility with std http.FileServer:
|
||||
// if request path ends with /index.html, it is redirected to base directory;
|
||||
// if request path points to a directory without trailing "/", it is redirected to a path with trailing "/".
|
||||
func (s *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
rw.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
|
||||
http.Error(rw, "Method Not Allowed\n\nmethod should be GET or HEAD", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(req.URL.Path, "/index.html") {
|
||||
localRedirect(rw, req, "./")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fn := strings.TrimPrefix(req.URL.Path, "/")
|
||||
ae := req.Header.Get("Accept-Encoding")
|
||||
|
||||
if s.info[fn].isDir {
|
||||
localRedirect(rw, req, path.Base(req.URL.Path)+"/")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if fn == "" || strings.HasSuffix(fn, "/") {
|
||||
fn += "index.html"
|
||||
}
|
||||
|
||||
// Always add Accept-Encoding to Vary to prevent intermediate caches corruption.
|
||||
rw.Header().Add("Vary", "Accept-Encoding")
|
||||
|
||||
if ae != "" {
|
||||
minInfo, minEnc := s.minEnc(strings.ToLower(ae), fn)
|
||||
|
||||
if minInfo.hash != "" {
|
||||
// Copy compressed data into response.
|
||||
s.serve(rw, req, fn, minEnc.FileExt, minEnc.ContentEncoding, minInfo, nil)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Copy uncompressed data into response.
|
||||
uncompressedInfo, uncompressedFound := s.info[fn]
|
||||
if uncompressedFound {
|
||||
s.serve(rw, req, fn, "", "", uncompressedInfo, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Decompress compressed data into response.
|
||||
for _, enc := range s.Encodings {
|
||||
info, found := s.info[fn+enc.FileExt]
|
||||
if !found || enc.Decoder == nil || info.isDir {
|
||||
continue
|
||||
}
|
||||
|
||||
info.hash += "U"
|
||||
info.size = 0
|
||||
s.serve(rw, req, fn, enc.FileExt, "", info, enc.Decoder)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(rw, req)
|
||||
}
|
||||
|
||||
// Encoding describes content encoding.
|
||||
type Encoding struct {
|
||||
// FileExt is an extension of file with compressed content, for example ".gz".
|
||||
FileExt string
|
||||
|
||||
// ContentEncoding is encoding name that is used in Accept-Encoding and Content-Encoding
|
||||
// headers, for example "gzip".
|
||||
ContentEncoding string
|
||||
|
||||
// Decoder is a function that can decode data for an agent that does not accept encoding,
|
||||
// can be nil to disable dynamic decompression.
|
||||
Decoder func(r io.Reader) (io.Reader, error)
|
||||
|
||||
// Encoder is a function that can encode data
|
||||
Encoder func(r io.Reader) ([]byte, error)
|
||||
}
|
||||
|
||||
type fileInfo struct {
|
||||
hash string
|
||||
size int
|
||||
content []byte
|
||||
isDir bool
|
||||
}
|
||||
|
||||
// OnError is an option to customize error handling in Server.
|
||||
func OnError(onErr func(rw http.ResponseWriter, r *http.Request, err error)) func(server *Server) {
|
||||
return func(server *Server) {
|
||||
server.OnError = onErr
|
||||
}
|
||||
}
|
||||
|
||||
// GzipEncoding provides gzip Encoding.
|
||||
func GzipEncoding() Encoding {
|
||||
return Encoding{
|
||||
FileExt: ".gz",
|
||||
ContentEncoding: "gzip",
|
||||
Decoder: func(r io.Reader) (io.Reader, error) {
|
||||
return gzip.NewReader(r)
|
||||
},
|
||||
Encoder: func(r io.Reader) ([]byte, error) {
|
||||
res := bytes.NewBuffer(nil)
|
||||
w := gzip.NewWriter(res)
|
||||
|
||||
if _, err := io.Copy(w, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Bytes(), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EncodeOnInit enables runtime encoding for unencoded files to allow compression
|
||||
// for uncompressed embedded files.
|
||||
//
|
||||
// Enabling this option can degrade startup performance and memory usage in case
|
||||
// of large embeddings, use with caution.
|
||||
func EncodeOnInit(server *Server) {
|
||||
server.EncodeOnInit = true
|
||||
}
|
||||
|
||||
// localRedirect gives a Moved Permanently response.
|
||||
// It does not convert relative paths to absolute paths like Redirect does.
|
||||
//
|
||||
// Copied go1.17/src/net/http/fs.go:685.
|
||||
func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
|
||||
if q := r.URL.RawQuery; q != "" {
|
||||
newPath += "?" + q
|
||||
}
|
||||
|
||||
w.Header().Set("Location", newPath)
|
||||
w.WriteHeader(http.StatusMovedPermanently)
|
||||
}
|
||||
352
vendor/github.com/vearutop/statigz/server_test.go
generated
vendored
Normal file
352
vendor/github.com/vearutop/statigz/server_test.go
generated
vendored
Normal file
@@ -0,0 +1,352 @@
|
||||
package statigz_test
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
brotli2 "github.com/andybalholm/brotli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/vearutop/statigz"
|
||||
"github.com/vearutop/statigz/brotli"
|
||||
)
|
||||
|
||||
//go:embed _testdata/*
|
||||
var v embed.FS
|
||||
|
||||
func TestServer_ServeHTTP_std(t *testing.T) {
|
||||
s := http.FileServer(http.FS(v))
|
||||
|
||||
for u, found := range map[string]bool{
|
||||
"/_testdata/favicon.png": true,
|
||||
"/_testdata/nonexistent": false,
|
||||
"/_testdata/swagger.json": true,
|
||||
"/_testdata/deeper/swagger.json": false,
|
||||
"/_testdata/deeper/openapi.json": false,
|
||||
"/_testdata/": true,
|
||||
"/_testdata/?abc": true,
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
if found {
|
||||
assert.Equal(t, "", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, http.StatusOK, rw.Code, u)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusNotFound, rw.Code, u)
|
||||
}
|
||||
}
|
||||
|
||||
for u, l := range map[string]string{
|
||||
"/_testdata/index.html": "./",
|
||||
"/_testdata": "_testdata/",
|
||||
"/_testdata?abc": "_testdata/?abc",
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusMovedPermanently, rw.Code, u)
|
||||
assert.Equal(t, l, rw.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_found(t *testing.T) {
|
||||
s := statigz.FileServer(v, brotli.AddEncoding, statigz.EncodeOnInit)
|
||||
|
||||
for u, found := range map[string]bool{
|
||||
"/_testdata/favicon.png": true,
|
||||
"/_testdata/nonexistent": false,
|
||||
"/_testdata/swagger.json": true,
|
||||
"/_testdata/deeper/swagger.json": true,
|
||||
"/_testdata/deeper/openapi.json": true,
|
||||
"/_testdata/": true,
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
if found {
|
||||
assert.Equal(t, "", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, http.StatusOK, rw.Code, u)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusNotFound, rw.Code, u)
|
||||
}
|
||||
}
|
||||
|
||||
for u, l := range map[string]string{
|
||||
"/_testdata/index.html": "./",
|
||||
"/_testdata": "_testdata/",
|
||||
"/_testdata?abc": "_testdata/?abc",
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusMovedPermanently, rw.Code, u)
|
||||
assert.Equal(t, l, rw.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_error(t *testing.T) {
|
||||
s := statigz.FileServer(v, brotli.AddEncoding)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, "/", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, rw.Code)
|
||||
assert.Equal(t, "Method Not Allowed\n\nmethod should be GET or HEAD\n", rw.Body.String())
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_acceptEncoding(t *testing.T) {
|
||||
s := statigz.FileServer(v, brotli.AddEncoding, statigz.EncodeOnInit)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/_testdata/deeper/swagger.json", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "br", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "3b88egjdndqox", rw.Header().Get("Etag"))
|
||||
assert.Len(t, rw.Body.Bytes(), 2548)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
rw = httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "3b88egjdndqoxU", rw.Header().Get("Etag"))
|
||||
assert.Len(t, rw.Body.Bytes(), 24919)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
req.Header.Set("If-None-Match", "3b88egjdndqox")
|
||||
|
||||
rw = httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotModified, rw.Code)
|
||||
assert.Equal(t, "", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "", rw.Header().Get("Etag"))
|
||||
assert.Len(t, rw.Body.Bytes(), 0)
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_badFile(t *testing.T) {
|
||||
s := statigz.FileServer(v, brotli.AddEncoding,
|
||||
statigz.OnError(func(rw http.ResponseWriter, r *http.Request, err error) {
|
||||
assert.EqualError(t, err, "gzip: invalid header")
|
||||
|
||||
_, err = rw.Write([]byte("failed"))
|
||||
assert.NoError(t, err)
|
||||
}))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/_testdata/bad.png", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "", rw.Header().Get("Etag"))
|
||||
assert.Equal(t, "failed", rw.Body.String())
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_head(t *testing.T) {
|
||||
s := statigz.FileServer(v, brotli.AddEncoding, statigz.EncodeOnInit)
|
||||
|
||||
req, err := http.NewRequest(http.MethodHead, "/_testdata/swagger.json", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "br", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "1bp69hxb9nd93.br", rw.Header().Get("Etag"))
|
||||
assert.Len(t, rw.Body.String(), 0)
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_head_gz(t *testing.T) {
|
||||
s := statigz.FileServer(v, statigz.EncodeOnInit)
|
||||
|
||||
req, err := http.NewRequest(http.MethodHead, "/_testdata/swagger.json", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "1bp69hxb9nd93.gz", rw.Header().Get("Etag"))
|
||||
assert.Len(t, rw.Body.String(), 0)
|
||||
}
|
||||
|
||||
func BenchmarkServer_ServeHTTP(b *testing.B) {
|
||||
s := statigz.FileServer(v, statigz.EncodeOnInit)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/_testdata/swagger.json", nil)
|
||||
require.NoError(b, err)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_get_gz(t *testing.T) {
|
||||
s := statigz.FileServer(v, statigz.EncodeOnInit)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/_testdata/swagger.json", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "1bp69hxb9nd93.gz", rw.Header().Get("Etag"))
|
||||
assert.Equal(t, "Accept-Encoding", rw.Header().Get("Vary"))
|
||||
assert.NotEmpty(t, rw.Body.String())
|
||||
|
||||
r, err := gzip.NewReader(rw.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decoded, err := io.ReadAll(r)
|
||||
assert.NoError(t, err)
|
||||
|
||||
raw, err := ioutil.ReadFile("_testdata/swagger.json")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, raw, decoded)
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_get_br(t *testing.T) {
|
||||
s := statigz.FileServer(v, statigz.EncodeOnInit, brotli.AddEncoding)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/_testdata/swagger.json", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "br", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "1bp69hxb9nd93.br", rw.Header().Get("Etag"))
|
||||
assert.NotEmpty(t, rw.Body.String())
|
||||
|
||||
r := brotli2.NewReader(rw.Body)
|
||||
|
||||
decoded, err := io.ReadAll(r)
|
||||
assert.NoError(t, err)
|
||||
|
||||
raw, err := ioutil.ReadFile("_testdata/swagger.json")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, raw, decoded)
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_indexCompressed(t *testing.T) {
|
||||
s := statigz.FileServer(v)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/_testdata/", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rw.Code)
|
||||
assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "45pls0g4wm91", rw.Header().Get("Etag"))
|
||||
assert.NotEmpty(t, rw.Body.String())
|
||||
|
||||
r, err := gzip.NewReader(rw.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decoded, err := io.ReadAll(r)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Hello!", string(decoded))
|
||||
}
|
||||
|
||||
func TestServer_ServeHTTP_sub(t *testing.T) {
|
||||
vs, err := fs.Sub(v, "_testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
s := statigz.FileServer(vs.(fs.ReadDirFS), brotli.AddEncoding, statigz.EncodeOnInit)
|
||||
|
||||
for u, found := range map[string]bool{
|
||||
"/favicon.png": true,
|
||||
"/nonexistent": false,
|
||||
"/swagger.json": true,
|
||||
"/deeper/swagger.json": true,
|
||||
"/deeper/openapi.json": true,
|
||||
"/": true,
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
if found {
|
||||
assert.Equal(t, "", rw.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, http.StatusOK, rw.Code, u)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusNotFound, rw.Code, u)
|
||||
}
|
||||
}
|
||||
|
||||
for u, l := range map[string]string{
|
||||
"/index.html": "./",
|
||||
"/deeper": "deeper/",
|
||||
"/deeper?abc": "deeper/?abc",
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
s.ServeHTTP(rw, req)
|
||||
|
||||
assert.Equal(t, http.StatusMovedPermanently, rw.Code, u)
|
||||
assert.Equal(t, l, rw.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
2
vendor/modules.txt
vendored
2
vendor/modules.txt
vendored
@@ -339,6 +339,8 @@ github.com/tidwall/pretty
|
||||
# github.com/urfave/cli/v2 v2.1.1
|
||||
## explicit; go 1.11
|
||||
github.com/urfave/cli/v2
|
||||
# github.com/vearutop/statigz v1.1.5
|
||||
## explicit; go 1.16
|
||||
# github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e
|
||||
## explicit
|
||||
github.com/vektah/dataloaden
|
||||
|
||||
Reference in New Issue
Block a user