├── .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 |
4 |
5 |
6 |
7 |
8 |
9 |
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 |
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 |
--------------------------------------------------------------------------------