├── .github └── workflows │ └── grain.yml ├── .gitignore ├── HIPPOFACTS ├── LICENSE.txt ├── Makefile ├── README.md ├── fileserver.gr ├── lib ├── env.gr ├── env.md ├── mediatype.gr ├── mediatype.md ├── stringutil.gr └── stringutil.md └── tests.gr /.github/workflows/grain.yml: -------------------------------------------------------------------------------- 1 | name: Grain 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | grainbuild: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: engineerd/configurator@v0.0.8 14 | with: 15 | name: "grain" 16 | url: "https://github.com/grain-lang/grain/releases/download/grain-v0.4.3/grain-linux-x64" 17 | - uses: engineerd/configurator@v0.0.8 18 | with: 19 | name: "wasmtime" 20 | url: "https://github.com/bytecodealliance/wasmtime/releases/download/v0.27.0/wasmtime-v0.27.0-x86_64-linux.tar.xz" 21 | pathInArchive: "wasmtime-v0.27.0-x86_64-linux/wasmtime" 22 | - uses: actions/checkout@v2 23 | - name: Testing 24 | run: | 25 | grain --version 26 | wasmtime --version 27 | make test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.wasm 2 | lib/*.wasm 3 | target/ 4 | _scratch/ -------------------------------------------------------------------------------- /HIPPOFACTS: -------------------------------------------------------------------------------- 1 | # Fact: Tawaret was the ancient Egyptian hippo goddess 2 | [bindle] 3 | name = "fileserver" 4 | version = "0.4.0" 5 | description = "Provides static file serving for Wagi" 6 | 7 | [[handler]] 8 | route = "/static/..." 9 | name = "fileserver.gr.wasm" 10 | files = ["README.md", "LICENSE.txt"] 11 | 12 | [[export]] 13 | id = "static" 14 | name = "fileserver.gr.wasm" 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 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 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # These are for both `run` (implicit) and `test` (explicit) 2 | PATH_INFO ?= /fileserver.gr 3 | X_MATCHED_ROUTE ?= /static/... 4 | BINDLE_SERVER_URL ?= http://localhost:8080/v1 5 | 6 | .PHONY: run 7 | run: 8 | PATH_INFO=${PATH_INFO} X_MATCHED_ROUTE=${X_MATCHED_ROUTE} \ 9 | grain fileserver.gr 10 | 11 | .PHONY: build 12 | build: 13 | grain compile fileserver.gr 14 | 15 | .PHONY: test-unit 16 | test-unit: 17 | grain tests.gr 18 | 19 | .PHONY: test 20 | test:build 21 | test: test-unit 22 | test: 23 | @echo EXPECT: Loading file fileserver.gr 24 | wasmtime --dir . --env PATH_INFO=${PATH_INFO} \ 25 | --env X_MATCHED_ROUTE=${X_MATCHED_ROUTE} \ 26 | fileserver.gr.wasm > /dev/null 27 | 28 | .PHONY: push 29 | push: 30 | hippofactory -s ${BINDLE_SERVER_URL} . 31 | 32 | doc: lib/*.gr 33 | grain doc lib/env.gr -o lib/env.md 34 | grain doc lib/mediatype.gr -o lib/mediatype.md 35 | grain doc lib/stringutil.gr -o lib/stringutil.md 36 | 37 | fmt: *.gr lib/*.gr 38 | grain format fileserver.gr --in-place 39 | grain format tests.gr --in-place 40 | grain format lib/env.gr --in-place 41 | grain format lib/mediatype.gr --in-place 42 | grain format lib/stringutil.gr --in-place 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fileserver-gr 2 | 3 | A [Wagi](https://github.com/deislabs/wagi) static fileserver written in Grain. 4 | 5 | > DeisLabs is experimenting with many WASM technologies right now. 6 | > This is one of a multitude of projects designed to test the limits 7 | > of WebAssembly as a cloud-based runtime. This code is not stable or 8 | > production ready. 9 | 10 | ## Getting a Binary 11 | 12 | We periodically release Wasm modules in binary form. To get the latest, go to the [releases page](https://github.com/deislabs/wagi-fileserver/releases). In the future, we will also publish bindles. 13 | 14 | ## Building and Running Locally 15 | 16 | You will need the [Grain](https://grain-lang.org) toolkit to work with this repo. 17 | 18 | To run: 19 | 20 | ```console 21 | $ make run 22 | ``` 23 | 24 | To compile: 25 | 26 | ```console 27 | $ make build 28 | ``` 29 | 30 | You can run unit tests with `make test-unit` or full tests with `make test`: 31 | 32 | ``` 33 | $ make test 34 | grain compile fileserver.gr 35 | grain tests.gr 36 | ✅ PASS Env.splitEnvVar should parse 37 | ✅ PASS Util.reverse should reverse string 38 | ✅ PASS Util.lastIndexOf should find Some 39 | ===== Expected: ===== 40 | Some(19) 41 | ======= Got: ======== 42 | Some(18) 43 | ===================== 44 | ⛔️ FAIL UtillastIndexOf should find Some 45 | ✅ PASS Util.lastIndexOf should find None 46 | ✅ PASS Mediatype.guess should find text/plain 47 | ✅ PASS Mediatype.guess should find default type 48 | ❌ Total failed tests: 1❌ 49 | make: *** [test-unit] Error 1 50 | ``` 51 | 52 | ## Running in Wagi 53 | 54 | You have two options for running in Wagi: 55 | 56 | 1. Use [hippofactory](https://github.com/deislabs/hippofactory) to build and push a bindle, then use `wagi -b $YOUR_BINDLE` 57 | 2. Write a `modules.toml` file and use `wagi -c modules.toml`. 58 | 59 | ### Using `hippofactory` 60 | 61 | Edit the `HIPPOFACTS` file to taste. 62 | 63 | To use `hippofactory`, you can just run this command from the repo root: 64 | 65 | ```console 66 | $ hippofactory -s http://localhost:8080/v1 . 67 | pushed: fileserver/0.1.0-technosophos-2021.06.03.17.25.54.484 68 | ``` 69 | 70 | Then run it in Wagi like this: 71 | 72 | ```console 73 | $ wagi -b fileserver/0.1.0-technosophos-2021.06.03.17.25.54.484 --bindle-server http://localhost:8080/v1 74 | [2021-06-03T23:26:54Z INFO wagi] => Starting server on 127.0.0.1:3000 75 | [2021-06-03T23:26:54Z DEBUG wagi::runtime::bindle] loaded 1 modules from the default group (parcels that do not have conditions.memberOf set) 76 | [2021-06-03T23:26:54Z DEBUG wagi::runtime] module cache miss. Loading module parcel:fileserver/0.1.0-technosophos-2021.06.03.17.25.54.484#110f6f54401b80d9d80dae9257969468a5a70248dba8d96ce74b9bc5bc104fdd from remote. 77 | [2021-06-03T23:26:54Z INFO wagi::runtime] (load_routes) instantiation time for module parcel:fileserver/0.1.0-technosophos-2021.06.03.17.25.54.484#110f6f54401b80d9d80dae9257969468a5a70248dba8d96ce74b9bc5bc104fdd: 107.802106ms 78 | ``` 79 | 80 | ### Using `modules.toml` 81 | 82 | Here is an example `modules.toml` for [Wagi](https://github.com/deislabs/wagi): 83 | 84 | ```toml 85 | [[module]] 86 | route = "/static/..." 87 | module = "/path/to/fileserver/fileserver.gr.wasm" 88 | volumes = {"/" = "/path/to/fileserver"} 89 | ``` 90 | 91 | The above configures Wagi to map the path `/static/...` to the `fileserver.gr.wasm` module. Then it serves all of the files in this project. 92 | 93 | ### Environment Variables 94 | 95 | The following environment variables can be passed via Wagi's `-e` flag: 96 | 97 | - `CACHE_CONTROL`: The string value of a cache-control header. If not specified, this will set cache-control to `no-cache`. Google recommends setting this value to `CACHE_CONTROL="max-age=31536000"` (cache for up to 1 year). 98 | - Type-specific cache controls can be set using the following env vars (all of which default to `CACHE_CONTROL` if not set): 99 | - `CSS_CACHE_CONTROL` 100 | - `FONT_CACHE_CONTROL` 101 | - `IMAGE_CACHE_CONTROL` 102 | - `JS_CACHE_CONTROL` 103 | 104 | 105 | ### Testing the Static Fileserver with `curl` 106 | 107 | This step is the same whether you use Bindle or a `modules.toml`. 108 | 109 | Assuming you have Wagi running on `http://localhost:3000`, you can then run this command: 110 | 111 | ```console 112 | $ curl -v localhost:3000/static/LICENSE.txt 113 | * Trying 127.0.0.1... 114 | * TCP_NODELAY set 115 | * Connected to localhost (127.0.0.1) port 3000 (#0) 116 | > GET /static/LICENSE.txt HTTP/1.1 117 | > Host: localhost:3000 118 | > User-Agent: curl/7.64.1 119 | > Accept: */* 120 | > 121 | < HTTP/1.1 200 OK 122 | < content-type: text/plain 123 | < content-length: 1104 124 | < date: Fri, 04 Jun 2021 00:16:14 GMT 125 | < 126 | The MIT License (MIT) 127 | 128 | Copyright (c) Microsoft Corporation. All rights reserved. 129 | 130 | Permission is hereby granted, free of charge, to any person obtaining a copy 131 | of this software and associated documentation files (the "Software"), to deal 132 | in the Software without restriction, including without limitation the rights 133 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 134 | copies of the Software, and to permit persons to whom the Software is 135 | furnished to do so, subject to the following conditions: 136 | 137 | The above copyright notice and this permission notice shall be included in all 138 | copies or substantial portions of the Software. 139 | 140 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 141 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 142 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 143 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 144 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 145 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 146 | SOFTWARE 147 | * Connection #0 to host localhost left intact 148 | * Closing connection 0 149 | ``` 150 | 151 | The fileserver took `/static/filserver.gr`, removed the `/static/` part from the front, and then loaded `fileserver.gr` from the directory mounted in the `modules.toml`. Note that any subdirectories are also served. So `/static/foo/bar` would translate to the path `foo/bar` inside of the WebAssembly module (which in the example above would fully resolve to "/path/to/fileserver/foo/bar"). 152 | 153 | ## Prefixing a Path 154 | 155 | `PATH_PREFIX` is an environment variable you can set. 156 | This allows you to add `-e PATH_PREFIX=/some/prefix` as an env var to `fileserver.gr.wasm`. 157 | 158 | This will allow the fileserver to set a specific path prefix for files before it looks them up. So instead of doing `http://example.com/static/static/foo.png`, you can set `wagi -e PATH_PREFIX=static/` and then `http://example.com/static/foo.png` will resolve on the filesystem, to `static/foo.png` instead of `foo.png`. 159 | 160 | ## Security Note 161 | 162 | The Wagi fileserver is designed to serve any file mounted in the volume. Do not mount a 163 | volume that contains files you do not want served. 164 | 165 | ## Code of Conduct 166 | 167 | This project has adopted the [Microsoft Open Source Code of 168 | Conduct](https://opensource.microsoft.com/codeofconduct/). 169 | 170 | For more information see the [Code of Conduct 171 | FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact 172 | [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional 173 | questions or comments. -------------------------------------------------------------------------------- /fileserver.gr: -------------------------------------------------------------------------------- 1 | // This is a simple Wagi static file server. 2 | 3 | import Env from "./lib/env" 4 | import Map from "map" 5 | import Option from "option" 6 | import File from "sys/file" 7 | import String from "string" 8 | import Result from "result" 9 | import Mediatype from "./lib/mediatype" 10 | import Stringutil from "./lib/stringutil" 11 | 12 | // Utility wrapper around a Result.expect that ignores the return value 13 | // so we don't need to worry about things returning non-Void types 14 | let validateResult = (msg, res) => { 15 | ignore(Result.expect(msg, res)) 16 | } 17 | 18 | let internalError = () => { 19 | validateResult( 20 | "Unexpected error when writing Internal Server Error response", 21 | File.fdWrite(File.stdout, "Status: 500\n\nInternal Server Error"), 22 | ) 23 | } 24 | 25 | let notFound = () => { 26 | validateResult( 27 | "Unexpected error when writing Not Found response", 28 | File.fdWrite(File.stdout, "Status: 404\n\nNot Found"), 29 | ) 30 | } 31 | 32 | // Pipe output to STDOUT 33 | let rec pipe = (in, out) => { 34 | let res = File.fdRead(in, 1024) 35 | match (res) { 36 | Err(err) => Err(err), 37 | Ok((d, len)) => { 38 | let res = File.fdWrite(out, d) 39 | if (len > 0) { 40 | pipe(in, out) 41 | } else { 42 | res 43 | } 44 | }, 45 | } 46 | } 47 | 48 | // Determine cache control values. 49 | let cache_duration = (env, mtype) => { 50 | let default_cache_control = match (Map.get("CACHE_CONTROL", env)) { 51 | Some(val) => val, 52 | None => "no-cache", 53 | } 54 | 55 | if (String.indexOf("image/", mtype) == Some(0)) { 56 | match (Map.get("IMAGE_CACHE_CONTROL", env)) { 57 | Some(val) => val, 58 | None => default_cache_control, 59 | } 60 | } else if (String.indexOf("font/", mtype) == Some(0)) { 61 | match (Map.get("FONT_CACHE_CONTROL", env)) { 62 | Some(val) => val, 63 | None => default_cache_control, 64 | } 65 | } else if (String.indexOf("text/css", mtype) == Some(0)) { 66 | match (Map.get("CSS_CACHE_CONTROL", env)) { 67 | Some(val) => val, 68 | None => default_cache_control, 69 | } 70 | } else if (String.indexOf("text/javascript", mtype) == Some(0)) { 71 | match (Map.get("JS_CACHE_CONTROL", env)) { 72 | Some(val) => val, 73 | None => default_cache_control, 74 | } 75 | } else { 76 | default_cache_control 77 | } 78 | } 79 | 80 | let headers = (env, path) => { 81 | let mtype = Mediatype.guess(path); 82 | "Content-Type: " ++ mtype ++ "\nCache-Control: " ++ cache_duration(env, mtype) ++ "\n\n" 83 | } 84 | 85 | let serve = (abs_path, env) => { 86 | // If PATH_PREFIX is set, then the path prefix is prepended onto the incoming path. 87 | // This allows you to map to a directory that does not match the directory name in the URL. 88 | let path = match (Map.get("PATH_PREFIX", env)) { 89 | Some(prefix) => { 90 | let tmp = String.slice(1, String.length(abs_path), abs_path) 91 | String.concat(prefix, tmp) 92 | }, 93 | // If no env var, trim off just the leading / 94 | None => String.slice(1, String.length(abs_path), abs_path) 95 | } 96 | // Explicitly ignoring any Ok or Err that happens on this log 97 | // The `ignore` can be removed if you don't want to be explicit about this behavior 98 | ignore(File.fdWrite(File.stderr, "Fileserver: Loading file " ++ path ++ "\n")) 99 | 100 | // Open file 101 | let result = File.pathOpen(File.pwdfd, [], path, [], [File.FdRead], [], []) 102 | 103 | match (result) { 104 | Err(_err) => notFound(), 105 | Ok(input) => { 106 | validateResult( 107 | "Unexpected error when writing Content-Type", 108 | File.fdWrite( 109 | File.stdout, 110 | headers(env, path), 111 | ), 112 | ) 113 | 114 | validateResult( 115 | "Unexpected error when streaming file body", 116 | pipe(input, File.stdout), 117 | ) 118 | // This validation may be able to be removed if it doesn't matter if the fdClose fails 119 | validateResult("Unexpected error when closing file", File.fdClose(input)) 120 | }, 121 | } 122 | } 123 | 124 | 125 | 126 | let guestpath = env => { 127 | // Backward compat for an older version of Wagi that had PATH_INFO wrong. 128 | // X_RELATIVE_PATH was removed before Wagi 0.4 129 | match (Map.get("X_RELATIVE_PATH", env)) { 130 | Some(p) => String.concat("/", p), 131 | None => { 132 | Option.unwrap(Map.get("PATH_INFO", env)) 133 | }, 134 | } 135 | } 136 | 137 | let kv = Env.envMap() 138 | let pathInfo = guestpath(kv) 139 | serve(pathInfo, kv) 140 | -------------------------------------------------------------------------------- /lib/env.gr: -------------------------------------------------------------------------------- 1 | import Process from "sys/process" 2 | import Map from "map" 3 | import Array from "array" 4 | import String from "string" 5 | import Option from "option" 6 | 7 | /** 8 | * Split an environment variable at the first equals sign. 9 | * 10 | * @param item: An environment variable pair, separated by an equals sign (=). 11 | * @returns A tuple key/value pair. 12 | */ 13 | export let splitEnvVar = item => { 14 | let offsetOpt = String.indexOf("=", item) 15 | 16 | // For now, fail if the env var is malformed. 17 | let offset = Option.unwrap(offsetOpt) 18 | 19 | let key = String.slice(0, offset, item) 20 | let val = String.slice(offset + 1, String.length(item), item) 21 | (key, val) 22 | } 23 | 24 | /** 25 | * Get the environment variables as a Map 26 | * 27 | * @returns A map of all environment variables. 28 | */ 29 | export let envMap = () => { 30 | let parsed = Map.make() 31 | let env = Process.env() 32 | match (env) { 33 | Err(err) => parsed, 34 | Ok(env) => { 35 | let pairs = Array.map(splitEnvVar, env) 36 | 37 | Array.forEach( 38 | item => { 39 | let (k, v) = item 40 | Map.set(k, v, parsed) 41 | }, 42 | pairs, 43 | ) 44 | parsed 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/env.md: -------------------------------------------------------------------------------- 1 | ### Env.**splitEnvVar** 2 | 3 | ```grain 4 | splitEnvVar : String -> (String, String) 5 | ``` 6 | 7 | Split an environment variable at the first equals sign. 8 | 9 | Parameters: 10 | 11 | |param|type|description| 12 | |-----|----|-----------| 13 | |`item`|`String`|An environment variable pair, separated by an equals sign (=).| 14 | 15 | Returns: 16 | 17 | |type|description| 18 | |----|-----------| 19 | |`(String, String)`|A tuple key/value pair.| 20 | 21 | ### Env.**envMap** 22 | 23 | ```grain 24 | envMap : () -> Map.Map 25 | ``` 26 | 27 | Get the environment variables as a Map 28 | 29 | Returns: 30 | 31 | |type|description| 32 | |----|-----------| 33 | |`Map.Map`|A map of all environment variables.| 34 | 35 | -------------------------------------------------------------------------------- /lib/mediatype.gr: -------------------------------------------------------------------------------- 1 | import String from "string" 2 | import Array from "array" 3 | import Option from "option" 4 | import Map from "map" 5 | import { lastIndexOf, reverse } from "./stringutil" 6 | 7 | export let default_mt = "application/octet-stream" 8 | 9 | // A giant map of all of the media types we know about. 10 | let mut mediatypes = Map.make() 11 | 12 | // Text formats 13 | Map.set("txt", "text/plain", mediatypes) 14 | Map.set("md", "text/plain", mediatypes) 15 | Map.set("mdown", "text/plain", mediatypes) 16 | Map.set("htm", "text/html", mediatypes) 17 | Map.set("html", "text/html", mediatypes) 18 | Map.set("xhtml", "application/xhtml+xml", mediatypes) 19 | Map.set("xml", "application/xml", mediatypes) 20 | Map.set("css", "text/css", mediatypes) 21 | Map.set("ics", "text/calendar", mediatypes) 22 | 23 | // Serialization formats 24 | Map.set("json", "application/json", mediatypes) 25 | Map.set("jsonld", "application/ld+json", mediatypes) 26 | Map.set("toml", "application/toml", mediatypes) 27 | Map.set("yaml", "application/yaml", mediatypes) 28 | 29 | // Applications 30 | // According to MSDN, prefered is text/javascript 31 | Map.set("js", "text/javascript", mediatypes) 32 | Map.set("mjs", "text/javascript", mediatypes) 33 | Map.set("wasm", "application/wasm", mediatypes) 34 | Map.set("csv", "text/csv", mediatypes) 35 | Map.set("sh", "application/x-sh", mediatypes) 36 | 37 | // Images 38 | Map.set("apng", "image/apng", mediatypes) 39 | Map.set("avif", "image/avif", mediatypes) 40 | Map.set("png", "image/png", mediatypes) 41 | Map.set("png", "image/png", mediatypes) 42 | Map.set("jpg", "image/jpeg", mediatypes) 43 | Map.set("jpeg", "image/jpeg", mediatypes) 44 | Map.set("pjpeg", "image/jpeg", mediatypes) 45 | Map.set("pjp", "image/jpeg", mediatypes) 46 | Map.set("jfif", "image/jpeg", mediatypes) 47 | Map.set("gif", "image/gif", mediatypes) 48 | Map.set("tif", "image/tiff", mediatypes) 49 | Map.set("tiff", "image/tiff", mediatypes) 50 | Map.set("webp", "image/webp", mediatypes) 51 | Map.set("svg", "image/svg+xml", mediatypes) 52 | Map.set("bmp", "image/bmp", mediatypes) 53 | Map.set("ico", "image/vnd.microsoft.icon", mediatypes) 54 | 55 | // Audio/Video 56 | Map.set("aac", "audio/aac", mediatypes) 57 | Map.set("avi", "video/x-msvideo", mediatypes) 58 | Map.set("wav", "audio/wave", mediatypes) 59 | Map.set("webm", "video/webm", mediatypes) 60 | Map.set("mp3", "audio/mpeg", mediatypes) 61 | Map.set("mp4", "video/mp4", mediatypes) 62 | Map.set("mpeg", "video/mpeg", mediatypes) 63 | Map.set("oga", "audio/ogg", mediatypes) 64 | Map.set("ogv", "video/ogg", mediatypes) 65 | Map.set("ogx", "application/ogg", mediatypes) 66 | Map.set("ts", "video/mp2t", mediatypes) 67 | 68 | // Compressed 69 | Map.set("bz2", "application/x-bzip2", mediatypes) 70 | Map.set("tbz", "application/x-bzip2", mediatypes) 71 | Map.set("tbz2", "application/x-bzip2", mediatypes) 72 | Map.set("gz", "application/gzip", mediatypes) 73 | Map.set("rar", "application/vnd.rar", mediatypes) 74 | Map.set("tar", "text/x-tar", mediatypes) 75 | Map.set("tgz", "application/gzip", mediatypes) 76 | Map.set("jar", "application/java-archive", mediatypes) 77 | Map.set("mpkg", "application/vnd.apple.installer+xml", mediatypes) 78 | Map.set("zip", "application/zip", mediatypes) 79 | Map.set("7z", "application/x-7z-compressed", mediatypes) 80 | 81 | // Binary 82 | Map.set("azw", "application/vnd.amazon.ebook", mediatypes) 83 | Map.set("bin", "application/octet-stream", mediatypes) 84 | Map.set("doc", "application/msword", mediatypes) 85 | Map.set( 86 | "docx", 87 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 88 | mediatypes, 89 | ) 90 | Map.set("epub", "application/epub+zip", mediatypes) 91 | Map.set("odp", "application/vnd.oasis.opendocument.presentation", mediatypes) 92 | Map.set("ods", "application/vnd.oasis.opendocument.spreadsheet", mediatypes) 93 | Map.set("odt", "application/vnd.oasis.opendocument.text", mediatypes) 94 | Map.set("pdf", "application/pdf", mediatypes) 95 | Map.set("ppt", "application/vnd.ms-powerpoint", mediatypes) 96 | Map.set( 97 | "pptx", 98 | "application/vnd.openxmlformats-officedocument.presentationml.presentation", 99 | mediatypes, 100 | ) 101 | Map.set("rtf", "application/rtf", mediatypes) 102 | Map.set("vsd", "application/vnd.visio", mediatypes) 103 | Map.set("xls", "application/vnd.ms-excel", mediatypes) 104 | Map.set( 105 | "xlsx", 106 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 107 | mediatypes, 108 | ) 109 | 110 | // Fonts 111 | Map.set("eot", "application/vnd.ms-fontobject", mediatypes) 112 | Map.set("otf", "font/otf", mediatypes) 113 | Map.set("ttf", "font/ttf", mediatypes) 114 | Map.set("woff", "font/woff", mediatypes) 115 | Map.set("woff2", "font/woff2", mediatypes) 116 | 117 | /** 118 | * Guess the media type of this file 119 | * 120 | * Per recommendation, if no media type is found for an extension, 121 | * this returns `application/octet-stream`. 122 | * 123 | * @param filename: The name of the file 124 | * @returns A media type 125 | */ 126 | export let guess = (filename: String) => { 127 | match (lastIndexOf(".", filename)) { 128 | Some(extOffset) => { 129 | let ext = String.slice(extOffset + 1, String.length(filename), filename) 130 | Option.unwrapWithDefault(default_mt, Map.get(ext, mediatypes)) 131 | }, 132 | None => default_mt, 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /lib/mediatype.md: -------------------------------------------------------------------------------- 1 | ### Mediatype.**default_mt** 2 | 3 | ```grain 4 | default_mt : String 5 | ``` 6 | 7 | ### Mediatype.**guess** 8 | 9 | ```grain 10 | guess : String -> String 11 | ``` 12 | 13 | Guess the media type of this file 14 | 15 | Per recommendation, if no media type is found for an extension, 16 | this returns `application/octet-stream`. 17 | 18 | Parameters: 19 | 20 | |param|type|description| 21 | |-----|----|-----------| 22 | |`filename`|`String`|The name of the file| 23 | 24 | Returns: 25 | 26 | |type|description| 27 | |----|-----------| 28 | |`String`|A media type| 29 | 30 | -------------------------------------------------------------------------------- /lib/stringutil.gr: -------------------------------------------------------------------------------- 1 | import String from "string" 2 | import Array from "array" 3 | 4 | /** 5 | * Return a String that is the reverse of the given String. 6 | * 7 | * @param str: The string to reverse. 8 | * @returns A reversed version of the given string 9 | */ 10 | export let reverse = (str: String) => { 11 | let chars = String.explode(str) 12 | let rev = Array.reverse(chars) 13 | String.implode(rev) 14 | } 15 | 16 | /** 17 | * Get the index of the last appearance of needle in the haystack. 18 | * 19 | * For a multi-character needle, this will return the end of that sequence, 20 | * not the beginning (as indexOf does). 21 | * 22 | * @param needle: The string to search for 23 | * @param haystack: The string to be searched 24 | * @returns The offset, if found, or a number 25 | */ 26 | export let lastIndexOf = (needle: String, haystack: String) => { 27 | let rev = reverse(haystack) 28 | let revNeedle = reverse(needle) 29 | let nlen = String.length(needle) 30 | let i = String.indexOf(revNeedle, rev) 31 | match (i) { 32 | Some(offset) => Some(String.length(haystack) - 1 - offset), 33 | None => None, 34 | } 35 | } 36 | 37 | export let afterLast = (needle: String, haystack: String) => { 38 | match (lastIndexOf(needle, haystack)) { 39 | Some(index) => String.slice(index + 1, String.length(haystack), haystack), 40 | None => haystack, 41 | } 42 | } 43 | 44 | export let beforeLast = (needle: String, haystack: String) => { 45 | let nlen = String.length(needle) 46 | match (lastIndexOf(needle, haystack)) { 47 | Some(index) => String.slice(0, index + 1 - nlen, haystack), 48 | None => haystack, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/stringutil.md: -------------------------------------------------------------------------------- 1 | ### Stringutil.**reverse** 2 | 3 | ```grain 4 | reverse : String -> String 5 | ``` 6 | 7 | Return a String that is the reverse of the given String. 8 | 9 | Parameters: 10 | 11 | |param|type|description| 12 | |-----|----|-----------| 13 | |`str`|`String`|The string to reverse.| 14 | 15 | Returns: 16 | 17 | |type|description| 18 | |----|-----------| 19 | |`String`|A reversed version of the given string| 20 | 21 | ### Stringutil.**lastIndexOf** 22 | 23 | ```grain 24 | lastIndexOf : (String, String) -> Option 25 | ``` 26 | 27 | Get the index of the last appearance of needle in the haystack. 28 | 29 | For a multi-character needle, this will return the end of that sequence, 30 | not the beginning (as indexOf does). 31 | 32 | Parameters: 33 | 34 | |param|type|description| 35 | |-----|----|-----------| 36 | |`needle`|`String`|The string to search for| 37 | |`haystack`|`String`|The string to be searched| 38 | 39 | Returns: 40 | 41 | |type|description| 42 | |----|-----------| 43 | |`Option`|The offset, if found, or a number| 44 | 45 | ### Stringutil.**afterLast** 46 | 47 | ```grain 48 | afterLast : (String, String) -> String 49 | ``` 50 | 51 | ### Stringutil.**beforeLast** 52 | 53 | ```grain 54 | beforeLast : (String, String) -> String 55 | ``` 56 | 57 | -------------------------------------------------------------------------------- /tests.gr: -------------------------------------------------------------------------------- 1 | import String from "string" 2 | import Process from "sys/process" 3 | import File from "sys/file" 4 | import Env from "./lib/env" 5 | import Util from "./lib/stringutil" 6 | import Mediatype from "./lib/mediatype" 7 | 8 | let mut totalErr = 0 9 | 10 | let check = (a, b, msg: String) => { 11 | match (a == b) { 12 | true => Ok(String.concat("✅ PASS\t\t", msg)), 13 | false => { 14 | totalErr += 1 15 | print("===== Expected: =====") 16 | print(a) 17 | print("======= Got: ========") 18 | print(b) 19 | print("=====================") 20 | Err(String.concat("⛔️ FAIL\t\t", msg)) 21 | }, 22 | } 23 | } 24 | 25 | let expect = (a, b, msg: String) => { 26 | match (check(a, b, msg)) { 27 | Ok(yay) => print(yay), 28 | Err(e) => print(e), 29 | } 30 | } 31 | 32 | let report = () => { 33 | if (totalErr > 0) { 34 | File.fdWrite(File.stderr, "❌ Total failed tests: ") 35 | File.fdWrite(File.stderr, toString(totalErr)) 36 | File.fdWrite(File.stderr, "❌\n") 37 | Process.exit(1) 38 | void 39 | } 40 | } 41 | 42 | expect(("a", "b"), Env.splitEnvVar("a=b"), "Env.splitEnvVar should parse") 43 | expect("gfedcba", Util.reverse("abcdefg"), "Util.reverse should reverse string") 44 | expect( 45 | Some(5), 46 | Util.lastIndexOf("/.", "aaaa/."), 47 | "Util.lastIndexOf should find Some", 48 | ) 49 | expect( 50 | Some(18), 51 | Util.lastIndexOf(".", "aaaa/fileserver.gr.wasm"), 52 | "Util.lastIndexOf should find last dot, not first dot", 53 | ) 54 | expect( 55 | Some(12), 56 | Util.lastIndexOf(".", "/.../aaaa/..."), 57 | "Util.lastIndexOf should find last set of three dots", 58 | ) 59 | expect( 60 | None, 61 | Util.lastIndexOf("??", "aaaa.."), 62 | "Util.lastIndexOf should find None", 63 | ) 64 | expect( 65 | "test", 66 | Util.afterLast("$.$", "foo$.$bar$.$test"), 67 | "Util.afterLast should find last match", 68 | ) 69 | expect( 70 | "/prefix/../path", 71 | Util.beforeLast("/..", "/prefix/../path/.."), 72 | "Util.beforeLast should return first part", 73 | ) 74 | expect( 75 | "/prefix/../path/..", 76 | Util.beforeLast("/$$", "/prefix/../path/.."), 77 | "Util.beforeLast should return entire string when no match", 78 | ) 79 | expect( 80 | "text/plain", 81 | Mediatype.guess("foo.txt"), 82 | "Mediatype.guess should find text/plain", 83 | ) 84 | expect( 85 | "application/octet-stream", 86 | Mediatype.guess("foo.MADEUP"), 87 | "Mediatype.guess should find default type", 88 | ) 89 | 90 | report() 91 | --------------------------------------------------------------------------------