├── .gitignore ├── LICENCE ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── Webber ├── Commands ├── BundleCommand.swift ├── NewCommand.swift ├── ReleaseCommand.swift ├── ServeCommand.swift └── VersionCommand.swift ├── Enums ├── AppType.swift └── BrowserType.swift ├── Extensions ├── Data+Bytes.swift ├── String+Random.swift ├── String+SwifWebLogo.swift └── URL+FileAttributes.swift ├── Helpers ├── DetectPlatformType.swift ├── WebberContext.swift └── WebberMiddleware.swift ├── Tools ├── Apt.swift ├── Arch.swift ├── Brew.swift ├── Extractor.swift ├── Installer.swift ├── IpConfig.swift ├── Npm.swift ├── OpenSSL.swift ├── Optimizer.swift ├── Server.swift ├── Toolchain.swift ├── ToolchainInstaller.swift ├── ToolchainRetriever.swift ├── WasmOpt.swift ├── Webber+Index.swift ├── Webber+JS.swift └── Webber.swift └── main.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .swiftpm 3 | .DS_Store 4 | *.xcodeproj 5 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 SwifWeb 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 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "async-http-client", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swift-server/async-http-client.git", 7 | "state" : { 8 | "revision" : "5bee16a79922e3efcb5cea06ecd27e6f8048b56b", 9 | "version" : "1.13.1" 10 | } 11 | }, 12 | { 13 | "identity" : "async-kit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/vapor/async-kit.git", 16 | "state" : { 17 | "revision" : "3be4b6418d1e8b835b0b1a1bee06b249faa4da5f", 18 | "version" : "1.14.0" 19 | } 20 | }, 21 | { 22 | "identity" : "console-kit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/swifweb/console-kit", 25 | "state" : { 26 | "branch" : "master", 27 | "revision" : "1ee91b326edcbc1d90e4d93784bc26564f2b139d" 28 | } 29 | }, 30 | { 31 | "identity" : "multipart-kit", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/vapor/multipart-kit.git", 34 | "state" : { 35 | "revision" : "0d55c35e788451ee27222783c7d363cb88092fab", 36 | "version" : "4.5.2" 37 | } 38 | }, 39 | { 40 | "identity" : "routing-kit", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/vapor/routing-kit.git", 43 | "state" : { 44 | "revision" : "ffac7b3a127ce1e85fb232f1a6271164628809ad", 45 | "version" : "4.6.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-algorithms", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-algorithms.git", 52 | "state" : { 53 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 54 | "version" : "1.0.0" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-atomics", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-atomics.git", 61 | "state" : { 62 | "revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe", 63 | "version" : "1.0.2" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-backtrace", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/swift-server/swift-backtrace.git", 70 | "state" : { 71 | "revision" : "f25620d5d05e2f1ba27154b40cafea2b67566956", 72 | "version" : "1.3.3" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-collections", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-collections.git", 79 | "state" : { 80 | "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", 81 | "version" : "1.0.3" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-crypto", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-crypto.git", 88 | "state" : { 89 | "revision" : "f652300628eb2003449e627f1f394a27ea2af9a8", 90 | "version" : "2.2.0" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-log", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/apple/swift-log.git", 97 | "state" : { 98 | "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c", 99 | "version" : "1.4.4" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-metrics", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/apple/swift-metrics.git", 106 | "state" : { 107 | "revision" : "53be78637ecd165d1ddedc4e20de69b8f43ec3b7", 108 | "version" : "2.3.2" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-nio", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/apple/swift-nio.git", 115 | "state" : { 116 | "revision" : "edfceecba13d68c1c993382806e72f7e96feaa86", 117 | "version" : "2.44.0" 118 | } 119 | }, 120 | { 121 | "identity" : "swift-nio-extras", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/apple/swift-nio-extras.git", 124 | "state" : { 125 | "revision" : "91dd2d61fb772e1311bb5f13b59266b579d77e42", 126 | "version" : "1.15.0" 127 | } 128 | }, 129 | { 130 | "identity" : "swift-nio-http2", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/apple/swift-nio-http2.git", 133 | "state" : { 134 | "revision" : "d6656967f33ed8b368b38e4b198631fc7c484a40", 135 | "version" : "1.23.1" 136 | } 137 | }, 138 | { 139 | "identity" : "swift-nio-ssl", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/apple/swift-nio-ssl.git", 142 | "state" : { 143 | "revision" : "4fb7ead803e38949eb1d6fabb849206a72c580f3", 144 | "version" : "2.23.0" 145 | } 146 | }, 147 | { 148 | "identity" : "swift-nio-transport-services", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 151 | "state" : { 152 | "revision" : "c0d9a144cfaec8d3d596aadde3039286a266c15c", 153 | "version" : "1.15.0" 154 | } 155 | }, 156 | { 157 | "identity" : "swift-numerics", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/apple/swift-numerics", 160 | "state" : { 161 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 162 | "version" : "1.0.2" 163 | } 164 | }, 165 | { 166 | "identity" : "vapor", 167 | "kind" : "remoteSourceControl", 168 | "location" : "https://github.com/vapor/vapor", 169 | "state" : { 170 | "revision" : "e227a63b72c33dcb8a28db3eda05709daa965b3b", 171 | "version" : "4.67.3" 172 | } 173 | }, 174 | { 175 | "identity" : "wasmtransformer", 176 | "kind" : "remoteSourceControl", 177 | "location" : "https://github.com/swiftwasm/WasmTransformer", 178 | "state" : { 179 | "revision" : "41548065647ba1b83ce8774a004763719458c2e6", 180 | "version" : "0.4.1" 181 | } 182 | }, 183 | { 184 | "identity" : "webber-tools", 185 | "kind" : "remoteSourceControl", 186 | "location" : "https://github.com/swifweb/webber-tools", 187 | "state" : { 188 | "revision" : "d342e526c7b924f5aef5d190e09a6370b098ea80", 189 | "version" : "1.4.3" 190 | } 191 | }, 192 | { 193 | "identity" : "websocket-kit", 194 | "kind" : "remoteSourceControl", 195 | "location" : "https://github.com/vapor/websocket-kit.git", 196 | "state" : { 197 | "revision" : "2d9d2188a08eef4a869d368daab21b3c08510991", 198 | "version" : "2.6.1" 199 | } 200 | } 201 | ], 202 | "version" : 2 203 | } 204 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | import PackageDescription 3 | import Foundation 4 | 5 | // MARK: - Conveniences 6 | 7 | let localDev = false 8 | let devDir = "../" 9 | 10 | struct Dep { 11 | let package: PackageDescription.Package.Dependency 12 | let targets: [Target.Dependency] 13 | 14 | init (_ what: What, _ targets: Target.Dependency...) { 15 | self.package = what.dependency 16 | self.targets = targets 17 | } 18 | } 19 | 20 | struct What { 21 | let dependency: Package.Dependency 22 | 23 | static func local(_ path: String) -> What { 24 | .init(dependency: .package(path: "\(devDir)\(path)")) 25 | } 26 | static func github(_ path: String, _ from: Version) -> What { 27 | .init(dependency: .package(url: "https://github.com/\(path)", from: from)) 28 | } 29 | static func github(_ path: String, exact: Version) -> What { 30 | .init(dependency: .package(url: "https://github.com/\(path)", exact: exact)) 31 | } 32 | static func github(_ path: String, branch: String) -> What { 33 | .init(dependency: .package(url: "https://github.com/\(path)", branch: branch)) 34 | } 35 | } 36 | 37 | extension Target.Dependency { 38 | static func product(_ name: String, _ package: String? = nil) -> Target.Dependency { 39 | .product(name: name, package: package ?? name) 40 | } 41 | } 42 | 43 | // MARK: - Dependencies 44 | 45 | var deps: [Dep] = [ 46 | .init(.github("vapor/vapor", "4.0.0"), .product("Vapor", "vapor")), 47 | .init(.github("swifweb/console-kit", branch: "master"), .product("ConsoleKit", "console-kit")), 48 | .init(.github("swiftwasm/WasmTransformer", "0.0.3"), .product("WasmTransformer", "WasmTransformer")) 49 | ] 50 | 51 | if localDev { 52 | deps.append(contentsOf: [ 53 | .init(.local("webber-tools"), .product("WebberTools", "webber-tools")) 54 | ]) 55 | } else { 56 | deps.append(contentsOf: [ 57 | .init(.github("swifweb/webber-tools", "1.4.2"), .product("WebberTools", "webber-tools")) 58 | ]) 59 | } 60 | 61 | // MARK: - Package 62 | 63 | let package = Package( 64 | name: "webber", 65 | platforms: [ 66 | .macOS(.v10_15) 67 | ], 68 | products: [ 69 | .executable(name: "Webber", targets: ["Webber"]) 70 | ], 71 | dependencies: deps.map { $0.package }, 72 | targets: [ 73 | .target(name: "Webber", dependencies: deps.flatMap { $0.targets }) 74 | ] 75 | ) 76 | 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | MIT License 4 | 5 | 6 | Swift 5.3 7 | 8 | 9 | Swift.Stream 10 | 11 |

12 | 13 | 14 | # Webber 15 | 16 | Powerful console tool for cooking your swifweb apps 17 | 18 | ## Requirements 19 | 20 | macOS 10.15 and Xcode 11.4 or later. 21 | 22 | or 23 | 24 | any ubuntu supported on [swift.org](https://swift.org/) 25 | 26 | ## Installation 27 | 28 | #### macOS 29 | 30 | On macOS `webber` can be installed with Homebrew. Make sure you have Homebrew installed and then run: 31 | 32 | ```bash 33 | brew install swifweb/tap/webber 34 | ``` 35 | 36 | to update already installed version run 37 | 38 | ```bash 39 | brew upgrade webber 40 | ``` 41 | 42 | #### Ubuntu 43 | 44 | 1. Install swift manually or via [swiftlang.xyz](https://www.swiftlang.xyz) 45 | 2. Install `binaryen` 46 | ```bash 47 | apt-get install binaryen 48 | ``` 49 | 3. Install `wasmer` 50 | ```bash 51 | curl https://get.wasmer.io -sSfL | sh 52 | ``` 53 | 4. Install `npm` 54 | ```bash 55 | apt-get install npm 56 | ``` 57 | 5. Install `webber` 58 | ```bash 59 | cd /opt 60 | git clone https://github.com/swifweb/webber 61 | cd /opt/webber 62 | swift build -c release 63 | ln -s /opt/webber/.build/release/Webber /usr/bin/webber 64 | exec bash 65 | ``` 66 | 6. Start using it inside of your project folder 67 | 68 | To update `webber` to latest version just do 69 | ```bash 70 | cd /opt/webber && git pull && swift build -c release 71 | ``` 72 | 73 | ## Usage 74 | 75 | If you already have a project then just go to its folder in console 76 | 77 | If you don't then manually `git clone` a template and then go to its directory 78 | 79 | ### New project 80 | 81 | You can either simply execute `webber new` or clone one of templates manually 82 | 83 | ```bash 84 | git clone https://github.com/swifweb/spa-template myspawebsite 85 | cd myspawebsite 86 | open Package.swift # to work with code 87 | webber serve # to launch in browser 88 | ``` 89 | 90 | progressive web app 91 | ```bash 92 | git clone https://github.com/swifweb/pwa-template mypwawebsite 93 | cd mypwawebsite 94 | open Package.swift # to work with code 95 | webber serve -t pwa -s Service # to launch in browser 96 | ``` 97 | 98 | ### Development 99 | 100 | if your project is `single page application` then this command will be enough to start working 101 | 102 | ```bash 103 | webber serve 104 | ``` 105 | 106 | This command do: 107 | - compile your project into webassembly file 108 | - cook needed html and js files, store them into `.webber` hidden folder inside project directory 109 | - spinup local webserver 110 | - open default browser to see your web app (it is on http/2 by default with self-signed cert, so add it into system) 111 | - watch for changes in the project directory, rebuild project automatically and update page in browser 112 | 113 | if you clone the `pwa` template then you should additionally provide the following arguments: 114 | - `-t pwa` to say `webber` that your project should be cooked as PWA 115 | - the name of your service worker target e.g. `-s Service` 116 | 117 | so in the end `serve` command for `pwa` template could look like this 118 | 119 | ```bash 120 | webber serve -t pwa -s Service -p 443 --browser chrome --browser-self-signed --browser-incognito 121 | ``` 122 | 123 | #### Additional parameters 124 | 125 | `-v` or `--verbose` to show more info in console for debugging purposes 126 | 127 | `-d` or `--debug-verbose` to show even more details about each step of webber execution 128 | 129 | `-p 443` or `--port 443` to start webber server on `443` port instead of default `8888` 130 | 131 | `--browser chrome/safari` to automatically open desired browser, by default it doesn't open any 132 | 133 | `--browser-self-signed` needed to debug service workers locally, otherwise they doesn't work 134 | 135 | `--browser-incognito` to open additional instance of browser in incognito mode, works only with chrome 136 | 137 | `--toolchain` to set custom toolchain e.g. `webber serve --toolchain 5.9-SNAPSHOT-2023-08-06-a` 138 | 139 | ### Release 140 | 141 | for SPA just run 142 | 143 | ```bash 144 | webber release 145 | ``` 146 | 147 | for PWA execute it this way 148 | 149 | ```bash 150 | webber release -t pwa -s Service 151 | ``` 152 | 153 | and then grub your files from `.webber/release/` 154 | 155 | ### How to serve release files with `nginx` 156 | 157 | 1. Install nginx by the [official instrucation](https://www.nginx.com/resources/wiki/start/topics/tutorials/install/) 158 | 2. Edit `/etc/nginx/mime.types` add `application/wasm wasm;` in order to serve `wasm` files correctly 159 | 3. Generate SSL certificate with letsencrypt (or anything else) 160 | 4. Declare your server like this 161 | ```ruby 162 | server { 163 | server_name yourdomain.com; 164 | 165 | listen [::]:443 ssl; 166 | listen 443 ssl; 167 | ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; 168 | ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; 169 | include /etc/letsencrypt/options-ssl-nginx.conf; 170 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 171 | 172 | ssl_session_cache shared:SSL:10m; 173 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 174 | add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;"; 175 | ssl_stapling on; 176 | ssl_stapling_verify on; 177 | 178 | root /app/yourdomain.com/.webber/release; 179 | 180 | location / { 181 | try_files $uri $uri/ /index.html; 182 | } 183 | 184 | location ~* \.(js|jpg|png|css|wasm)$ { 185 | root /app/yourdomain.com/.webber/release; 186 | expires 30d; 187 | add_header Cache-Control "public, no-transform"; 188 | } 189 | } 190 | ``` 191 | 192 | ## Credits 193 | 194 | Infinite thanks to the [swiftwasm](https://github.com/swiftwasm) organization for their 195 | - awesome swift fork adopted for webassembly 196 | - awesome [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit) which is used under the hood of the `web` package 197 | - awesome [carton](https://github.com/swiftwasm/carton) tool which was an inspiration for creating `webber` to cover all the needs 198 | -------------------------------------------------------------------------------- /Sources/Webber/Commands/BundleCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BundleCommand.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 21.02.2021. 6 | // 7 | 8 | import ConsoleKit 9 | import Vapor 10 | import NIOSSL 11 | import WasmTransformer 12 | import WebberTools 13 | 14 | class BundleCommand: Command { 15 | var server: Server! 16 | lazy var dir = DirectoryConfiguration.detect() 17 | var context: WebberContext! 18 | lazy var toolchain = Toolchain(context) 19 | var swift: Swift! 20 | var serviceWorkerTarget: String? 21 | var productTarget: String! 22 | var webber: Webber! 23 | var debug: Bool { false } 24 | var serve: Bool { false } 25 | var appType: AppType = .spa 26 | var products: [String] = [] 27 | 28 | enum CommandError: Error, CustomStringConvertible { 29 | case error(String) 30 | 31 | var description: String { 32 | switch self { 33 | case .error(let description): return description 34 | } 35 | } 36 | } 37 | 38 | struct Signature: CommandSignature { 39 | @Option( 40 | name: "toolchain", 41 | help: "Toolchain tag name from official swift-wasm/swift repo https://github.com/swiftwasm/swift/tags" 42 | ) 43 | var toolchain: String? 44 | 45 | @Option( 46 | name: "type", 47 | short: "t", 48 | help: "App type. It is `spa` by default. Could also be `pwa`.", 49 | completion: .values(AppType.all) 50 | ) 51 | var type: AppType? 52 | 53 | @Option( 54 | name: "service-worker-target", 55 | short: "s", 56 | help: "Name of service worker target." 57 | ) 58 | var serviceWorkerTarget: String? 59 | 60 | @Option( 61 | name: "app-target", 62 | short: "a", 63 | help: "Name of app target." 64 | ) 65 | var appTarget: String? 66 | 67 | @Option( 68 | name: "verbose", 69 | short: "v", 70 | help: "Prints more info in console." 71 | ) 72 | var verbose: Bool? 73 | 74 | @Option( 75 | name: "debug-verbose", 76 | short: "d", 77 | help: "Prints a lot of info in console." 78 | ) 79 | var debugVerbose: Bool? 80 | 81 | @Option( 82 | name: "ignore-swift-version", 83 | help: "Ignores .swift-version file." 84 | ) 85 | var ignoreCustomSwiftVersion: Bool? 86 | 87 | @Option( 88 | name: "port", 89 | short: "p", 90 | help: "Port for webber server. Default is 8888." 91 | ) 92 | var port: Int? 93 | 94 | @Option( 95 | name: "browser", 96 | help: "Destination browser name to automatically launch in.", 97 | completion: .values(BrowserType.all) 98 | ) 99 | var browserType: BrowserType? 100 | 101 | @Option( 102 | name: "browser-self-signed", 103 | help: "Opens additional instance of browser with allowed self-signed SSL setting to debug service-workers." 104 | ) 105 | var browserSelfSigned: Bool? 106 | 107 | @Option( 108 | name: "browser-incognito", 109 | help: "Opens additional instance of browser in incognito mode." 110 | ) 111 | var browserIncognito: Bool? 112 | 113 | init() {} 114 | } 115 | 116 | var help: String { "" } 117 | 118 | func run(using context: CommandContext, signature: Signature) throws { 119 | appType = signature.type ?? .spa 120 | serviceWorkerTarget = signature.serviceWorkerTarget 121 | context.console.output([ 122 | ConsoleTextFragment(string: String.swifWebASCIILogo, style: .init(color: .green, isBold: false)) 123 | ]) 124 | // context.console.output([ 125 | // ConsoleTextFragment(string: String.swifWebASCIILogo2, style: .init(color: .green, isBold: false)) 126 | // ]) 127 | // context.console.output([ 128 | // ConsoleTextFragment(string: String.swifWebASCIILogo3, style: .init(color: .green, isBold: false)) 129 | // ]) 130 | if appType == .pwa && serviceWorkerTarget == nil { 131 | throw CommandError.error("You have to provide service target name for PWA. Use: -s ServiceTargetName") 132 | } else if appType != .pwa && serviceWorkerTarget != nil { 133 | context.console.output([ 134 | ConsoleTextFragment(string: "You provided service target name but forgot to set app type to PWA.", style: .init(color: .magenta, isBold: true)), 135 | ConsoleTextFragment(string: " Use: -t pwa", style: .init(color: .yellow, isBold: true)) 136 | ]) 137 | } 138 | 139 | // Instantiate webber context 140 | self.context = WebberContext( 141 | customToolchain: signature.toolchain, 142 | dir: dir, 143 | command: context, 144 | verbose: signature.$verbose.isPresent, 145 | debugVerbose: signature.$debugVerbose.isPresent, 146 | ignoreCustomSwiftVersion: signature.$ignoreCustomSwiftVersion.isPresent, 147 | port: signature.port ?? 8888, 148 | browserType: signature.browserType, 149 | browserSelfSigned: signature.$browserSelfSigned.isPresent, 150 | browserIncognito: signature.$browserIncognito.isPresent, 151 | console: context.console 152 | ) 153 | 154 | self.context.debugVerbose("Instantiate swift started") 155 | // Instantiate swift 156 | swift = Swift(try toolchain.pathToSwift(), self.context.dir.workingDirectory) 157 | self.context.debugVerbose("Instantiate swift finished") 158 | 159 | // Printing swift version 160 | self.context.debugVerbose("Printing swift version started") 161 | context.console.output("\n\(try swift.version())") 162 | self.context.debugVerbose("Printing swift version finished") 163 | 164 | // Lookup product target 165 | self.context.debugVerbose("Lookup product target started") 166 | if let appTarget = signature.appTarget { 167 | self.context.debugVerbose("Lookup product target: looking for: \(appTarget)") 168 | productTarget = signature.appTarget 169 | try swift.checkIfAppProductPresent(appTarget) 170 | } else { 171 | self.context.debugVerbose("Lookup product target: trying to lookup executable name") 172 | productTarget = try swift.lookupExecutableName(excluding: serviceWorkerTarget) 173 | } 174 | self.context.debugVerbose("Lookup product target finished") 175 | 176 | // Check for service worker target 177 | self.context.debugVerbose("Check for service worker target started") 178 | if appType == .pwa { 179 | if let sw = serviceWorkerTarget { 180 | try swift.checkIfServiceWorkerProductPresent(sw) 181 | } 182 | } 183 | self.context.debugVerbose("Check for service worker target finished") 184 | 185 | // Fill products array 186 | self.context.debugVerbose("Fill products array started") 187 | products.append(productTarget) 188 | if let product = serviceWorkerTarget { 189 | products.append(product) 190 | } 191 | self.context.debugVerbose("Fill products array finished") 192 | 193 | // Instantiate webber 194 | self.context.debugVerbose("Instantiate webber object started") 195 | webber = try Webber(self.context) 196 | self.context.debugVerbose("Instantiate webber object finished") 197 | 198 | self.context.debugVerbose("Execute method started") 199 | try execute() 200 | self.context.debugVerbose("Execute method finished") 201 | 202 | if serve { 203 | self.context.debugVerbose("Watching for file changes") 204 | try watchForFileChanges() 205 | self.context.debugVerbose("Spinning up the local server") 206 | try spinup() 207 | } 208 | } 209 | 210 | func execute() throws { 211 | self.context.debugVerbose("Execute method: iterating products started (products.count: \(products.count)") 212 | try products.forEach { product in 213 | try build(product, alsoNative: serviceWorkerTarget == product) 214 | } 215 | self.context.debugVerbose("Execute method: iterating products finished") 216 | 217 | self.context.debugVerbose("Execute method: dependencies installation started") 218 | try webber.installDependencies() 219 | self.context.debugVerbose("Execute method: dependencies installation finished") 220 | 221 | if !debug { 222 | self.context.debugVerbose("Execute method: optimization started") 223 | try products.forEach { product in 224 | try optimize(product) 225 | } 226 | self.context.debugVerbose("Execute method: optimization finished") 227 | } 228 | 229 | self.context.debugVerbose("Execute method: cooking web files started") 230 | try cook() 231 | self.context.debugVerbose("Execute method: cooking web files finished") 232 | self.context.debugVerbose("Execute method: moving wasm files started") 233 | try moveWasmFiles() 234 | self.context.debugVerbose("Execute method: moving wasm files finished") 235 | self.context.debugVerbose("Execute method: moving resources started") 236 | try webber.moveResources(dev: debug) 237 | self.context.debugVerbose("Execute method: moving resources finished") 238 | } 239 | 240 | /// Build swift into wasm (sync) 241 | private func build(_ targetName: String, alsoNative: Bool = false) throws { 242 | context.command.console.output([ 243 | ConsoleTextFragment(string: "Started building product ", style: .init(color: .brightGreen, isBold: true)), 244 | ConsoleTextFragment(string: targetName, style: .init(color: .brightYellow)) 245 | ]) 246 | 247 | let buildingStartedAt = Date() 248 | // let buildingBar = context.command.console.loadingBar(title: "Building") 249 | // buildingBar.start() 250 | try swiftBuild(targetName, release: !debug, tripleWasm: true) 251 | if alsoNative { 252 | // building non-wasi executable (usually for service worker to grab manifest json) 253 | // should be built in debug cause of compile error in JavaScriptKit in release mode 254 | // buildingBar.activity.title = "Grabbing info" 255 | try swiftBuild(targetName, release: false, tripleWasm: false) 256 | } 257 | // buildingBar.succeed() 258 | 259 | context.command.console.clear(.line) 260 | context.command.console.output([ 261 | ConsoleTextFragment(string: "Finished building ", style: .init(color: .brightGreen, isBold: true)), 262 | ConsoleTextFragment(string: targetName, style: .init(color: .brightYellow)), 263 | ConsoleTextFragment(string: " in ", style: .init(color: .brightGreen, isBold: true)), 264 | ConsoleTextFragment(string: String(format: "%.2fs", Date().timeIntervalSince(buildingStartedAt)), style: .init(color: .brightMagenta)) 265 | ]) 266 | } 267 | 268 | private func _printBuildErrors(_ compilationProblem: Swift.SwiftError) throws { 269 | switch compilationProblem { 270 | case .errors(let errors): 271 | context.command.console.output(" ") 272 | for error in errors { 273 | context.command.console.output([ 274 | ConsoleTextFragment(string: " " + error.file.lastPathComponent + " ", style: .init(color: .green, background: .custom(r: 68, g: 68, b: 68))) 275 | ] + " " + [ 276 | ConsoleTextFragment(string: error.file.path, style: .init(color: .custom(r: 168, g: 168, b: 168))) 277 | ]) 278 | context.command.console.output(" ") 279 | for place in error.places { 280 | let lineNumberString = "\(place.line) |" 281 | let errorTitle = " ERROR " 282 | let errorTitlePrefix = " " 283 | context.command.console.output([ 284 | ConsoleTextFragment(string: errorTitlePrefix, style: .init(color: .none)), 285 | ConsoleTextFragment(string: errorTitle, style: .init(color: .brightWhite, background: .red, isBold: true)) 286 | ] + " " + [ 287 | ConsoleTextFragment(string: place.reason, style: .init(color: .none)) 288 | ]) 289 | let _len = (errorTitle.count + 5) - lineNumberString.count 290 | let errorLinePrefix = _len > 0 ? (0..._len).map { _ in " " }.joined(separator: "") : "" 291 | context.command.console.output([ 292 | ConsoleTextFragment(string: errorLinePrefix + lineNumberString, style: .init(color: .brightCyan)) 293 | ] + " " + [ 294 | ConsoleTextFragment(string: place.code, style: .init(color: .none)) 295 | ]) 296 | let linePointerBeginning = (0...lineNumberString.count - 2).map { _ in " " }.joined(separator: "") + "|" 297 | context.command.console.output([ 298 | ConsoleTextFragment(string: errorLinePrefix + linePointerBeginning, style: .init(color: .brightCyan)) 299 | ] + " " + [ 300 | ConsoleTextFragment(string: place.pointer, style: .init(color: .brightRed)) 301 | ]) 302 | context.command.console.output(" ") 303 | } 304 | } 305 | case .raw(let raw): 306 | context.command.console.output([ 307 | ConsoleTextFragment(string: "Compilation failed\n", style: .init(color: .brightMagenta)), 308 | ConsoleTextFragment(string: raw, style: .init(color: .brightRed)) 309 | ]) 310 | throw Swift.SwiftError.text("Unable to continue cause of failed compilation 🥺\n") 311 | default: 312 | throw compilationProblem 313 | } 314 | } 315 | 316 | private func swiftBuild(_ productName: String, release: Bool = false, tripleWasm: Bool) throws { 317 | if webber.context.verbose { 318 | context.command.console.output([ 319 | ConsoleTextFragment(string: swift.launchPath, style: .init(color: .brightBlue, isBold: true)), 320 | ConsoleTextFragment(string: " " + Swift.Command.build(release: release, productName: productName).arguments(tripleWasm: tripleWasm).joined(separator: " ") + "\n", style: .init(color: .brightMagenta)) 321 | ]) 322 | } 323 | do { 324 | try swift.build(productName, release: release, tripleWasm: tripleWasm) 325 | } catch let error as Swift.SwiftError { 326 | try _printBuildErrors(error) 327 | throw error 328 | } catch { 329 | throw error 330 | } 331 | } 332 | 333 | private func optimize(_ targetName: String) throws { 334 | // Optimization for old Safari 335 | try Optimizer.optimizeForOldSafari(debug: debug, targetName, context: context) 336 | // Stripping debug info 337 | try Optimizer.stripDebugInfo(targetName, context: context) 338 | // Optimize using `wasm-opt` 339 | try WasmOpt.optimize(targetName, context: context) 340 | } 341 | 342 | /// Cook web files 343 | private func cook() throws { 344 | try webber.cook( 345 | dev: debug, 346 | appTarget: productTarget, 347 | serviceWorkerTarget: serviceWorkerTarget ?? "sw", 348 | type: appType 349 | ) 350 | } 351 | 352 | /// Moves wasm files into public dev/release folder 353 | private func moveWasmFiles() throws { 354 | try products.forEach { product in 355 | try webber.moveWasmFile(dev: debug, productName: product) 356 | } 357 | } 358 | 359 | lazy var rebuildQueue = DispatchQueue(label: "webber.rebuilder") 360 | private var lastRebuildRequestedAt: Date? 361 | 362 | func watchForFileChanges() throws { 363 | var isRebuilding = false 364 | var needOneMoreRebuilding = false 365 | func rebuildWasm() { 366 | guard !isRebuilding else { 367 | if let lastRebuildRequestedAt = lastRebuildRequestedAt { 368 | if Date().timeIntervalSince(lastRebuildRequestedAt) < 2 { 369 | return 370 | } 371 | } 372 | needOneMoreRebuilding = true 373 | return 374 | } 375 | lastRebuildRequestedAt = Date() 376 | isRebuilding = true 377 | let buildingStartedAt = Date() 378 | context.command.console.output([ 379 | ConsoleTextFragment(string: "Rebuilding, please wait...", style: .init(color: .brightYellow)) 380 | ]) 381 | 382 | let finishRebuilding = { 383 | isRebuilding = false 384 | if needOneMoreRebuilding { 385 | needOneMoreRebuilding = false 386 | rebuildWasm() 387 | } 388 | } 389 | let handleError: (Error) -> Void = { error in 390 | self.context.command.console.clear(.line) 391 | self.context.command.console.output([ 392 | ConsoleTextFragment(string: "Rebuilding error: \(error)", style: .init(color: .brightRed)) 393 | ]) 394 | finishRebuilding() 395 | } 396 | var productsToRebuild: [String] = [] 397 | productsToRebuild.append(contentsOf: products) 398 | func rebuild() { 399 | guard productsToRebuild.count > 0 else { 400 | try? self.webber.recookManifestWithIndex( 401 | dev: self.debug, 402 | appTarget: self.productTarget, 403 | serviceWorkerTarget: self.serviceWorkerTarget ?? "sw", 404 | type: self.appType 405 | ) 406 | self.context.command.console.clear(.line) 407 | do { 408 | try self.moveWasmFiles() 409 | try self.webber.moveResources(dev: debug) 410 | } catch { 411 | handleError(error) 412 | } 413 | // notify ws clients 414 | self.server.notify(.wasmRecompiled) 415 | let df = DateFormatter() 416 | df.dateFormat = "hh:mm:ss" 417 | // notify console 418 | self.context.command.console.output([ 419 | ConsoleTextFragment(string: "[\(df.string(from: Date()))] Rebuilt in ", style: .init(color: .brightGreen, isBold: true)), 420 | ConsoleTextFragment(string: String(format: "%.2fs", Date().timeIntervalSince(buildingStartedAt)), style: .init(color: .brightMagenta)) 421 | ]) 422 | finishRebuilding() 423 | return 424 | } 425 | let product = productsToRebuild.removeFirst() 426 | do { 427 | try swift.build(product) 428 | if self.serviceWorkerTarget == product { 429 | try self.swift.build(product, tripleWasm: false) 430 | DispatchQueue.global(qos: .userInteractive).async { 431 | rebuild() 432 | } 433 | } else { 434 | DispatchQueue.global(qos: .userInteractive).async { 435 | rebuild() 436 | } 437 | } 438 | } catch let error as Swift.SwiftError { 439 | try? _printBuildErrors(error) 440 | handleError(error) 441 | } catch { 442 | handleError(error) 443 | } 444 | } 445 | DispatchQueue.global(qos: .userInteractive).async { 446 | rebuild() 447 | } 448 | } 449 | 450 | // Recooking 451 | var isRecooking = false 452 | var needOneMoreRecook = false 453 | func recookEntrypoint() { 454 | guard !isRecooking else { 455 | // needOneMoreRecook = true 456 | return 457 | } 458 | isRecooking = true 459 | DispatchQueue.global(qos: .userInteractive).async { 460 | do { 461 | // cook web files 462 | try self.cook() 463 | // notify ws clients 464 | self.server.notify(.entrypointRecooked) 465 | } catch { 466 | self.context.command.console.output([ 467 | ConsoleTextFragment(string: "Recooking error: \(error)", style: .init(color: .brightRed)) 468 | ]) 469 | } 470 | isRecooking = false 471 | if needOneMoreRecook { 472 | needOneMoreRecook = false 473 | recookEntrypoint() 474 | } 475 | } 476 | } 477 | FS.watch(URL(fileURLWithPath: dir.workingDirectory).appendingPathComponent("Package.swift")) { url in 478 | try? self.swift.lookupLocalDependencies().forEach { 479 | guard !FS.contains(path: $0) else { return } 480 | FS.watch($0) { url in 481 | self.rebuildQueue.sync { 482 | rebuildWasm() 483 | } 484 | } 485 | } 486 | self.rebuildQueue.sync { 487 | rebuildWasm() 488 | } 489 | } 490 | FS.watch(URL(fileURLWithPath: dir.workingDirectory).appendingPathComponent(".swift-version")) { url in 491 | self.rebuildQueue.sync { 492 | rebuildWasm() 493 | } 494 | } 495 | FS.watch(URL(fileURLWithPath: dir.workingDirectory).appendingPathComponent("Sources")) { url in 496 | self.rebuildQueue.sync { 497 | rebuildWasm() 498 | } 499 | } 500 | try swift.lookupLocalDependencies().forEach { 501 | FS.watch($0) { url in 502 | self.rebuildQueue.sync { 503 | rebuildWasm() 504 | } 505 | } 506 | } 507 | FS.watch(webber.entrypoint) { url in 508 | self.rebuildQueue.sync { 509 | recookEntrypoint() 510 | } 511 | } 512 | } 513 | 514 | private var isSpinnedUp = false 515 | 516 | /// Spin up Vapor server 517 | func spinup() throws { 518 | guard !isSpinnedUp else { return } 519 | isSpinnedUp = true 520 | server = Server(webber) 521 | try server.spinup() 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /Sources/Webber/Commands/NewCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewCommand.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 04.03.2023. 6 | // 7 | 8 | import Foundation 9 | import ConsoleKit 10 | import Vapor 11 | 12 | class NewCommand: Command { 13 | lazy var dir = DirectoryConfiguration.detect() 14 | 15 | enum CommandError: Error, CustomStringConvertible { 16 | case error(String) 17 | 18 | var description: String { 19 | switch self { 20 | case .error(let description): return description 21 | } 22 | } 23 | } 24 | 25 | struct Signature: CommandSignature { 26 | @Option( 27 | name: "type", 28 | short: "t", 29 | help: "App type. It is `spa` by default. Could also be `pwa`.", 30 | completion: .values(AppType.all) 31 | ) 32 | var type: AppType? 33 | 34 | init() {} 35 | } 36 | 37 | var help: String { "Creates the new project" } 38 | 39 | func run(using context: CommandContext, signature: Signature) throws { 40 | let type: AppType 41 | if let t = signature.type { 42 | type = t 43 | } else { 44 | type = context.console.choose("Choose \("type", color: .brightYellow) of the app", from: [AppType.pwa, .spa]) 45 | } 46 | let name = context.console.ask("Enter \("name", color: .brightYellow) of the app").firstCapitalized 47 | let newFolderPath = dir.workingDirectory.appending("/\(name)") 48 | 49 | let filesCountInFolder = (try? FileManager.default.contentsOfDirectory(atPath: newFolderPath).count) ?? 0 50 | guard filesCountInFolder == 0 else { 51 | throw CommandError.error("Unfortunately \"\(name)\" folder already exists and it is not empty") 52 | } 53 | try FileManager.default.createDirectory(atPath: newFolderPath, withIntermediateDirectories: false) 54 | // Root files 55 | FileManager.default.createFile(atPath: newFolderPath.appending("/Package.xpreview"), contents: nil) 56 | FileManager.default.createFile(atPath: newFolderPath.appending("/.gitignore"), contents: """ 57 | .DS_Store 58 | /.build 59 | /Packages 60 | /*.xcodeproj 61 | xcuserdata/ 62 | /.webber/dev 63 | /.webber/release 64 | /.webber/entrypoint/dev/.ssl 65 | /.webber/node_modules 66 | /.webber/package* 67 | /.swiftpm 68 | """.data(using: .utf8)) 69 | FileManager.default.createFile(atPath: newFolderPath.appending("/Package.swift"), contents: """ 70 | // swift-tools-version:5.10 71 | import PackageDescription 72 | 73 | let package = Package( 74 | name: "\(name)", 75 | platforms: [ 76 | .macOS(.v10_15) 77 | ], 78 | products: [ 79 | .executable(name: "App", targets: ["App"]),\(type == .pwa ? """ 80 | 81 | .executable(name: "Service", targets: ["Service"]) 82 | """ : "") 83 | ], 84 | dependencies: [ 85 | .package(url: "https://github.com/swifweb/web", from: "1.0.0-beta.2.0.0") 86 | ], 87 | targets: [ 88 | .executableTarget(name: "App", dependencies: [ 89 | .product(name: "Web", package: "web") 90 | ]),\(type == .pwa ? """ 91 | 92 | .executableTarget(name: "Service", dependencies: [ 93 | .product(name: "ServiceWorker", package: "web") 94 | ], resources: [ 95 | //.copy("images/favicon.ico"), 96 | //.copy("images") 97 | ]), 98 | """ : "") 99 | .testTarget(name: "AppTests", dependencies: ["App"]) 100 | ] 101 | ) 102 | """.data(using: .utf8)) 103 | // Tests folder 104 | let testsPath = newFolderPath.appending("/Tests") 105 | let appTestsPath = testsPath.appending("/AppTests") 106 | try FileManager.default.createDirectory(atPath: appTestsPath, withIntermediateDirectories: true) 107 | FileManager.default.createFile(atPath: testsPath.appending("/LinuxMain.swift"), contents: """ 108 | import XCTest 109 | 110 | import SwiftWasmAppTests 111 | 112 | var tests = [XCTestCaseEntry]() 113 | tests += SwiftWasmAppTests.allTests() 114 | XCTMain(tests) 115 | """.data(using: .utf8)) 116 | FileManager.default.createFile(atPath: appTestsPath.appending("/AppTests.swift"), contents: """ 117 | import XCTest 118 | import class Foundation.Bundle 119 | 120 | final class SwiftWasmAppTests: XCTestCase { 121 | func testExample() throws { 122 | XCTAssertEqual("Hello, world!", "Hello, world!") 123 | } 124 | 125 | static var allTests = [ 126 | ("testExample", testExample), 127 | ] 128 | } 129 | """.data(using: .utf8)) 130 | FileManager.default.createFile(atPath: appTestsPath.appending("/XCTestManifests.swift"), contents: """ 131 | import XCTest 132 | 133 | #if !canImport(ObjectiveC) 134 | public func allTests() -> [XCTestCaseEntry] { 135 | return [ 136 | testCase(SwiftWasmAppTests.allTests), 137 | ] 138 | } 139 | #endif 140 | """.data(using: .utf8)) 141 | // Sources folder 142 | let sourcesPath = newFolderPath.appending("/Sources") 143 | let appSourcesPath = sourcesPath.appending("/App") 144 | try FileManager.default.createDirectory(atPath: appSourcesPath, withIntermediateDirectories: true) 145 | FileManager.default.createFile(atPath: appSourcesPath.appending("/App.swift"), contents: """ 146 | import Web 147 | 148 | @main 149 | class App: WebApp { 150 | @State var color = Color.cyan 151 | 152 | enum Theme { 153 | case happy, sad 154 | } 155 | 156 | @State var theme: Theme = .happy 157 | 158 | @AppBuilder override var app: Configuration { 159 | Lifecycle.didFinishLaunching { 160 | Navigator.shared.serviceWorker?.register("./service.js") 161 | print("Lifecycle.didFinishLaunching") 162 | }.willTerminate { 163 | print("Lifecycle.willTerminate") 164 | }.willResignActive { 165 | print("Lifecycle.willResignActive") 166 | }.didBecomeActive { 167 | print("Lifecycle.didBecomeActive") 168 | }.didEnterBackground { 169 | print("Lifecycle.didEnterBackground") 170 | }.willEnterForeground { 171 | print("Lifecycle.willEnterForeground") 172 | } 173 | Routes { 174 | Page { IndexPage() } 175 | Page("hello") { HelloPage() } 176 | Page("**") { NotFoundPage() } 177 | } 178 | HappyStyle().id(.happyStyle).disabled($theme.map { $0 != .happy }) 179 | SadStyle().id("sadStyle").disabled($theme.map { $0 != .sad }) 180 | } 181 | } 182 | 183 | class HappyStyle: Stylesheet { 184 | @Rules 185 | override var rules: Rules.Content { 186 | Rule(H1.pointer).color(App.current.$color) 187 | Rule(Pointer.any) 188 | .margin(all: 0) 189 | .padding(all: 0) 190 | Rule(H1.class(.hello).after, H2.class(.hello).after) { 191 | AlignContent(.baseline) 192 | Color(.red) 193 | } 194 | .property(.alignContent, .auto) 195 | .alignContent(.auto) 196 | .color(.red) 197 | } 198 | } 199 | 200 | class SadStyle: Stylesheet { 201 | @Rules 202 | override var rules: Rules.Content { 203 | Rule(H1.pointer).color(.deepPink) 204 | } 205 | } 206 | 207 | extension Id { 208 | static var happyStyle: Id { "happyStyle" } 209 | } 210 | 211 | extension Class { 212 | static var hello: Class { "hello" } 213 | } 214 | """.data(using: .utf8)) 215 | let extensionsAppSourcesPath = appSourcesPath.appending("/Extensions") 216 | try FileManager.default.createDirectory(atPath: extensionsAppSourcesPath, withIntermediateDirectories: true) 217 | FileManager.default.createFile(atPath: extensionsAppSourcesPath.appending("/Fonts.swift"), contents: """ 218 | import Web 219 | 220 | extension FontFamilyType { 221 | static var sanFrancisco: Self { .init("San Francisco") } 222 | 223 | static var roboto: Self { .init("Roboto") } 224 | 225 | static var segoeUI: Self { .init("Segoe UI") } 226 | 227 | static var helveticaNeue: Self { .init("Helvetica Neue") } 228 | 229 | static var lucidaGrande: Self { .init("Lucida Grande") } 230 | 231 | static var app: Self { .combined(.system, .appleSystem, .sanFrancisco, .roboto, .segoeUI, .helveticaNeue, .lucidaGrande, .sansSerif) } 232 | } 233 | """.data(using: .utf8)) 234 | let pagesAppSourcesPath = appSourcesPath.appending("/Pages") 235 | try FileManager.default.createDirectory(atPath: pagesAppSourcesPath, withIntermediateDirectories: true) 236 | FileManager.default.createFile(atPath: pagesAppSourcesPath.appending("/HelloPage.swift"), contents: """ 237 | import Web 238 | 239 | class HelloPage: PageController { 240 | @DOM override var body: DOM.Content { 241 | P("Hello page") 242 | .textAlign(.center) 243 | .body { 244 | Button("go back").display(.block).onClick { 245 | History.back() 246 | } 247 | } 248 | Div().width(300.px).height(300.px).background(color: .yellow).backgroundImage("https://i.ytimg.com/vi/1Ne1hqOXKKI/maxresdefault.jpg").backgroundSize(h: 200.px, v: 200.px).backgroundRepeat(.noRepeat) 249 | } 250 | } 251 | 252 | /// Live preview works in both XCode and VSCode 253 | /// To make it work in XCode install the `XLivePreview` app 254 | /// To make it work in VSCode install `webber` extension 255 | class Hello_Preview: WebPreview { 256 | @Preview override class var content: Preview.Content { 257 | Language.en 258 | Title("My hello preview") 259 | Size(400, 400) 260 | HelloPage() 261 | } 262 | } 263 | """.data(using: .utf8)) 264 | FileManager.default.createFile(atPath: pagesAppSourcesPath.appending("/IndexPage.swift"), contents: """ 265 | import Web 266 | import FetchAPI 267 | 268 | class IndexPage: PageController { 269 | @State var firstTodoTitle = "n/a" 270 | 271 | @DOM override var body: DOM.Content { 272 | Header { 273 | Div { 274 | H1("First Todo Title") 275 | Br() 276 | H2(self.$firstTodoTitle) 277 | Br() 278 | Button("Load First Todo Title").onClick { 279 | Fetch("https://jsonplaceholder.typicode.com/todos/1") { 280 | switch $0 { 281 | case .failure: 282 | break 283 | case .success(let response): 284 | struct Todo: Decodable { 285 | let id, userId: Int 286 | let title: String 287 | let completed: Bool 288 | } 289 | response.json(as: Todo.self) { 290 | switch $0 { 291 | case .failure(let error): 292 | self.firstTodoTitle = "some error occured: \\(error)" 293 | case .success(let todo): 294 | self.firstTodoTitle = todo.title 295 | } 296 | } 297 | } 298 | } 299 | } 300 | } 301 | .position(.absolute) 302 | .display(.block) 303 | .top(50.percent) 304 | .left(50.percent) 305 | .transform(.translate(-50.percent, -50.percent)) 306 | .whiteSpace(.nowrap) 307 | .overflow(.hidden) 308 | } 309 | .position(.fixed) 310 | .width(100.percent) 311 | .height(100.percent) 312 | .background(.linearGradient(angle: -30, .red/20, .green/80, .red)) 313 | } 314 | 315 | override func buildUI() { 316 | super.buildUI() 317 | title = "Fetch example" 318 | metaDescription = "An awesome Swift in heart of your website" 319 | } 320 | } 321 | 322 | /// Live preview works in both XCode and VSCode 323 | /// To make it work in XCode install the `XLivePreview` app 324 | /// To make it work in VSCode install `webber` extension 325 | class Welcome_Preview: WebPreview { 326 | @Preview override class var content: Preview.Content { 327 | Language.en 328 | Title("Initial page") 329 | Size(640, 480) 330 | // add styles if needed 331 | AppStyles.id(.happyStyle) 332 | // add here as many elements as needed 333 | IndexPage() 334 | } 335 | } 336 | """.data(using: .utf8)) 337 | FileManager.default.createFile(atPath: pagesAppSourcesPath.appending("/NotFoundPage.swift"), contents: """ 338 | import Web 339 | 340 | class NotFoundPage: PageController { 341 | @DOM override var body: DOM.Content { 342 | P("this is catchall aka 404 NOT FOUND page") 343 | .textAlign(.center) 344 | .body { 345 | Button("go back").display(.block).onClick { 346 | History.back() 347 | } 348 | } 349 | } 350 | } 351 | 352 | /// Live preview works in both XCode and VSCode 353 | /// To make it work in XCode install the `XLivePreview` app 354 | /// To make it work in VSCode install `webber` extension 355 | class NotFound_Preview: WebPreview { 356 | @Preview override class var content: Preview.Content { 357 | Language.en 358 | Title("Not found endpoint") 359 | Size(200, 200) 360 | NotFoundPage() 361 | } 362 | } 363 | """.data(using: .utf8)) 364 | if type == .pwa { 365 | let serviceSourcesPath = sourcesPath.appending("/Service") 366 | let imagesServiceSourcesPath = serviceSourcesPath.appending("/images") 367 | try FileManager.default.createDirectory(atPath: serviceSourcesPath, withIntermediateDirectories: true) 368 | try FileManager.default.createDirectory(atPath: imagesServiceSourcesPath, withIntermediateDirectories: true) 369 | FileManager.default.createFile(atPath: serviceSourcesPath.appending("/Service.swift"), contents: """ 370 | import ServiceWorker 371 | 372 | @main 373 | public class Service: ServiceWorker { 374 | @ServiceBuilder public override var body: ServiceBuilder.Content { 375 | Manifest 376 | .name("\(name)") 377 | .startURL(".") 378 | .display(.standalone) 379 | .backgroundColor("#2A3443") 380 | .themeColor("white") 381 | // .icons( 382 | // .init(src: "images/192.png", sizes: .x192, type: .png), 383 | // .init(src: "images/512.png", sizes: .x512, type: .png) 384 | // ) 385 | Lifecycle.activate { 386 | debugPrint("service activate event") 387 | }.install { 388 | debugPrint("service install event") 389 | }.fetch { 390 | debugPrint("service fetch event") 391 | }.sync { 392 | debugPrint("service sync event") 393 | }.contentDelete { 394 | debugPrint("service contentDelete event") 395 | } 396 | } 397 | } 398 | """.data(using: .utf8)) 399 | } 400 | #if os(macOS) 401 | let isMacOS = true 402 | let chromeInstalled = FileManager.default.fileExists(atPath: "/Applications/Google Chrome.app") 403 | #else 404 | let isMacOS = false 405 | let chromeInstalled = false 406 | #endif 407 | context.console.output(""" 408 | ********************************************************************************************** 409 | * 410 | * New \(type == .pwa ? "PWA" : "SPA", color: .brightMagenta) project \(name, color: .brightYellow) has been created! 411 | * 412 | * Go to the project folder 413 | * \("cd \(name)", color: .brightGreen) 414 | * 415 | * Launch debug session 416 | * \("webber serve \(type == .pwa ? "-t pwa -s Service" : "") -p 443 \(chromeInstalled ? "--browser chrome --browser-self-signed --browser-incognito" : "")", color: .brightGreen) 417 | * 418 | * Open project folder in VSCode\(isMacOS ? " or Package.swift in Xcode" : "") and start working 419 | * Webber will reload page automatically once you save changes 420 | * 421 | ********************************************************************************************** 422 | """) 423 | } 424 | } 425 | 426 | fileprivate extension StringProtocol { 427 | var firstCapitalized: String { prefix(1).capitalized + dropFirst() } 428 | } 429 | -------------------------------------------------------------------------------- /Sources/Webber/Commands/ReleaseCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReleaseCommand.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 21.02.2021. 6 | // 7 | 8 | import ConsoleKit 9 | import Vapor 10 | 11 | final class ReleaseCommand: BundleCommand { 12 | override var help: String { 13 | "Compile, cook and pack files for release" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Webber/Commands/ServeCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServeCommand.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 31.01.2021. 6 | // 7 | 8 | import ConsoleKit 9 | import Vapor 10 | import NIOSSL 11 | import WasmTransformer 12 | 13 | final class ServeCommand: BundleCommand { 14 | override var debug: Bool { true } 15 | override var serve: Bool { true } 16 | 17 | override var help: String { 18 | "Compile, cook and serve files for debug and development" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Webber/Commands/VersionCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionCommand.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 25.12.2022. 6 | // 7 | 8 | import ConsoleKit 9 | import Vapor 10 | import NIOSSL 11 | import WasmTransformer 12 | 13 | final class VersionCommand: Command { 14 | static var currentVersion = "1.9.0" 15 | 16 | struct Signature: CommandSignature { 17 | init() {} 18 | } 19 | 20 | var help: String { "Prints current version" } 21 | 22 | func run(using context: ConsoleKit.CommandContext, signature: Signature) throws { 23 | let arch = try Arch.get() 24 | #if DEBUG 25 | let mode = "DEBUG" 26 | #else 27 | let mode = "RELEASE" 28 | #endif 29 | context.console.output([ 30 | ConsoleTextFragment(string: "Webber", style: .init(color: .magenta, isBold: true)), 31 | ConsoleTextFragment(string: " \(Self.currentVersion)-\(mode)", style: .init(color: .yellow, isBold: true)), 32 | ConsoleTextFragment(string: " \(arch)", style: .init(color: .green, isBold: true)) 33 | ]) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Webber/Enums/AppType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppType.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 21.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | enum AppType: String, LosslessStringConvertible { 11 | var description: String { rawValue } 12 | 13 | init?(_ description: String) { 14 | switch description { 15 | case "spa": self = .spa 16 | case "pwa": self = .pwa 17 | default: return nil 18 | } 19 | } 20 | 21 | case spa, pwa 22 | 23 | static var all: [String] { 24 | [AppType.spa, .pwa].map { $0.rawValue } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Webber/Enums/BrowserType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowserType.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 28.11.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | enum BrowserType: String, LosslessStringConvertible { 11 | var description: String { rawValue } 12 | 13 | init?(_ description: String) { 14 | switch description.lowercased() { 15 | case "safari": self = .safari 16 | case "chrome": self = .chrome 17 | case "google chrome": self = .chrome 18 | default: return nil 19 | } 20 | } 21 | 22 | case safari, chrome 23 | 24 | var appName: String { 25 | switch self { 26 | case .safari: return "Safari" 27 | case .chrome: return "Google Chrome" 28 | } 29 | } 30 | 31 | static var all: [String] { 32 | [BrowserType.safari, .chrome].map { $0.rawValue } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Webber/Extensions/Data+Bytes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data+Bytes.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 05.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | var bytes: [UInt8] { .init(self) } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Webber/Extensions/String+Random.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Random.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 11.02.2021. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | extension String { 12 | static func shuffledAlphabet(_ length: Int, upperLetters: Bool? = nil, lowerLetters: Bool? = nil, digits: Bool? = nil, specialCharacters: Bool? = nil) -> String { 13 | var letters = "" 14 | if digits == true { 15 | letters.append("0123456789") 16 | } 17 | if upperLetters == true { 18 | letters.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ") 19 | } 20 | if lowerLetters == true { 21 | letters.append("abcdefghijklmnopqrstuvwxyz") 22 | } 23 | if specialCharacters == true { 24 | letters.append("!@#$%&()=.") 25 | } 26 | if letters.count == 0 { 27 | assert(letters.count == 0, "Unable to generate random string") 28 | } 29 | var randomString = "" 30 | for _ in 0...length - 1 { 31 | let random = [UInt32].random(count: 1)[0] 32 | let rand = random % UInt32(letters.count) 33 | let ind = Int(rand) 34 | let character = letters[letters.index(letters.startIndex, offsetBy: ind)] 35 | randomString.append(character) 36 | } 37 | return randomString 38 | } 39 | 40 | static func shuffledNumber(_ length: Int) -> String { 41 | let letters = "0123456789" 42 | var randomString = "" 43 | for _ in 0...length - 1 { 44 | let random = [UInt32].random(count: 1)[0] 45 | let rand = random % UInt32(letters.count) 46 | let ind = Int(rand) 47 | let character = letters[letters.index(letters.startIndex, offsetBy: ind)] 48 | randomString.append(character) 49 | } 50 | return randomString 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Webber/Extensions/String+SwifWebLogo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+SwifWebLogo.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 05.10.2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | static var swifWebASCIILogo = #""" 12 | _______.____ __ ____ __ ___________ __ ____ _______ .______ 13 | / |\ \ / \ / / | | | ____\ \ / \ / / | ____|| _ \ 14 | | (----` \ \/ \/ / | | | |__ \ \/ \/ / | |__ | |_) | 15 | \ \ \ / | | | __| \ / | __| | _ < 16 | .----) | \ /\ / | | | | \ /\ / | |____ | |_) | 17 | |_______/ \__/ \__/ |__| |__| \__/ \__/ |_______||______/ 18 | """# 19 | static var swifWebASCIILogo2 = """ 20 | ░██████╗░██╗░░░░░░░██╗██╗███████╗░██╗░░░░░░░██╗███████╗██████╗░ 21 | ██╔════╝░██║░░██╗░░██║██║██╔════╝░██║░░██╗░░██║██╔════╝██╔══██╗ 22 | ╚█████╗░░╚██╗████╗██╔╝██║█████╗░░░╚██╗████╗██╔╝█████╗░░██████╦╝ 23 | ░╚═══██╗░░████╔═████║░██║██╔══╝░░░░████╔═████║░██╔══╝░░██╔══██╗ 24 | ██████╔╝░░╚██╔╝░╚██╔╝░██║██║░░░░░░░╚██╔╝░╚██╔╝░███████╗██████╦╝ 25 | ╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░░░░░░░╚═╝░░░╚═╝░░╚══════╝╚═════╝░ 26 | """ 27 | static var swifWebASCIILogo3 = """ 28 | ╭━━━╮╱╱╱╱╱╱╭━┳╮╭╮╭╮╱╱╭╮ 29 | ┃╭━╮┃╱╱╱╱╱╱┃╭┫┃┃┃┃┃╱╱┃┃ 30 | ┃╰━━┳╮╭╮╭┳┳╯╰┫┃┃┃┃┣━━┫╰━╮ 31 | ╰━━╮┃╰╯╰╯┣╋╮╭┫╰╯╰╯┃┃━┫╭╮┃ 32 | ┃╰━╯┣╮╭╮╭┫┃┃┃╰╮╭╮╭┫┃━┫╰╯┃ 33 | ╰━━━╯╰╯╰╯╰╯╰╯╱╰╯╰╯╰━━┻━━╯ 34 | """ 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Webber/Extensions/URL+FileAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+FileAttributes.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 21.02.2021. 6 | // Taken from https://stackoverflow.com/questions/28268145/get-file-size-in-swift 7 | // 8 | 9 | import Foundation 10 | 11 | extension URL { 12 | var attributes: [FileAttributeKey : Any]? { 13 | do { 14 | return try FileManager.default.attributesOfItem(atPath: path) 15 | } catch { 16 | debugPrint("FileAttribute error: \(error)") 17 | } 18 | return nil 19 | } 20 | 21 | var fileSize: UInt64 { 22 | attributes?[.size] as? UInt64 ?? UInt64(0) 23 | } 24 | 25 | var fileSizeString: String { 26 | ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) 27 | } 28 | 29 | var creationDate: Date? { 30 | attributes?[.creationDate] as? Date 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Webber/Helpers/DetectPlatformType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetectPlatformType.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 29.01.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | func isAppleSilicon() -> Bool { 11 | var systeminfo = utsname() 12 | uname(&systeminfo) 13 | let machine = withUnsafeBytes(of: &systeminfo.machine) { bufPtr -> String in 14 | let data = Data(bufPtr) 15 | if let lastIndex = data.lastIndex(where: {$0 != 0}) { 16 | return String(data: data[0...lastIndex], encoding: .isoLatin1)! 17 | } else { 18 | return String(data: data, encoding: .isoLatin1)! 19 | } 20 | } 21 | return machine == "arm64" 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Webber/Helpers/WebberContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebberContext.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 01.02.2021. 6 | // 7 | 8 | import Vapor 9 | import ConsoleKit 10 | 11 | private var _defaultToolchainVersion = "wasm-5.10.0-RELEASE" 12 | 13 | class WebberContext { 14 | private(set) lazy var defaultToolchainVersion = _defaultToolchainVersion 15 | 16 | #if os(macOS) 17 | private(set) lazy var toolchainPaths: [String] = [ 18 | "/Library/Developer/Toolchains", 19 | "~/Library/Developer/Toolchains" 20 | ] 21 | #else 22 | private(set) lazy var toolchainPaths: [String] = ["/opt"] 23 | #endif 24 | 25 | #if os(macOS) 26 | let toolchainExtension = ".xctoolchain" 27 | #else 28 | let toolchainExtension = "" 29 | #endif 30 | 31 | let customToolchain: String? 32 | let dir: DirectoryConfiguration 33 | let command: CommandContext 34 | let verbose: Bool 35 | let debugVerbose: Bool 36 | let port: Int 37 | let browserType: BrowserType? 38 | let browserSelfSigned: Bool 39 | let browserIncognito: Bool 40 | let console: Console 41 | let customSwiftVersion: String? 42 | let ignoreCustomSwiftVersion: Bool 43 | 44 | lazy var toolchainFolder = "swift-" + toolchainVersion + toolchainExtension 45 | 46 | private var toolchainVersion: String { 47 | if let customToolchain = customToolchain { 48 | let cleaned = customToolchain.replacingOccurrences(of: "swift-", with: "") 49 | if cleaned.hasPrefix("wasm-") { 50 | defaultToolchainVersion = cleaned 51 | return cleaned 52 | } 53 | if cleaned.contains("SNAPSHOT") { 54 | self.command.console.output([ 55 | ConsoleTextFragment(string: "YOU ARE ON PRE-RELEASE TOOLCHAIN", style: .init(color: .magenta, isBold: true)) 56 | ]) 57 | } 58 | defaultToolchainVersion = "wasm-" + cleaned 59 | return defaultToolchainVersion 60 | } 61 | return customSwiftVersion ?? defaultToolchainVersion 62 | } 63 | 64 | init ( 65 | customToolchain: String?, 66 | dir: DirectoryConfiguration, 67 | command: CommandContext, 68 | verbose: Bool, 69 | debugVerbose: Bool, 70 | ignoreCustomSwiftVersion: Bool, 71 | port: Int, 72 | browserType: BrowserType?, 73 | browserSelfSigned: Bool, 74 | browserIncognito: Bool, 75 | console: Console 76 | ) { 77 | self.customToolchain = customToolchain 78 | self.dir = dir 79 | self.command = command 80 | if debugVerbose { 81 | self.verbose = debugVerbose 82 | } else { 83 | self.verbose = verbose 84 | } 85 | self.debugVerbose = debugVerbose 86 | self.ignoreCustomSwiftVersion = ignoreCustomSwiftVersion 87 | self.port = port 88 | self.browserType = browserType 89 | self.browserSelfSigned = browserSelfSigned 90 | self.browserIncognito = browserIncognito 91 | self.console = console 92 | if !ignoreCustomSwiftVersion { 93 | let swiftVersionPath = URL(fileURLWithPath: dir.workingDirectory).appendingPathComponent(".swift-version").path 94 | if let data = FileManager.default.contents(atPath: swiftVersionPath), let swiftVersion = String(data: data, encoding: .utf8), swiftVersion.hasPrefix("wasm-") { 95 | let v = swiftVersion.trimmingCharacters(in: .whitespacesAndNewlines) 96 | if _defaultToolchainVersion != v { 97 | self.customSwiftVersion = v 98 | // TODO: add check for silent mode 99 | console.output([ 100 | ConsoleTextFragment(string: "Using .swift-version: \(v)", style: .init(color: .brightMagenta, isBold: true)) 101 | ]) 102 | } else { 103 | self.customSwiftVersion = nil 104 | } 105 | } else { 106 | self.customSwiftVersion = nil 107 | } 108 | } else { 109 | self.customSwiftVersion = nil 110 | } 111 | } 112 | 113 | func debugVerbose(_ text: String) { 114 | guard debugVerbose else { return } 115 | console.output([ 116 | ConsoleTextFragment(string: "D: \(text)", style: .init(color: .brightRed, isBold: false)) 117 | ]) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/Webber/Helpers/WebberMiddleware.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebberMiddleware.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 10.02.2021. 6 | // 7 | 8 | import Vapor 9 | 10 | final class WebberMiddleware: Middleware { 11 | private let publicDirectory: URL 12 | 13 | public init(publicDirectory: URL) { 14 | self.publicDirectory = publicDirectory 15 | } 16 | 17 | public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { 18 | // make a copy of the percent-decoded path 19 | guard var path = request.url.path.removingPercentEncoding else { 20 | return request.eventLoop.makeFailedFuture(Abort(.badRequest)) 21 | } 22 | 23 | func respondWithIndex() -> Response { 24 | let filePath = publicDirectory.appendingPathComponent("index.html").path 25 | var isDir: ObjCBool = false 26 | guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir), !isDir.boolValue else { 27 | return Response(status: .ok, body: Response.Body.init(staticString: "Seems that you deleted index file")) 28 | } 29 | return request.fileio.streamFile(at: filePath) 30 | } 31 | 32 | // respond with index 33 | guard path != "/" else { 34 | return request.eventLoop.makeSucceededFuture(respondWithIndex()) 35 | } 36 | 37 | // path must be relative. 38 | while path.hasPrefix("/") { 39 | path = String(path.dropFirst()) 40 | } 41 | 42 | // protect against relative paths 43 | guard !path.contains("../") else { 44 | return request.eventLoop.makeFailedFuture(Abort(.forbidden)) 45 | } 46 | 47 | guard path != "webber" else { 48 | return next.respond(to: request) 49 | } 50 | 51 | // create absolute file path 52 | let fileURL = publicDirectory.appendingPathComponent(path) 53 | let fileInResourcesURL = publicDirectory.appendingPathComponent(".resources").appendingPathComponent(path) 54 | let filePath = fileURL.path 55 | 56 | /// Stream file from .resources directory if it doesn't exists in the root directory 57 | if FileManager.default.fileExists(atPath: fileInResourcesURL.path) && !FileManager.default.fileExists(atPath: fileURL.path) { 58 | let res = request.fileio.streamFile(at: fileInResourcesURL.path) 59 | return request.eventLoop.makeSucceededFuture(res) 60 | } 61 | 62 | /// robots.txt placeholder for Lighthouse 63 | guard fileURL.lastPathComponent != "robots.txt" else { 64 | let scheme = request.url.scheme ?? "https" 65 | let host = request.url.host ?? "127.0.0.1" 66 | let port = request.url.port ?? 8888 67 | return request.eventLoop.makeSucceededFuture(Response(status: .ok, body: .init(string: """ 68 | User-agent: * 69 | Allow: / 70 | 71 | Sitemap: \(scheme)://\(host):\(port)/sitemap.xml 72 | """))) 73 | } 74 | 75 | // check if file exists and is not a directory 76 | var isDir: ObjCBool = false 77 | guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir), !isDir.boolValue else { 78 | return request.eventLoop.makeSucceededFuture(respondWithIndex()) 79 | } 80 | 81 | // stream the file 82 | var mediaType: HTTPMediaType? 83 | if fileURL.pathExtension == "wasm" { 84 | mediaType = .init(type: "application", subType: "wasm") 85 | } 86 | let res = request.fileio.streamFile(at: filePath, mediaType: mediaType) 87 | res.headers.add(name: .cacheControl, value: "max-age=31536000, public") 88 | return request.eventLoop.makeSucceededFuture(res) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Apt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Apt.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 12.09.2022. 6 | // 7 | 8 | import Foundation 9 | import WebberTools 10 | 11 | struct Apt { 12 | enum AptError: Error, CustomStringConvertible { 13 | case unableToInstall(program: String) 14 | 15 | var description: String { 16 | switch self { 17 | case .unableToInstall(let program): return "Unable to install `\(program)`" 18 | } 19 | } 20 | } 21 | 22 | static func install(_ program: String) throws { 23 | let stdout = Pipe() 24 | let process = Process() 25 | process.launchPath = try Bash.which("apt-get") 26 | process.arguments = ["--yes", "--force-yes", "install", program] 27 | process.standardOutput = stdout 28 | 29 | let outHandle = stdout.fileHandleForReading 30 | outHandle.waitForDataInBackgroundAndNotify() 31 | 32 | process.launch() 33 | process.waitUntilExit() 34 | 35 | guard process.terminationStatus == 0 else { 36 | throw AptError.unableToInstall(program: program) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Arch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Arch.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 23.12.2022. 6 | // 7 | 8 | import Foundation 9 | import WebberTools 10 | 11 | struct Arch { 12 | enum ArchError: Error, CustomStringConvertible { 13 | case unableToGetCurrentArchitecture 14 | 15 | var description: String { 16 | switch self { 17 | case .unableToGetCurrentArchitecture: return "Unable to get architecture of the current system" 18 | } 19 | } 20 | } 21 | 22 | /// Returns current architecture 23 | static func get() throws -> String { 24 | let stdout = Pipe() 25 | let process = Process() 26 | process.launchPath = try Bash.which("uname") 27 | process.arguments = ["-m"] 28 | process.standardOutput = stdout 29 | 30 | let outHandle = stdout.fileHandleForReading 31 | 32 | process.launch() 33 | process.waitUntilExit() 34 | 35 | guard process.terminationStatus == 0 else { 36 | throw ArchError.unableToGetCurrentArchitecture 37 | } 38 | 39 | let data = outHandle.readDataToEndOfFile() 40 | guard data.count > 0, let path = String(data: data, encoding: .utf8) else { 41 | throw ArchError.unableToGetCurrentArchitecture 42 | } 43 | return path.trimmingCharacters(in: .whitespacesAndNewlines) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Brew.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Brew.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 09.02.2021. 6 | // 7 | 8 | import Foundation 9 | import WebberTools 10 | 11 | struct Brew { 12 | enum BrewError: Error, CustomStringConvertible { 13 | case unableToInstall(program: String) 14 | 15 | var description: String { 16 | switch self { 17 | case .unableToInstall(let program): return "Unable to install `\(program)`" 18 | } 19 | } 20 | } 21 | 22 | static func install(_ program: String) throws { 23 | let stdout = Pipe() 24 | let process = Process() 25 | process.launchPath = try Bash.which("brew") 26 | process.arguments = ["install", program] 27 | process.standardOutput = stdout 28 | 29 | let outHandle = stdout.fileHandleForReading 30 | outHandle.waitForDataInBackgroundAndNotify() 31 | 32 | process.launch() 33 | process.waitUntilExit() 34 | 35 | guard process.terminationStatus == 0 else { 36 | throw BrewError.unableToInstall(program: program) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Extractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extractor.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 02.02.2021. 6 | // 7 | 8 | import Foundation 9 | import WebberTools 10 | 11 | struct Extractor { 12 | enum ExtractorError: Error, CustomStringConvertible { 13 | case brokenStdout 14 | case somethingWentWrong(code: Int32) 15 | 16 | var description: String { 17 | switch self { 18 | case .brokenStdout: return "Unable to read stdout" 19 | case .somethingWentWrong(let code): return "Extractor failed with code \(code)" 20 | } 21 | } 22 | } 23 | 24 | static func extract(archive archivePath: URL, dest destinationPath: URL) throws { 25 | let stdout = Pipe() 26 | let stderr = Pipe() 27 | 28 | let process = Process() 29 | process.launchPath = try Bash.which("tar") 30 | process.arguments = ["xzf", archivePath.path, "--strip-components=1", "--directory", destinationPath.path] 31 | process.standardOutput = stdout 32 | process.standardError = stderr 33 | 34 | let outHandle = stdout.fileHandleForReading 35 | outHandle.waitForDataInBackgroundAndNotify() 36 | 37 | process.launch() 38 | process.waitUntilExit() 39 | 40 | guard process.terminationStatus == 0 else { 41 | throw ExtractorError.somethingWentWrong(code: process.terminationStatus) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Installer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Installer.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 02.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Installer { 11 | enum InstallerError: Error, CustomStringConvertible { 12 | case unavailableOnLinux 13 | case brokenStdout 14 | case somethingWentWrong(code: Int32) 15 | 16 | var description: String { 17 | switch self { 18 | case .unavailableOnLinux: return "macOS package installer is not available on Linux" 19 | case .brokenStdout: return "Unable to read stdout" 20 | case .somethingWentWrong(let code): return "Installer failed with code \(code)" 21 | } 22 | } 23 | } 24 | 25 | static func install(_ url: URL) throws { 26 | #if !os(macOS) 27 | throw InstallerError.unavailableOnLinux 28 | #endif 29 | let stdout = Pipe() 30 | let stderr = Pipe() 31 | let process = Process() 32 | process.launchPath = "/usr/sbin/installer" 33 | process.arguments = ["-target", "CurrentUserHomeDirectory", "-pkg", url.path] 34 | process.standardOutput = stdout 35 | process.standardError = stderr 36 | 37 | let outHandle = stdout.fileHandleForReading 38 | outHandle.waitForDataInBackgroundAndNotify() 39 | 40 | process.launch() 41 | process.waitUntilExit() 42 | 43 | guard process.terminationStatus == 0 else { 44 | throw InstallerError.somethingWentWrong(code: process.terminationStatus) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/IpConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IpConfig.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 10.02.2021. 6 | // 7 | 8 | import Foundation 9 | import WebberTools 10 | 11 | struct IpConfig { 12 | static func getLocalIPs() -> [String] { 13 | ["en0", "en1", "en2", "en3", "en4"].compactMap { 14 | try? getIP(at: $0) 15 | } + ["127.0.0.1"] 16 | } 17 | 18 | static func getIP(at interface: String) throws -> String? { 19 | let stdout = Pipe() 20 | let process = Process() 21 | process.launchPath = try Bash.which("ipconfig") 22 | process.arguments = ["getifaddr", interface] 23 | process.standardOutput = stdout 24 | 25 | let outHandle = stdout.fileHandleForReading 26 | 27 | process.launch() 28 | process.waitUntilExit() 29 | 30 | guard process.terminationStatus == 0 else { 31 | return nil 32 | } 33 | 34 | let data = outHandle.readDataToEndOfFile() 35 | guard data.count > 0, let path = String(data: data, encoding: .utf8) else { 36 | return nil 37 | } 38 | return path.trimmingCharacters(in: .whitespacesAndNewlines) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Npm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Mihael Isaev on 09.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ConsoleKit 10 | import WebberTools 11 | 12 | private let programName = "npm" 13 | 14 | struct Npm { 15 | let console: Console 16 | let launchPath, cwd: String 17 | 18 | init (_ console: Console, _ cwd: String) throws { 19 | self.console = console 20 | self.cwd = cwd 21 | if !Bash.whichBool(programName) { 22 | guard console.confirm([ 23 | ConsoleTextFragment(string: programName, style: .init(color: .brightYellow, isBold: true)), 24 | ConsoleTextFragment(string: " is not installed, would you like to install it?", style: .init(color: .none)) 25 | ]) else { 26 | throw NpmError.error("\(programName) is required to prepare web files, please install it manually then") 27 | } 28 | #if os(macOS) 29 | try Brew.install(programName) 30 | #else 31 | try Apt.install(programName) 32 | #endif 33 | } 34 | launchPath = try Bash.which(programName) 35 | } 36 | 37 | func install() throws { 38 | guard !isNodeModulesFolderExists() else { return } 39 | let stdout = Pipe() 40 | let process = Process() 41 | process.currentDirectoryPath = cwd 42 | process.launchPath = launchPath 43 | process.arguments = ["install", "--quiet", "--no-progress"] 44 | process.standardOutput = stdout 45 | 46 | let outHandle = stdout.fileHandleForReading 47 | outHandle.waitForDataInBackgroundAndNotify() 48 | 49 | process.launch() 50 | process.waitUntilExit() 51 | 52 | guard process.terminationStatus == 0 else { 53 | throw NpmError.unableToInstall 54 | } 55 | } 56 | 57 | func addDevDependencies() throws { 58 | let packages = [ 59 | "@wasmer/wasi@0.12.0", 60 | "@wasmer/wasmfs@0.12.0", 61 | "javascript-kit-swift@0.17.0", 62 | "reconnecting-websocket", 63 | "webpack", 64 | "webpack-cli" 65 | ] 66 | let installedPackages = try list() 67 | try packages.forEach { 68 | guard !installedPackages.contains($0) else { return } 69 | try addDependency($0, dev: true) 70 | } 71 | } 72 | 73 | func addDependency(_ packageName: String, dev: Bool = false) throws { 74 | let stdout = Pipe() 75 | let process = Process() 76 | process.currentDirectoryPath = cwd 77 | process.launchPath = launchPath 78 | process.arguments = ["install", packageName, dev ? "--save-dev" : "--save-prod", "--quiet", "--no-progress"] 79 | process.standardOutput = stdout 80 | 81 | let outHandle = stdout.fileHandleForReading 82 | outHandle.waitForDataInBackgroundAndNotify() 83 | 84 | process.launch() 85 | process.waitUntilExit() 86 | 87 | guard process.terminationStatus == 0 else { 88 | throw NpmError.unableToInstall 89 | } 90 | } 91 | 92 | func installAllGlobalPackages() throws { 93 | try installGlobalPackage("webpack-cli") 94 | } 95 | 96 | func installGlobalPackage(_ packageName: String) throws { 97 | guard !Bash.whichBool(packageName) else { return } 98 | let stdout = Pipe() 99 | let process = Process() 100 | process.currentDirectoryPath = cwd 101 | process.launchPath = launchPath 102 | process.arguments = ["install", packageName, "-g", "--quiet", "--no-progress"] 103 | process.standardOutput = stdout 104 | 105 | let outHandle = stdout.fileHandleForReading 106 | outHandle.waitForDataInBackgroundAndNotify() 107 | 108 | process.launch() 109 | process.waitUntilExit() 110 | 111 | guard process.terminationStatus == 0 else { 112 | throw NpmError.unableToInstall 113 | } 114 | } 115 | 116 | func isNodeModulesFolderExists() -> Bool { 117 | let path = URL(fileURLWithPath: cwd) 118 | .appendingPathComponent("node_modules") 119 | .path 120 | var isDir : ObjCBool = true 121 | return FileManager.default.fileExists(atPath: path, isDirectory: &isDir) 122 | } 123 | 124 | func list() throws -> String { 125 | guard isNodeModulesFolderExists() else { return "" } 126 | let stdout = Pipe() 127 | let process = Process() 128 | process.currentDirectoryPath = cwd 129 | process.launchPath = launchPath 130 | process.arguments = ["list", "--depth=0"] 131 | process.standardOutput = stdout 132 | 133 | let outHandle = stdout.fileHandleForReading 134 | 135 | process.launch() 136 | process.waitUntilExit() 137 | 138 | guard process.terminationStatus == 0 else { 139 | throw NpmError.unableToInstall 140 | } 141 | let data = outHandle.readDataToEndOfFile() 142 | guard data.count > 0, let list = String(data: data, encoding: .utf8) else { 143 | throw NpmError.error("Unable to get list of packages from npm") 144 | } 145 | return list 146 | } 147 | 148 | enum NpmError: Error, CustomStringConvertible { 149 | case unableToInstall 150 | case error(String) 151 | 152 | var description: String { 153 | switch self { 154 | case .unableToInstall: return "`npm install` command failed" 155 | case .error(let description): return description 156 | } 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/OpenSSL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenSSL.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 10.02.2021. 6 | // 7 | 8 | import Foundation 9 | import WebberTools 10 | 11 | struct OpenSSL { 12 | enum OpenSSLError: Error, CustomStringConvertible { 13 | case error(String) 14 | 15 | var description: String { 16 | switch self { 17 | case .error(let description): return description 18 | } 19 | } 20 | } 21 | 22 | static func generate(at path: String, keyName: String, certName: String, configName: String) throws { 23 | let stdout = Pipe() 24 | let process = Process() 25 | process.launchPath = try Bash.which("openssl") 26 | process.currentDirectoryPath = path 27 | process.arguments = [ 28 | "req", 29 | "-x509", 30 | "-days", "3650", 31 | "-keyout", keyName, 32 | "-out", certName, 33 | "-newkey", "rsa:2048", 34 | "-nodes", 35 | "-sha256", 36 | "-subj", "/CN=0.0.0.0", 37 | "-extensions", "EXT", 38 | "-config", configName 39 | ] 40 | process.standardOutput = stdout 41 | 42 | let outHandle = stdout.fileHandleForReading 43 | outHandle.waitForDataInBackgroundAndNotify() 44 | 45 | process.launch() 46 | process.waitUntilExit() 47 | 48 | guard process.terminationStatus == 0 else { 49 | throw OpenSSLError.error("Unable to generate self-signed SSL certificate") 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Optimizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Optimizer.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 11.02.2021. 6 | // 7 | 8 | import Foundation 9 | import WasmTransformer 10 | import ConsoleKit 11 | 12 | struct Optimizer { 13 | static func optimizeForOldSafari(debug: Bool, _ productName: String, context: WebberContext) throws { 14 | let wasmFileURL = URL(fileURLWithPath: context.dir.workingDirectory) 15 | .appendingPathComponent(".build") 16 | .appendingPathComponent(".wasi") 17 | .appendingPathComponent("wasm32-unknown-wasi") 18 | .appendingPathComponent(debug ? "debug" : "release") 19 | .appendingPathComponent(productName) 20 | .appendingPathExtension("wasm") 21 | 22 | let startedAt = Date() 23 | 24 | // let bar = context.command.console.loadingBar(title: "Optimizing \"\(productName)\" for old Safari") 25 | // bar.start() 26 | 27 | guard let wasmBeforeOptimization = FileManager.default.contents(atPath: wasmFileURL.path) else { 28 | // bar.fail() 29 | context.command.console.clear(.line) 30 | context.command.console.output([ 31 | ConsoleTextFragment(string: "Unable to read compiled wasm file ☹️ at: \(wasmFileURL.path)", style: .init(color: .brightRed, isBold: true)) 32 | ]) 33 | return 34 | } 35 | 36 | guard FileManager.default.createFile( 37 | atPath: wasmFileURL.path, 38 | contents: Data(try lowerI64Imports(wasmBeforeOptimization.bytes)), 39 | attributes: nil 40 | ) else { 41 | // bar.fail() 42 | context.command.console.clear(.line) 43 | context.command.console.output([ 44 | ConsoleTextFragment(string: "Unable to save optimized wasm file ☹️", style: .init(color: .brightRed, isBold: true)) 45 | ]) 46 | return 47 | } 48 | 49 | // bar.succeed() 50 | // context.command.console.clear(.line) 51 | context.command.console.output([ 52 | ConsoleTextFragment(string: "Optimized \"\(productName)\" for old Safari in ", style: .init(color: .brightBlue, isBold: true)), 53 | ConsoleTextFragment(string: String(format: "%.2fs", Date().timeIntervalSince(startedAt)), style: .init(color: .brightMagenta)) 54 | ]) 55 | } 56 | 57 | static func stripDebugInfo(debug: Bool = false, _ productName: String, context: WebberContext) throws { 58 | let wasmFileURL = URL(fileURLWithPath: context.dir.workingDirectory) 59 | .appendingPathComponent(".build") 60 | .appendingPathComponent(".wasi") 61 | .appendingPathComponent("wasm32-unknown-wasi") 62 | .appendingPathComponent(debug ? "debug" : "release") 63 | .appendingPathComponent(productName) 64 | .appendingPathExtension("wasm") 65 | 66 | let startedAt = Date() 67 | 68 | // let bar = context.command.console.loadingBar(title: "Stripping \"\(productName)\" debug info") 69 | // bar.start() 70 | 71 | guard let wasmBeforeOptimization = FileManager.default.contents(atPath: wasmFileURL.path) else { 72 | // bar.fail() 73 | // context.command.console.clear(.line) 74 | context.command.console.output([ 75 | ConsoleTextFragment(string: "Unable to read compiled wasm file ☹️", style: .init(color: .brightRed, isBold: true)) 76 | ]) 77 | return 78 | } 79 | 80 | guard FileManager.default.createFile( 81 | atPath: wasmFileURL.path, 82 | contents: Data(try stripCustomSections(wasmBeforeOptimization.bytes)), 83 | attributes: nil 84 | ) else { 85 | // bar.fail() 86 | // context.command.console.clear(.line) 87 | context.command.console.output([ 88 | ConsoleTextFragment(string: "Unable to save stripped wasm file ☹️", style: .init(color: .brightRed, isBold: true)) 89 | ]) 90 | return 91 | } 92 | 93 | // bar.succeed() 94 | // context.command.console.clear(.line) 95 | context.command.console.output([ 96 | ConsoleTextFragment(string: "Stripped \"\(productName)\" debug info in ", style: .init(color: .brightBlue, isBold: true)), 97 | ConsoleTextFragment(string: String(format: "%.2fs", Date().timeIntervalSince(startedAt)), style: .init(color: .brightMagenta)) 98 | ]) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Server.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Server.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 10.02.2021. 6 | // 7 | 8 | import Vapor 9 | import NIOSSL 10 | import WebberTools 11 | 12 | class Server { 13 | fileprivate var app: Application! 14 | let webber: Webber 15 | let port: Int 16 | private var wsClients: [WebSocket] = [] 17 | 18 | init (_ webber: Webber) { 19 | self.webber = webber 20 | self.port = webber.context.port // 8888 21 | } 22 | 23 | private var isSpinnedUp = false 24 | 25 | func spinup() throws { 26 | guard !isSpinnedUp else { return } 27 | isSpinnedUp = true 28 | 29 | var env = try Environment.detect() 30 | try LoggingSystem.bootstrap(from: &env) 31 | app = Application(env) 32 | try configureHTTP2() 33 | let publicDirectory = URL(fileURLWithPath: webber.context.dir.workingDirectory) 34 | .appendingPathComponent(".webber") 35 | .appendingPathComponent("dev") 36 | app.middleware.use(WebberMiddleware(publicDirectory: publicDirectory)) 37 | app.webSocket("webber") { req, client in 38 | self.wsClients.append(client) 39 | client.onClose.whenComplete { _ in self.wsClients.removeAll(where: { $0 === client }) } 40 | } 41 | app.logger.logLevel = .critical 42 | defer { app.shutdown() } 43 | app.lifecycle.use(self) 44 | try app.run() 45 | } 46 | 47 | enum WSOutgoingNotification: String { 48 | case wasmRecompiled 49 | case entrypointRecooked 50 | } 51 | 52 | func notify(_ notification: WSOutgoingNotification) { 53 | wsClients.forEach { 54 | $0.send(notification.rawValue) 55 | } 56 | } 57 | 58 | private func configureHTTP1() { 59 | app.http.server.configuration.address = .hostname("0.0.0.0", port: port) 60 | } 61 | 62 | private func configureHTTP2() throws { 63 | let sslURL = URL(fileURLWithPath: webber.entrypointDevSSL) 64 | let keyURL = sslURL.appendingPathComponent("key.pem") 65 | let certURL = sslURL.appendingPathComponent("cert.pem") 66 | let certs = try certificates(ssl: sslURL, key: keyURL, cert: certURL) 67 | let tls = TLSConfiguration.makeServerConfiguration(certificateChain: certs, privateKey: .file(keyURL.path)) 68 | 69 | app.http.server.configuration = .init(hostname: "0.0.0.0", 70 | port: port, 71 | backlog: 256, 72 | reuseAddress: true, 73 | tcpNoDelay: true, 74 | responseCompression: .disabled, 75 | requestDecompression: .disabled, 76 | supportPipelining: false, 77 | supportVersions: [.two], 78 | tlsConfiguration: tls, 79 | serverName: nil, 80 | logger: nil) 81 | } 82 | 83 | private func certificates(ssl: URL, key: URL, cert: URL) throws -> [NIOSSLCertificateSource] { 84 | if !FileManager.default.fileExists(atPath: key.path) || !FileManager.default.fileExists(atPath: cert.path) { 85 | var isDir : ObjCBool = false 86 | if !FileManager.default.fileExists(atPath: ssl.path, isDirectory: &isDir) { 87 | try FileManager.default.createDirectory(at: ssl, withIntermediateDirectories: false, attributes: nil) 88 | } else if isDir.boolValue { 89 | throw ServerError.error("SSL path is unexpectedly file, not a folder: \(ssl.path)") 90 | } 91 | context.console.output([ 92 | ConsoleTextFragment(string: "Generating self-signed SSL certificate", style: .init(color: .brightYellow, isBold: true)) 93 | ]) 94 | let configURL = ssl.appendingPathComponent("conf.cnf") 95 | do { 96 | let config = """ 97 | [dn] 98 | CN=0.0.0.0 99 | [req] 100 | distinguished_name = dn 101 | [EXT] 102 | subjectAltName=DNS:localhost 103 | keyUsage=digitalSignature 104 | extendedKeyUsage=serverAuth 105 | """ 106 | guard FileManager.default.createFile(atPath: configURL.path, contents: config.data(using: .utf8), attributes: nil) else { 107 | throw ServerError.error("Unable to create SSL config file") 108 | } 109 | try OpenSSL.generate( 110 | at: ssl.path, 111 | keyName: key.lastPathComponent, 112 | certName: cert.lastPathComponent, 113 | configName: configURL.lastPathComponent 114 | ) 115 | try? FileManager.default.removeItem(at: configURL) 116 | } catch { 117 | try? FileManager.default.removeItem(at: configURL) 118 | throw error 119 | } 120 | context.console.clear(.line) 121 | context.console.output([ 122 | ConsoleTextFragment(string: "Generated self-signed SSL certificate", style: .init(color: .brightBlue, isBold: true)) 123 | ]) 124 | } 125 | 126 | return try NIOSSLCertificate.fromPEMFile(cert.path).map { .certificate($0) } 127 | } 128 | } 129 | 130 | extension Server: LifecycleHandler { 131 | public func didBoot(_ application: Application) throws { 132 | IpConfig.getLocalIPs().forEach { address in 133 | webber.context.command.console.output([ 134 | ConsoleTextFragment(string: "Available at ", style: .init(color: .brightBlue, isBold: true)), 135 | ConsoleTextFragment(string: "https://" + address + ":\(webber.context.port)", style: .init(color: .brightMagenta)) 136 | ]) 137 | } 138 | var url = "https://localhost" 139 | if webber.context.port != 443 { 140 | url = "\(url):\(webber.context.port)" 141 | } 142 | open(url: url) 143 | } 144 | 145 | func shutdown(_ application: Application) { 146 | FS.shutdown() 147 | exit(EXIT_SUCCESS) 148 | } 149 | 150 | private func open(url: String) { 151 | #if os(macOS) 152 | let launchPath = "/usr/bin/open" 153 | #else 154 | let launchPath = "/bin/xdg-open" 155 | #endif 156 | let process = Process() 157 | process.launchPath = launchPath 158 | #if os(macOS) 159 | if let browserType = webber.context.browserType { 160 | var args: [String] = ["-a", browserType.appName] 161 | if webber.context.browserSelfSigned { 162 | args += ["-n", "-u", url] 163 | args += [ 164 | "--args", "--user-data-dir=/tmp", "--ignore-certificate-errors", "--unsafely-treat-insecure-origin-as-secure=\(url)" 165 | ] 166 | if webber.context.browserIncognito { 167 | args += ["--incognito"] 168 | } 169 | process.arguments = args 170 | } else { 171 | if webber.context.browserIncognito { 172 | args += ["-u", url, "--args", "--incognito"] 173 | } 174 | process.arguments = args 175 | } 176 | process.launch() 177 | } 178 | #else 179 | process.arguments = [url] 180 | process.launch() 181 | #endif 182 | } 183 | } 184 | 185 | private enum ServerError: Error, CustomStringConvertible { 186 | case error(String) 187 | 188 | var description: String { 189 | switch self { 190 | case .error(let description): return description 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Toolchain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Toolchain.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 01.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ConsoleKit 10 | 11 | class Toolchain { 12 | let context: WebberContext 13 | 14 | private(set) var toolchainPath: String? 15 | 16 | private var _swiftPath: String? { 17 | guard let path = toolchainPath else { return nil } 18 | return URL(fileURLWithPath: path).appendingPathComponent("swift").path 19 | } 20 | 21 | init (_ context: WebberContext) { 22 | self.context = context 23 | lookup() 24 | } 25 | 26 | enum ToolchainError: Error, CustomStringConvertible { 27 | case toolchainNotFound 28 | 29 | var description: String { 30 | switch self { 31 | case .toolchainNotFound: return "Toolchain not found" 32 | } 33 | } 34 | } 35 | 36 | func pathToSwift() throws -> String { 37 | try lookupOrInstall() 38 | guard let path = _swiftPath else { 39 | throw ToolchainError.toolchainNotFound 40 | } 41 | return path 42 | } 43 | 44 | private var isLookedUp = false 45 | 46 | func lookupOrInstall() throws { 47 | guard !isLookedUp else { 48 | return 49 | } 50 | isLookedUp = true 51 | guard let toolchainPath = self.toolchainPath else { 52 | let localURL = try ToolchainRetriever(self.context).retrieve() 53 | #if os(macOS) 54 | try Installer.install(localURL) 55 | #else 56 | let destURL = URL(fileURLWithPath: "/opt/" + self.context.toolchainFolder) 57 | if !FileManager.default.fileExists(atPath: destURL.path) { 58 | try FileManager.default.createDirectory(atPath: destURL.path, withIntermediateDirectories: true) 59 | } 60 | try Extractor.extract(archive: localURL, dest: destURL) 61 | #endif 62 | lookup() 63 | guard let toolchainPath = self.toolchainPath else { 64 | throw ToolchainError.toolchainNotFound 65 | } 66 | context.command.console.output([ 67 | ConsoleTextFragment(string: "Toolchain has been installed at ", style: .init(color: .brightGreen)), 68 | ConsoleTextFragment(string: toolchainPath, style: .init(color: .brightYellow)) 69 | ]) 70 | return 71 | } 72 | context.command.console.output([ 73 | ConsoleTextFragment(string: "Toolchain has been found at ", style: .init(color: .brightGreen)), 74 | ConsoleTextFragment(string: toolchainPath, style: .init(color: .brightYellow)) 75 | ]) 76 | } 77 | 78 | private func lookup() { 79 | for path in context.toolchainPaths { 80 | var isDir : ObjCBool = false 81 | let url = URL(fileURLWithPath: 82 | path.hasPrefix("~/") 83 | ? path.replacingOccurrences(of: "~", with: FileManager.default.homeDirectoryForCurrentUser.path) 84 | : path 85 | ) 86 | .appendingPathComponent(context.toolchainFolder) 87 | .appendingPathComponent("usr") 88 | .appendingPathComponent("bin") 89 | let path: String = url.appendingPathComponent("swift").path 90 | guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { 91 | continue 92 | } 93 | self.toolchainPath = url.path 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/ToolchainInstaller.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolchainInstaller.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 02.02.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | class ToolchainInstaller { 11 | let context: WebberContext 12 | let url: URL 13 | 14 | init (_ context: WebberContext, _ url: URL) { 15 | self.context = context 16 | self.url = url 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/ToolchainRetriever.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolchainRetriever.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 02.02.2021. 6 | // 7 | 8 | import Foundation 9 | #if canImport(FoundationNetworking) 10 | import FoundationNetworking 11 | #endif 12 | import ConsoleKit 13 | 14 | class ToolchainRetriever { 15 | let context: WebberContext 16 | #if os(macOS) 17 | private var observation: NSKeyValueObservation? 18 | #endif 19 | // TODO: add linux support 20 | 21 | init (_ context: WebberContext) { 22 | self.context = context 23 | } 24 | 25 | deinit { 26 | #if os(macOS) 27 | observation?.invalidate() 28 | #endif 29 | // TODO: add linux support 30 | } 31 | 32 | enum ToolchainRetrieverError: Error, CustomStringConvertible { 33 | case unableToPreapareURL 34 | case unableToFindTagForCurrentOS 35 | case githubBadResponse 36 | case undecodableTag 37 | case unableToDownload 38 | case somethingWentWrong(Error) 39 | 40 | var description: String { 41 | switch self { 42 | case .unableToPreapareURL: return "Unable to prepare URL for toolchain tag" 43 | case .unableToFindTagForCurrentOS: return "Unable to find tag for current OS" 44 | case .githubBadResponse: return "Bad response from github" 45 | case .undecodableTag: return "Unable to decode tag from github" 46 | case .unableToDownload: return "Unable to download toolchain" 47 | case .somethingWentWrong(let e): return e.localizedDescription 48 | } 49 | } 50 | } 51 | 52 | private struct GithubTag: Decodable { 53 | struct Asset: Decodable { 54 | let name: String 55 | let size: Int 56 | let browser_download_url: URL 57 | } 58 | let assets: [Asset] 59 | } 60 | 61 | private var ubuntuRelease: String? { 62 | guard let data = FileManager.default.contents(atPath: "/etc/lsb-release"), let str = String(data: data, encoding: .utf8) else { 63 | return nil 64 | } 65 | if str.contains("DISTRIB_RELEASE=18.04") { 66 | return "ubuntu18.04" 67 | } else if str.contains("DISTRIB_RELEASE=20.04") { 68 | return "ubuntu20.04" 69 | } else if str.contains("DISTRIB_RELEASE=22.04") { 70 | return "ubuntu22.04" 71 | } else if str.contains("DISTRIB_RELEASE=24.04") { 72 | return "ubuntu24.04" 73 | } 74 | return nil 75 | } 76 | 77 | func retrieve() throws -> URL { 78 | let tag = context.customSwiftVersion ?? context.defaultToolchainVersion 79 | 80 | guard let url = URL(string: "https://api.github.com/repos/swiftwasm/swift/releases/tags/swift-\(tag)") else { 81 | throw ToolchainRetrieverError.unableToPreapareURL 82 | } 83 | 84 | if context.verbose { 85 | self.context.command.console.output([ 86 | ConsoleTextFragment(string: "Toolchain Asset URL: ", style: .init(color: .brightYellow)), 87 | ConsoleTextFragment(string: url.absoluteString, style: .init(color: .brightGreen)), 88 | ]) 89 | } 90 | 91 | var request = URLRequest(url: url) 92 | request.httpMethod = "GET" 93 | 94 | let group = DispatchGroup() 95 | 96 | func getLastToolchainAsset() throws -> GithubTag.Asset { 97 | var d: Data? 98 | var r: URLResponse? 99 | var e: Error? 100 | group.enter() 101 | URLSession.shared.dataTask(with: request) { 102 | d = $0 103 | r = $1 104 | e = $2 105 | group.leave() 106 | }.resume() 107 | group.wait() 108 | if let err = e { 109 | throw ToolchainRetrieverError.somethingWentWrong(err) 110 | } 111 | guard let response = r as? HTTPURLResponse, (200..<300).contains(response.statusCode), let data = d else { 112 | throw ToolchainRetrieverError.githubBadResponse 113 | } 114 | let tag = try JSONDecoder().decode(GithubTag.self, from: data) 115 | let arch = try Arch.get() 116 | if context.verbose { 117 | self.context.command.console.output([ 118 | ConsoleTextFragment(string: "Current architecture: ", style: .init(color: .brightYellow)), 119 | ConsoleTextFragment(string: arch, style: .init(color: .brightGreen)), 120 | ]) 121 | } 122 | #if os(macOS) 123 | guard let asset = tag.assets.first(where: { $0.name.contains("macos") && $0.name.contains(arch) && !$0.name.contains("artifact") && $0.browser_download_url.absoluteString.hasSuffix(".pkg") }) else { 124 | throw ToolchainRetrieverError.undecodableTag 125 | } 126 | return asset 127 | #else 128 | guard 129 | let ubuntuRelease = self.ubuntuRelease, 130 | let asset = tag.assets.first(where: { $0.name.contains(ubuntuRelease) && $0.name.contains(arch) && !$0.name.contains("artifact") }) 131 | else { 132 | throw ToolchainRetrieverError.unableToFindTagForCurrentOS 133 | } 134 | return asset 135 | #endif 136 | } 137 | 138 | let asset = try getLastToolchainAsset() 139 | 140 | func download(_ url: URL, _ size: Int) throws -> URL { 141 | if context.verbose { 142 | self.context.command.console.output([ 143 | ConsoleTextFragment(string: "Toolchain URL: ", style: .init(color: .brightYellow)), 144 | ConsoleTextFragment(string: url.absoluteString, style: .init(color: .brightGreen)), 145 | ]) 146 | } 147 | var localURL: URL? 148 | var error: Error? 149 | group.enter() 150 | let task = URLSession.shared.downloadTask(with: url) { 151 | localURL = $0 152 | error = $2 153 | group.leave() 154 | } 155 | #if os(macOS) 156 | observation = task.progress.observe(\.fractionCompleted) { progress, _ in 157 | let progress = String(format: "%.2fMb (%.2f", (Double(size) / 1_000_000) * progress.fractionCompleted, progress.fractionCompleted * 100) + "%)" 158 | self.context.command.console.clear(.line) 159 | self.context.command.console.output([ 160 | ConsoleTextFragment(string: "Toolchain downloading progress: ", style: .init(color: .brightYellow)), 161 | ConsoleTextFragment(string: progress, style: .init(color: .brightGreen)), 162 | ]) 163 | } 164 | #endif 165 | // TODO: add linux support 166 | let downloadinStartedAt = Date() 167 | context.command.console.output([ConsoleTextFragment(string: "Started toolchain downloading, please wait", style: .init(color: .yellow))]) 168 | task.resume() 169 | group.wait() 170 | context.command.console.clear(.line) 171 | context.command.console.output([ 172 | ConsoleTextFragment(string: "Toolchain downloaded in ", style: .init(color: .brightGreen)), 173 | ConsoleTextFragment(string: String(format: "%.2fs", Date().timeIntervalSince(downloadinStartedAt)), style: .init(color: .brightMagenta)), 174 | ]) 175 | guard error == nil else { 176 | throw ToolchainRetrieverError.unableToDownload 177 | } 178 | guard let localURLUnwrapped = localURL else { 179 | throw ToolchainRetrieverError.unableToDownload 180 | } 181 | #if os(macOS) 182 | let ext = ".pkg" 183 | #else 184 | let ext = ".tar.gz" 185 | #endif 186 | let destURL = URL(fileURLWithPath: "/tmp/" + localURLUnwrapped.deletingPathExtension().lastPathComponent + ext) 187 | try FileManager.default.moveItem(at: localURLUnwrapped, to: destURL) 188 | return destURL 189 | } 190 | 191 | return try download(asset.browser_download_url, asset.size) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/WasmOpt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WasmOpt.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 21.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ConsoleKit 10 | import WebberTools 11 | 12 | struct WasmOpt { 13 | enum WasmOptError: Error, CustomStringConvertible { 14 | case brokenStdout 15 | case somethingWentWrong(code: Int32) 16 | 17 | var description: String { 18 | switch self { 19 | case .brokenStdout: return "Unable to read stdout" 20 | case .somethingWentWrong(let code): return "WasmOpt failed with code \(code)" 21 | } 22 | } 23 | } 24 | 25 | static func optimize(_ productName: String, context: WebberContext) throws { 26 | let wasmFileURL = URL(fileURLWithPath: context.dir.workingDirectory) 27 | .appendingPathComponent(".build") 28 | .appendingPathComponent(".wasi") 29 | .appendingPathComponent("release") 30 | .appendingPathComponent(productName) 31 | .appendingPathExtension("wasm") 32 | 33 | let stdout = Pipe() 34 | let process = Process() 35 | process.launchPath = try Bash.which("wasm-opt") 36 | process.arguments = ["-Os", "--enable-bulk-memory", wasmFileURL.path, "-o", wasmFileURL.path] 37 | process.standardOutput = stdout 38 | 39 | let outHandle = stdout.fileHandleForReading 40 | outHandle.waitForDataInBackgroundAndNotify() 41 | 42 | let startedAt = Date() 43 | 44 | // let bar = context.command.console.loadingBar(title: "Optimizing \"\(productName)\" with `wasm-opt`") 45 | // bar.start() 46 | 47 | process.launch() 48 | process.waitUntilExit() 49 | 50 | guard process.terminationStatus == 0 else { 51 | // bar.fail() 52 | // context.command.console.clear(.line) 53 | throw WasmOptError.somethingWentWrong(code: process.terminationStatus) 54 | } 55 | 56 | // bar.succeed() 57 | // context.command.console.clear(.line) 58 | context.command.console.output([ 59 | ConsoleTextFragment(string: "Optimized \"\(productName)\" with `wasm-opt` in ", style: .init(color: .brightBlue, isBold: true)), 60 | ConsoleTextFragment(string: String(format: "%.2fs", Date().timeIntervalSince(startedAt)), style: .init(color: .brightMagenta)), 61 | ConsoleTextFragment(string: " new size is ", style: .init(color: .brightBlue, isBold: true)), 62 | ConsoleTextFragment(string: wasmFileURL.fileSizeString, style: .init(color: .brightMagenta)) 63 | ]) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Webber+Index.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Webber+Index.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 09.02.2021. 6 | // 7 | 8 | extension Webber { 9 | func index(dev: Bool = false, appJS: String, swJS: String, type: AppType = .spa, manifest: Manifest?) -> String { 10 | var headlines: [String] = [] 11 | headlines.append(#""#) 12 | headlines.append(#""#) 13 | headlines.append(#""#) 14 | headlines.append(#""#) 15 | if type == .pwa { 16 | if let manifest = manifest { 17 | headlines.append("") 18 | } 19 | headlines.append(#""#) 20 | } 21 | headlines.append("") 22 | let style = """ 23 | 77 | """ 78 | let eventListeners = """ 79 | 100 | """ 101 | return """ 102 | 103 | 104 | 105 | \(headlines.joined(separator: "\n")) 106 | 107 | 108 | \(style) 109 |

Loading

110 |

111 |
112 |
113 |
114 | \(eventListeners) 115 | 116 | 117 | """ 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Webber+JS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Webber+JS.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 09.02.2021. 6 | // 7 | 8 | extension Webber { 9 | func js(dev: Bool = false, wasmFilename: String, type: AppType, serviceWorker: Bool = false) -> String { 10 | var file = """ 11 | // Copyright 2020 Carton contributors 12 | // Modifications copyright 2021 Webber contributors 13 | // 14 | // Licensed under the Apache License, Version 2.0 (the "License"); 15 | // you may not use this file except in compliance with the License. 16 | // You may obtain a copy of the License at 17 | // 18 | // http://www.apache.org/licenses/LICENSE-2.0 19 | // 20 | // Unless required by applicable law or agreed to in writing, software 21 | // distributed under the License is distributed on an "AS IS" BASIS, 22 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 23 | // See the License for the specific language governing permissions and 24 | // limitations under the License. 25 | """ 26 | if serviceWorker { 27 | file += "\n" 28 | file += "\n" 29 | file += """ 30 | self.serviceInstallWasCalled = false; 31 | self.serviceInstalled = false; 32 | 33 | const serviceWorkerInstallPromise = new Promise((resolve, reject) => { 34 | function check(resolve) { 35 | setTimeout(() => { 36 | if (self.serviceInstalled) { 37 | resolve(); 38 | } else if (self.serviceInstallationError) { 39 | reject(self.serviceInstallationError); 40 | } else { 41 | check(resolve); 42 | } 43 | }, 1000); 44 | } 45 | check(resolve); 46 | }); 47 | 48 | self.addEventListener('install', (event) => { 49 | self.serviceInstallWasCalled = true; 50 | event.waitUntil(serviceWorkerInstallPromise); 51 | }); 52 | self.addEventListener('activate', (event) => { 53 | self.activate(event); 54 | }); 55 | self.addEventListener('contentdelete', (event) => { 56 | self.contentDelete(event); 57 | }); 58 | self.addEventListener('fetch', (event) => { 59 | self.fetch(event); 60 | }); 61 | self.addEventListener('message', (event) => { 62 | self.message(event); 63 | }); 64 | self.addEventListener('notificationclick', (event) => { 65 | self.notificationClick(event); 66 | }); 67 | self.addEventListener('notificationclose', (event) => { 68 | self.notificationClose(event); 69 | }); 70 | self.addEventListener('push', (event) => { 71 | self.push(event); 72 | }); 73 | self.addEventListener('pushsubscriptionchange', (event) => { 74 | self.pushSubscriptionChange(event); 75 | }); 76 | self.addEventListener('sync', (event) => { 77 | self.sync(event); 78 | }); 79 | """ 80 | file += "\n" 81 | } 82 | var imports: [String] = [] 83 | imports.append(#"import { SwiftRuntime } from "javascript-kit-swift";"#) 84 | imports.append(#"import { WASI } from "@wasmer/wasi";"#) 85 | imports.append(#"import { WasmFs } from "@wasmer/wasmfs";"#) 86 | if dev { 87 | imports.append(#"import ReconnectingWebSocket from "reconnecting-websocket";"#) 88 | } 89 | file += "\n" 90 | file += "\n" 91 | file += imports.joined(separator: "\n") 92 | file += "\n" 93 | file += "\n" 94 | file += """ 95 | const swift = new SwiftRuntime(); 96 | 97 | // Instantiate a new WASI Instance 98 | const wasmFs = new WasmFs(); 99 | """ 100 | if dev { 101 | file += """ 102 | \n 103 | const socket = new ReconnectingWebSocket(`wss://${location.host}/webber`); 104 | socket.addEventListener("message", (message) => { 105 | if (message.data === "wasmRecompiled") { 106 | location.reload(); 107 | } else if (message.data === "entrypointRecooked") { 108 | location.reload(); 109 | } 110 | }); 111 | \n 112 | """ 113 | } 114 | file += """ 115 | // Output stdout and stderr to console 116 | const originalWriteSync = wasmFs.fs.writeSync; 117 | wasmFs.fs.writeSync = (fd, buffer, offset, length, position) => { 118 | const text = new TextDecoder("utf-8").decode(buffer); 119 | if (text !== "\\n") { 120 | switch (fd) { 121 | case 1: 122 | console.log(text); 123 | break; 124 | case 2: 125 | """ 126 | if dev { 127 | file += """ 128 | console.error(text); 129 | const prevLimit = Error.stackTraceLimit; 130 | Error.stackTraceLimit = 1000 131 | socket.send( 132 | JSON.stringify({ 133 | kind: "stackTrace", 134 | stackTrace: new Error().stack, 135 | }) 136 | ); 137 | Error.stackTraceLimit = prevLimit; 138 | """ 139 | } else { 140 | file += "console.error(text);" 141 | } 142 | file += """ 143 | break; 144 | } 145 | } 146 | return originalWriteSync(fd, buffer, offset, length, position); 147 | }; 148 | """ 149 | file += "\n" 150 | file += """ 151 | const wasi = new WASI({ 152 | args: [], 153 | env: {}, 154 | bindings: { 155 | ...WASI.defaultBindings, 156 | fs: wasmFs.fs, 157 | }, 158 | }); 159 | """ 160 | file += "\n" 161 | file += "\n" 162 | file += """ 163 | const startWasiTask = async () => { 164 | const fetchPromise = fetch("/\(wasmFilename).wasm"); 165 | 166 | // Fetch our Wasm File 167 | const response = await fetchPromise 168 | 169 | const reader = response.body.getReader(); 170 | 171 | // Step 2: get total length 172 | const contentLength = +response.headers.get('Content-Length'); 173 | """ 174 | file += "\n" 175 | if !serviceWorker { 176 | file += """ 177 | if (response.status == 304) { 178 | new Event('WASMLoadedFromCache'); 179 | } else if (response.status == 200) { 180 | if (contentLength > 0) { 181 | document.dispatchEvent(new Event('WASMLoadingStarted')); 182 | document.dispatchEvent(new CustomEvent('WASMLoadingProgress', { detail: 0 })); 183 | } else { 184 | document.dispatchEvent(new Event('WASMLoadingStartedWithoutProgress')); 185 | } 186 | } else { 187 | document.dispatchEvent(new Event('WASMLoadingError')); 188 | } 189 | """ 190 | } 191 | file += """ 192 | // Step 3: read the data 193 | let receivedLength = 0; 194 | let chunks = []; 195 | while(true) { 196 | const {done, value} = await reader.read(); 197 | 198 | if (done) { 199 | break; 200 | } 201 | 202 | chunks.push(value); 203 | receivedLength += value.length; 204 | """ 205 | file += "\n" 206 | if !serviceWorker { 207 | file += """ 208 | if (contentLength > 0) { 209 | document.dispatchEvent(new CustomEvent('WASMLoadingProgress', { detail: Math.trunc(receivedLength / (contentLength / 100)) })); 210 | } 211 | """ 212 | file += "\n" 213 | } 214 | file += """ 215 | } 216 | 217 | // Step 4: concatenate chunks into single Uint8Array 218 | let chunksAll = new Uint8Array(receivedLength); 219 | let position = 0; 220 | for (let chunk of chunks) { 221 | chunksAll.set(chunk, position); 222 | position += chunk.length; 223 | } 224 | 225 | // Instantiate the WebAssembly file 226 | const wasmBytes = chunksAll.buffer; 227 | 228 | const patchWASI = function (wasiObject) { 229 | // PATCH: @wasmer-js/wasi@0.x forgets to call `refreshMemory` in `clock_res_get`, 230 | // which writes its result to memory view. Without the refresh the memory view, 231 | // it accesses a detached array buffer if the memory is grown by malloc. 232 | // But they wasmer team discarded the 0.x codebase at all and replaced it with 233 | // a new implementation written in Rust. The new version 1.x is really unstable 234 | // and not production-ready as far as katei investigated in Apr 2022. 235 | // So override the broken implementation of `clock_res_get` here instead of 236 | // fixing the wasi polyfill. 237 | // Reference: https://github.com/wasmerio/wasmer-js/blob/55fa8c17c56348c312a8bd23c69054b1aa633891/packages/wasi/src/index.ts#L557 238 | const original_clock_res_get = wasiObject.wasiImport["clock_res_get"]; 239 | 240 | wasiObject.wasiImport["clock_res_get"] = (clockId, resolution) => { 241 | wasiObject.refreshMemory(); 242 | return original_clock_res_get(clockId, resolution); 243 | }; 244 | return wasiObject.wasiImport; 245 | }; 246 | 247 | var wasmImports = {}; 248 | wasmImports.wasi_snapshot_preview1 = patchWASI(wasi); 249 | wasmImports.javascript_kit = swift.wasmImports; 250 | wasmImports.__stack_sanitizer = { 251 | report_stack_overflow: () => { 252 | throw new Error("Detected stack buffer overflow."); 253 | }, 254 | }; 255 | 256 | const module = await WebAssembly.instantiate(wasmBytes, wasmImports); 257 | 258 | // Node support 259 | const instance = "instance" in module ? module.instance : module; 260 | 261 | if (swift && instance.exports.swjs_library_version) { 262 | swift.setInstance(instance); 263 | } 264 | 265 | // Start the WebAssembly WASI instance 266 | wasi.start(instance); 267 | 268 | // Initialize and start Reactor 269 | if (instance.exports._initialize) { 270 | instance.exports._initialize(); 271 | instance.exports.main(); 272 | } 273 | }; 274 | """ 275 | file += "\n" 276 | file += "\n" 277 | file += """ 278 | function handleError(e) { 279 | console.error(e); 280 | if (e instanceof WebAssembly.RuntimeError) { 281 | console.log(e.stack); 282 | } 283 | } 284 | """ 285 | file += "\n" 286 | file += "\n" 287 | file += """ 288 | try { 289 | startWasiTask().catch(handleError); 290 | } catch (e) { 291 | handleError(e); 292 | } 293 | """ 294 | return file 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /Sources/Webber/Tools/Webber.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Webber.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 09.02.2021. 6 | // 7 | 8 | import Foundation 9 | import ConsoleKit 10 | import WebberTools 11 | 12 | struct Webber { 13 | private struct NewNpmPackage: Encodable { 14 | let name = "webber" 15 | let version = "1.0.0" 16 | } 17 | 18 | let console: Console 19 | let context: WebberContext 20 | let webberFolder = ".webber" 21 | let cwd: String 22 | var devPath: String { 23 | URL(fileURLWithPath: cwd).appendingPathComponent(point(true)).path 24 | } 25 | var releasePath: String { 26 | URL(fileURLWithPath: cwd).appendingPathComponent(point(false)).path 27 | } 28 | var entrypoint: String { 29 | URL(fileURLWithPath: cwd).appendingPathComponent("entrypoint").path 30 | } 31 | var entrypointDev: String { 32 | URL(fileURLWithPath: entrypoint).appendingPathComponent("dev").path 33 | } 34 | var entrypointDevSSL: String { 35 | URL(fileURLWithPath: entrypointDev).appendingPathComponent(".ssl").path 36 | } 37 | 38 | init (_ context: WebberContext) throws { 39 | self.console = context.command.console 40 | self.context = context 41 | cwd = URL(fileURLWithPath: context.dir.workingDirectory).appendingPathComponent(webberFolder).path 42 | try checkOrCreateCWD() 43 | try checkOrCreatePackageFile() 44 | } 45 | 46 | struct CookSettings { 47 | let appType: AppType 48 | let dev: Bool 49 | let point: String 50 | let appJSURL, destURL, serviceWorkerJSURL, indexHTMLURL: URL 51 | 52 | init (webber: Webber, type: AppType, dev: Bool, appTarget: String, serviceWorkerTarget: String, type appType: AppType) { 53 | self.appType = type 54 | self.dev = dev 55 | self.point = webber.point(dev) 56 | let appTarget = appTarget.lowercased() 57 | let serviceWorkerTarget = serviceWorkerTarget.lowercased() 58 | appJSURL = URL(fileURLWithPath: webber.entrypoint) 59 | .appendingPathComponent(point) 60 | .appendingPathComponent(appTarget + ".js") 61 | destURL = URL(fileURLWithPath: webber.cwd).appendingPathComponent(point) 62 | serviceWorkerJSURL = URL(fileURLWithPath: webber.entrypoint) 63 | .appendingPathComponent(point) 64 | .appendingPathComponent(serviceWorkerTarget + ".js") 65 | indexHTMLURL = URL(fileURLWithPath: webber.entrypoint) 66 | .appendingPathComponent(point) 67 | .appendingPathComponent("index.html") 68 | } 69 | } 70 | 71 | func cook(dev: Bool, appTarget: String, serviceWorkerTarget: String, type appType: AppType) throws { 72 | let startedAt = Date() 73 | console.output([ 74 | ConsoleTextFragment(string: "Cooking web files, please wait", style: .init(color: .brightYellow)) 75 | ]) 76 | if !dev { 77 | try? FileManager.default.removeItem(atPath: releasePath) 78 | } 79 | try checkOrCreateEntrypoint() 80 | let settings = CookSettings(webber: self, type: appType, dev: dev, appTarget: appTarget, serviceWorkerTarget: serviceWorkerTarget, type: appType) 81 | try createPointFolderInsideEntrypoint(dev: dev) 82 | var manifest: Manifest? 83 | if appType == .pwa { 84 | manifest = try grabPWAManifest(dev: dev, serviceWorkerTarget: serviceWorkerTarget) 85 | } 86 | var isDir : ObjCBool = false 87 | let appJSPath = settings.appJSURL.path 88 | if !FileManager.default.fileExists(atPath: appJSPath, isDirectory: &isDir) { 89 | let js = self.js(dev: dev, wasmFilename: appTarget.lowercased(), type: appType) 90 | guard let data = js.data(using: .utf8), FileManager.default.createFile(atPath: appJSPath, contents: data) else { 91 | throw WebberError.error("Unable to create \(settings.appJSURL.lastPathComponent) file") 92 | } 93 | } 94 | try webpack(dev: dev, jsFile: settings.appJSURL.lastPathComponent, destURL: settings.destURL) 95 | let serviceWorkerJSPath = settings.serviceWorkerJSURL.path 96 | if appType == .pwa { 97 | if !FileManager.default.fileExists(atPath: serviceWorkerJSPath, isDirectory: &isDir) { 98 | let js = self.js(dev: dev, wasmFilename: serviceWorkerTarget.lowercased(), type: appType, serviceWorker: true) 99 | guard let data = js.data(using: .utf8), FileManager.default.createFile(atPath: serviceWorkerJSPath, contents: data) else { 100 | throw WebberError.error("Unable to create \(settings.serviceWorkerJSURL.lastPathComponent) file") 101 | } 102 | } 103 | try webpack(dev: dev, jsFile: settings.serviceWorkerJSURL.lastPathComponent, destURL: settings.destURL) 104 | } 105 | try? cookIndexFile(settings, manifest) 106 | console.clear(.line) 107 | console.output([ 108 | ConsoleTextFragment(string: "Cooked web files in ", style: .init(color: .brightBlue, isBold: true)), 109 | ConsoleTextFragment(string: String(format: "%.2fs", Date().timeIntervalSince(startedAt)), style: .init(color: .brightMagenta)) 110 | ]) 111 | } 112 | 113 | struct Manifest: Codable { 114 | let themeColor: String 115 | 116 | private enum CodingKeys : String, CodingKey { 117 | case themeColor = "theme_color" 118 | } 119 | } 120 | 121 | private func cookIndexFile(_ settings: CookSettings, _ manifest: Manifest?) throws { 122 | let indexHTMLPath = settings.indexHTMLURL.path 123 | var isDir : ObjCBool = false 124 | if !FileManager.default.fileExists(atPath: indexHTMLPath, isDirectory: &isDir) { 125 | let index = self.index( 126 | dev: settings.dev, 127 | appJS: settings.appJSURL.lastPathComponent, 128 | swJS: settings.serviceWorkerJSURL.lastPathComponent, 129 | type: settings.appType, 130 | manifest: manifest 131 | ) 132 | guard let data = index.data(using: .utf8), FileManager.default.createFile(atPath: indexHTMLPath, contents: data) else { 133 | throw WebberError.error("Unable to create \(settings.indexHTMLURL.lastPathComponent) file") 134 | } 135 | } 136 | let newIndexPath = settings.destURL 137 | .appendingPathComponent(settings.indexHTMLURL.lastPathComponent) 138 | .path 139 | try? FileManager.default.removeItem(atPath: newIndexPath) 140 | try FileManager.default.copyItem(atPath: indexHTMLPath, toPath: newIndexPath) 141 | } 142 | 143 | func recookManifestWithIndex(dev: Bool, appTarget: String, serviceWorkerTarget: String, type appType: AppType) throws { 144 | let manifest = try grabPWAManifest(dev: dev, serviceWorkerTarget: serviceWorkerTarget) 145 | let settings = CookSettings(webber: self, type: appType, dev: dev, appTarget: appTarget, serviceWorkerTarget: serviceWorkerTarget, type: appType) 146 | try cookIndexFile(settings, manifest) 147 | } 148 | 149 | private func grabPWAManifest(dev: Bool, serviceWorkerTarget: String) throws -> Manifest? { 150 | let executablePath = URL(fileURLWithPath: context.dir.workingDirectory) 151 | .appendingPathComponent(".build") 152 | .appendingPathComponent(".native") 153 | .appendingPathComponent("debug") 154 | .appendingPathComponent(serviceWorkerTarget) 155 | .path 156 | var isDir : ObjCBool = false 157 | guard FileManager.default.fileExists(atPath: executablePath, isDirectory: &isDir) else { 158 | console.clear(.line) 159 | console.output([ 160 | ConsoleTextFragment(string: "⚠️ Warning: unable to cook pwa manifest, executable wasn't built", style: .init(color: .brightYellow, isBold: true)) 161 | ]) 162 | return nil 163 | } 164 | let jsonString = try executeServiceWorkerToGrabManifest(path: executablePath) 165 | guard let data = jsonString.data(using: .utf8) else { 166 | console.clear(.line) 167 | console.output([ 168 | ConsoleTextFragment(string: "⚠️ Warning: unable to cook pwa manifest, seems it is corrupted", style: .init(color: .brightYellow, isBold: true)) 169 | ]) 170 | return nil 171 | } 172 | do { 173 | let manifest = try JSONDecoder().decode(Manifest.self, from: data) 174 | let resultDir = dev ? devPath : releasePath 175 | if !FileManager.default.fileExists(atPath: resultDir) { 176 | try? FileManager.default.createDirectory(atPath: resultDir, withIntermediateDirectories: false) 177 | } 178 | let manifestPath = URL(fileURLWithPath: resultDir).appendingPathComponent("manifest.json").path 179 | if FileManager.default.fileExists(atPath: manifestPath) { 180 | try? FileManager.default.removeItem(atPath: manifestPath) 181 | } 182 | guard FileManager.default.createFile(atPath: manifestPath, contents: data) else { 183 | console.clear(.line) 184 | console.output([ 185 | ConsoleTextFragment(string: "⚠️ Warning: unable to save pwa manifest", style: .init(color: .brightYellow, isBold: true)) 186 | ]) 187 | return nil 188 | } 189 | return manifest 190 | } catch { 191 | console.clear(.line) 192 | console.output([ 193 | ConsoleTextFragment(string: "⚠️ Warning: unable to cook pwa manifest: \(error)", style: .init(color: .brightYellow, isBold: true)) 194 | ]) 195 | return nil 196 | } 197 | } 198 | 199 | private func executeServiceWorkerToGrabManifest(path: String) throws -> String { 200 | let stdout = Pipe() 201 | let process = Process() 202 | process.launchPath = path 203 | process.standardOutput = stdout 204 | 205 | let outHandle = stdout.fileHandleForReading 206 | 207 | process.launch() 208 | process.waitUntilExit() 209 | 210 | guard process.terminationStatus == 0 else { 211 | let data = outHandle.readDataToEndOfFile() 212 | guard data.count > 0, let error = String(data: data, encoding: .utf8) else { 213 | throw WebberError.error("Service worker executable failed with code \(process.terminationStatus)") 214 | } 215 | throw WebberError.error("Service worker executable failed: \(error)") 216 | } 217 | let data = outHandle.readDataToEndOfFile() 218 | guard data.count > 0, let manifest = String(data: data, encoding: .utf8) else { 219 | throw WebberError.error("Service worker executable failed: empty string has been returned") 220 | } 221 | return manifest 222 | } 223 | 224 | private func createPointFolderInsideEntrypoint(dev: Bool) throws { 225 | let point = self.point(dev) 226 | let pointPath = URL(fileURLWithPath: entrypoint) 227 | .appendingPathComponent(point) 228 | .path 229 | var isDir : ObjCBool = true 230 | if !FileManager.default.fileExists(atPath: pointPath, isDirectory: &isDir) { 231 | try FileManager.default.createDirectory(atPath: pointPath, withIntermediateDirectories: true) 232 | } 233 | } 234 | 235 | fileprivate func point(_ dev: Bool) -> String { 236 | dev ? "dev" : "release" 237 | } 238 | 239 | private func webpack(dev: Bool = false, jsFile: String, destURL: URL) throws { 240 | let configPath = URL(fileURLWithPath: entrypoint).appendingPathComponent("config.js").path 241 | try createWebpackConfig(dev, at: configPath, jsFile: jsFile, dest: destURL.path) 242 | do { 243 | try executeWebpack() 244 | } catch { 245 | try? deleteWebpackConfig(at: configPath) 246 | throw error 247 | } 248 | try? deleteWebpackConfig(at: configPath) 249 | } 250 | 251 | private func executeWebpack() throws { 252 | let stdout = Pipe() 253 | let process = Process() 254 | process.launchPath = try Bash.which("webpack-cli") 255 | process.currentDirectoryPath = webberFolder 256 | process.arguments = ["--config", "entrypoint/config.js"] 257 | process.standardOutput = stdout 258 | 259 | let outHandle = stdout.fileHandleForReading 260 | 261 | process.launch() 262 | process.waitUntilExit() 263 | 264 | guard process.terminationStatus == 0 else { 265 | let data = outHandle.readDataToEndOfFile() 266 | guard data.count > 0, let error = String(data: data, encoding: .utf8) else { 267 | throw WebberError.error("Webpack command failed") 268 | } 269 | throw WebberError.error(error) 270 | } 271 | } 272 | 273 | private func createWebpackConfig(_ dev: Bool = false, at configPath: String, jsFile: String, dest destPath: String) throws { 274 | let point = self.point(dev) 275 | let mode = dev ? "development" : "production" 276 | let config = """ 277 | module.exports = { 278 | entry: "./entrypoint/\(point)/\(jsFile)", 279 | mode: "\(mode)", 280 | output: { 281 | filename: "\(jsFile)", 282 | path: "\(destPath)", 283 | }, 284 | }; 285 | """ 286 | guard let data = config.data(using: .utf8), FileManager.default.createFile(atPath: configPath, contents: data) else { 287 | throw WebberError.error("Unable to create webpack config file") 288 | } 289 | } 290 | 291 | private func deleteWebpackConfig(at path: String) throws { 292 | try FileManager.default.removeItem(atPath: path) 293 | } 294 | 295 | private func checkOrCreateCWD() throws { 296 | var isDir : ObjCBool = true 297 | if !FileManager.default.fileExists(atPath: cwd, isDirectory: &isDir) { 298 | try FileManager.default.createDirectory(atPath: cwd, withIntermediateDirectories: false) 299 | } 300 | } 301 | 302 | func installDependencies() throws { 303 | let npm = try Npm(console, cwd) 304 | try npm.addDevDependencies() 305 | try npm.install() 306 | try npm.installAllGlobalPackages() 307 | } 308 | 309 | private func checkOrCreatePackageFile() throws { 310 | var isDir : ObjCBool = false 311 | let path = URL(fileURLWithPath: cwd).appendingPathComponent("package.json").path 312 | if !FileManager.default.fileExists(atPath: path, isDirectory: &isDir) { 313 | let data = try JSONEncoder().encode(NewNpmPackage()) 314 | guard FileManager.default.createFile(atPath: path, contents: data) else { 315 | throw WebberError.error("Unable to create file: .webber/package.json") 316 | } 317 | } 318 | } 319 | 320 | private func checkOrCreateEntrypoint() throws { 321 | var isDir : ObjCBool = true 322 | if !FileManager.default.fileExists(atPath: entrypoint, isDirectory: &isDir) { 323 | try FileManager.default.createDirectory(atPath: entrypoint, withIntermediateDirectories: false) 324 | } 325 | } 326 | 327 | func moveWasmFile(dev: Bool = false, productName: String) throws { 328 | let originalWasm = URL(fileURLWithPath: context.dir.workingDirectory) 329 | .appendingPathComponent(".build") 330 | .appendingPathComponent(".wasi") 331 | .appendingPathComponent("wasm32-unknown-wasi") 332 | .appendingPathComponent(dev ? "debug" : "release") 333 | .appendingPathComponent("\(productName).wasm") 334 | let wasm = URL(fileURLWithPath: context.dir.workingDirectory) 335 | .appendingPathComponent(".webber") 336 | .appendingPathComponent(dev ? "dev" : "release") 337 | .appendingPathComponent("\(productName.lowercased()).wasm") 338 | try? FileManager.default.removeItem(at: wasm) 339 | try FileManager.default.copyItem(at: originalWasm, to: wasm) 340 | } 341 | 342 | func moveResources(dev: Bool = false) throws { 343 | var destFolder = URL(fileURLWithPath: context.dir.workingDirectory) 344 | .appendingPathComponent(".webber") 345 | .appendingPathComponent(dev ? "dev" : "release") 346 | if dev { 347 | destFolder = destFolder.appendingPathComponent(".resources") 348 | try? FileManager.default.removeItem(atPath: destFolder.path) 349 | } 350 | let mode = dev ? "debug" : "release" 351 | let buildFolder = URL(fileURLWithPath: context.dir.workingDirectory) 352 | .appendingPathComponent(".build") 353 | .appendingPathComponent(".wasi") 354 | .appendingPathComponent("wasm32-unknown-wasi") 355 | .appendingPathComponent(mode) 356 | guard let resourceFolders = try? FileManager.default.contentsOfDirectory(atPath: buildFolder.path).filter({ $0.hasSuffix(".resources") }) else { 357 | return 358 | } 359 | guard resourceFolders.count > 0 else { return } 360 | try? FileManager.default.createDirectory(atPath: destFolder.path, withIntermediateDirectories: false) 361 | resourceFolders.forEach { 362 | let fromFolderURL = buildFolder.appendingPathComponent($0) 363 | let modulePath = fromFolderURL.path.replacingOccurrences(of: context.dir.workingDirectory, with: "") 364 | let moduleName = modulePath.components(separatedBy: "/\(mode)/").last?.replacingOccurrences(of: ".resources", with: "") ?? "n/a (please report a bug)" 365 | if context.verbose { 366 | console.output([ 367 | ConsoleTextFragment( 368 | string: "📦 copying files from module: \(moduleName)", 369 | style: .init(color: .brightYellow, isBold: true) 370 | ) 371 | ]) 372 | } 373 | guard let files = try? FileManager.default.contentsOfDirectory(atPath: fromFolderURL.path), files.count > 0 else { return } 374 | for f in files { 375 | func processPath(_ components: [String]) { 376 | var fromURL = fromFolderURL 377 | for component in components { 378 | fromURL = fromURL.appendingPathComponent(component) 379 | } 380 | let fromFile = fromURL.path 381 | var toURL = destFolder 382 | for component in components { 383 | toURL = toURL.appendingPathComponent(component) 384 | } 385 | let toFile = toURL.path 386 | var isDir: ObjCBool = false 387 | guard FileManager.default.fileExists(atPath: fromFile, isDirectory: &isDir) else { return } 388 | if !isDir.boolValue { 389 | if context.verbose { 390 | let purePathFrom = fromFile.replacingOccurrences(of: context.dir.workingDirectory, with: "").replacingOccurrences(of: modulePath, with: "") 391 | let purePathTo = toFile.replacingOccurrences(of: context.dir.workingDirectory, with: "").replacingOccurrences(of: modulePath, with: "") 392 | var consoleOutput: [ConsoleTextFragment] = [ 393 | ConsoleTextFragment( 394 | string: "📑 copy \(purePathFrom)", 395 | style: .init(color: .brightMagenta, isBold: true) 396 | ), 397 | ConsoleTextFragment(string: " → ", style: .init(color: .brightCyan, isBold: true)), 398 | ConsoleTextFragment( 399 | string: "\(purePathTo)", 400 | style: .init(color: .brightYellow, isBold: true) 401 | ) 402 | ] 403 | if FileManager.default.fileExists(atPath: toFile) { 404 | consoleOutput.append(ConsoleTextFragment( 405 | string: " (⚠️ same file from another module has been overwritten)", 406 | style: .init(color: .brightRed, isBold: true) 407 | )) 408 | } 409 | console.output(ConsoleText(fragments: consoleOutput)) 410 | } 411 | try? FileManager.default.copyItem(atPath: fromFile, toPath: toFile) 412 | try? FileManager.default.removeItem(atPath: fromFile) 413 | } else { 414 | try? FileManager.default.createDirectory(atPath: toFile, withIntermediateDirectories: true) 415 | guard let files = try? FileManager.default.contentsOfDirectory(atPath: fromFile), files.count > 0 else { return } 416 | for f in files { 417 | processPath(components + [f]) 418 | } 419 | } 420 | } 421 | processPath([f]) 422 | } 423 | } 424 | } 425 | 426 | enum WebberError: Error, CustomStringConvertible { 427 | case error(String) 428 | 429 | var description: String { 430 | switch self { 431 | case .error(let description): return description 432 | } 433 | } 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /Sources/Webber/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // Webber 4 | // 5 | // Created by Mihael Isaev on 31.01.2021. 6 | // 7 | 8 | import ConsoleKit 9 | import Foundation 10 | 11 | let console: Console = Terminal() 12 | var input = CommandInput(arguments: CommandLine.arguments) 13 | var context = CommandContext(console: console, input: input) 14 | 15 | var commands = Commands(enableAutocomplete: false) 16 | commands.use(VersionCommand(), as: "version", isDefault: false) 17 | commands.use(ServeCommand(), as: "serve", isDefault: false) 18 | commands.use(ReleaseCommand(), as: "release", isDefault: false) 19 | commands.use(NewCommand(), as: "new", isDefault: false) 20 | 21 | do { 22 | let group = commands 23 | .group(help: "Hey there, this tool will help you to test and bundle your web app 🚀 details could be found on swifweb.com") 24 | try console.run(group, input: input) 25 | } catch let error { 26 | console.error("\(error)") 27 | exit(1) 28 | } 29 | --------------------------------------------------------------------------------