├── .gitignore ├── .spi.yml ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── VercelUI ├── CachePolicy.swift ├── GoogleAnalytics.swift ├── Router+View.swift ├── URL+Path.swift └── VercelUI.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [VercelUI] 5 | -------------------------------------------------------------------------------- /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" : "16f7e62c08c6969899ce6cc277041e868364e5cf", 9 | "version" : "1.19.0" 10 | } 11 | }, 12 | { 13 | "identity" : "async-kit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/vapor/async-kit.git", 16 | "state" : { 17 | "revision" : "eab9edff78e8ace20bd7cb6e792ab46d54f59ab9", 18 | "version" : "1.18.0" 19 | } 20 | }, 21 | { 22 | "identity" : "console-kit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/vapor/console-kit.git", 25 | "state" : { 26 | "revision" : "9a12000f4064a2bdc49068d7258292ec1bdc88fc", 27 | "version" : "4.7.0" 28 | } 29 | }, 30 | { 31 | "identity" : "javascriptkit", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/swiftwasm/JavaScriptKit.git", 34 | "state" : { 35 | "revision" : "096584bb6959f16d97daf3ebf52039f98c36fdbf", 36 | "version" : "0.18.0" 37 | } 38 | }, 39 | { 40 | "identity" : "multipart-kit", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/vapor/multipart-kit.git", 43 | "state" : { 44 | "revision" : "1adfd69df2da08f7931d4281b257475e32c96734", 45 | "version" : "4.5.4" 46 | } 47 | }, 48 | { 49 | "identity" : "opencombine", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/OpenCombine/OpenCombine.git", 52 | "state" : { 53 | "revision" : "8576f0d579b27020beccbccc3ea6844f3ddfc2c2", 54 | "version" : "0.14.0" 55 | } 56 | }, 57 | { 58 | "identity" : "opencombinejs", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/swiftwasm/OpenCombineJS.git", 61 | "state" : { 62 | "revision" : "e574e418ba468ff5c2d4c499eb56f108aeb4d2ba", 63 | "version" : "0.2.0" 64 | } 65 | }, 66 | { 67 | "identity" : "routing-kit", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/vapor/routing-kit.git", 70 | "state" : { 71 | "revision" : "e0539da5b60a60d7381f44cdcf04036f456cee2f", 72 | "version" : "4.8.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-algorithms", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-algorithms.git", 79 | "state" : { 80 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 81 | "version" : "1.0.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-argument-parser", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-argument-parser", 88 | "state" : { 89 | "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", 90 | "version" : "1.2.3" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-atomics", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/apple/swift-atomics.git", 97 | "state" : { 98 | "revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10", 99 | "version" : "1.1.0" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-aws-lambda-runtime", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/swift-server/swift-aws-lambda-runtime.git", 106 | "state" : { 107 | "revision" : "de730b240df25897c4b5b68889c178c994fd6817", 108 | "version" : "1.0.0-alpha.1" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-backtrace", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/swift-server/swift-backtrace.git", 115 | "state" : { 116 | "revision" : "f25620d5d05e2f1ba27154b40cafea2b67566956", 117 | "version" : "1.3.3" 118 | } 119 | }, 120 | { 121 | "identity" : "swift-benchmark", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/google/swift-benchmark", 124 | "state" : { 125 | "revision" : "8163295f6fe82356b0bcf8e1ab991645de17d096", 126 | "version" : "0.1.2" 127 | } 128 | }, 129 | { 130 | "identity" : "swift-collections", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/apple/swift-collections.git", 133 | "state" : { 134 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 135 | "version" : "1.0.4" 136 | } 137 | }, 138 | { 139 | "identity" : "swift-crypto", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/apple/swift-crypto", 142 | "state" : { 143 | "revision" : "60f13f60c4d093691934dc6cfdf5f508ada1f894", 144 | "version" : "2.6.0" 145 | } 146 | }, 147 | { 148 | "identity" : "swift-log", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/apple/swift-log.git", 151 | "state" : { 152 | "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", 153 | "version" : "1.5.3" 154 | } 155 | }, 156 | { 157 | "identity" : "swift-metrics", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/apple/swift-metrics.git", 160 | "state" : { 161 | "revision" : "971ba26378ab69c43737ee7ba967a896cb74c0d1", 162 | "version" : "2.4.1" 163 | } 164 | }, 165 | { 166 | "identity" : "swift-nio", 167 | "kind" : "remoteSourceControl", 168 | "location" : "https://github.com/apple/swift-nio.git", 169 | "state" : { 170 | "revision" : "cf281631ff10ec6111f2761052aa81896a83a007", 171 | "version" : "2.58.0" 172 | } 173 | }, 174 | { 175 | "identity" : "swift-nio-extras", 176 | "kind" : "remoteSourceControl", 177 | "location" : "https://github.com/apple/swift-nio-extras.git", 178 | "state" : { 179 | "revision" : "0e0d0aab665ff1a0659ce75ac003081f2b1c8997", 180 | "version" : "1.19.0" 181 | } 182 | }, 183 | { 184 | "identity" : "swift-nio-http2", 185 | "kind" : "remoteSourceControl", 186 | "location" : "https://github.com/apple/swift-nio-http2.git", 187 | "state" : { 188 | "revision" : "a8ccf13fa62775277a5d56844878c828bbb3be1a", 189 | "version" : "1.27.0" 190 | } 191 | }, 192 | { 193 | "identity" : "swift-nio-ssl", 194 | "kind" : "remoteSourceControl", 195 | "location" : "https://github.com/apple/swift-nio-ssl.git", 196 | "state" : { 197 | "revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9", 198 | "version" : "2.25.0" 199 | } 200 | }, 201 | { 202 | "identity" : "swift-nio-transport-services", 203 | "kind" : "remoteSourceControl", 204 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 205 | "state" : { 206 | "revision" : "e7403c35ca6bb539a7ca353b91cc2d8ec0362d58", 207 | "version" : "1.19.0" 208 | } 209 | }, 210 | { 211 | "identity" : "swift-numerics", 212 | "kind" : "remoteSourceControl", 213 | "location" : "https://github.com/apple/swift-numerics", 214 | "state" : { 215 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 216 | "version" : "1.0.2" 217 | } 218 | }, 219 | { 220 | "identity" : "tokamak", 221 | "kind" : "remoteSourceControl", 222 | "location" : "https://github.com/TokamakUI/Tokamak", 223 | "state" : { 224 | "revision" : "f1cbfcf073e2675566b0e9aa337441357d40d88a", 225 | "version" : "0.11.1" 226 | } 227 | }, 228 | { 229 | "identity" : "vapor", 230 | "kind" : "remoteSourceControl", 231 | "location" : "https://github.com/vapor/vapor", 232 | "state" : { 233 | "revision" : "1bb4a2ed94bec7a92f92e82896408c785d068f5c", 234 | "version" : "4.79.0" 235 | } 236 | }, 237 | { 238 | "identity" : "vercel", 239 | "kind" : "remoteSourceControl", 240 | "location" : "https://github.com/swift-cloud/Vercel", 241 | "state" : { 242 | "revision" : "61f4815cdacf87203a861c73921549076c2a685b", 243 | "version" : "1.15.2" 244 | } 245 | }, 246 | { 247 | "identity" : "websocket-kit", 248 | "kind" : "remoteSourceControl", 249 | "location" : "https://github.com/vapor/websocket-kit.git", 250 | "state" : { 251 | "revision" : "53fe0639a98903858d0196b699720decb42aee7b", 252 | "version" : "2.14.0" 253 | } 254 | } 255 | ], 256 | "version" : 2 257 | } 258 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "VercelUI", 7 | platforms: [ 8 | .macOS(.v12), 9 | .iOS(.v15), 10 | .tvOS(.v15), 11 | .watchOS(.v8) 12 | ], 13 | products: [ 14 | .library(name: "VercelUI", targets: ["VercelUI"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/swift-cloud/Vercel", from: "1.15.2"), 18 | .package(url: "https://github.com/TokamakUI/Tokamak", from: "0.11.1") 19 | ], 20 | targets: [ 21 | .target(name: "VercelUI", dependencies: [ 22 | "Vercel", 23 | .product(name: "TokamakStaticHTML", package: "Tokamak") 24 | ]) 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VercelUI 2 | 3 | Build server side rendered webpages in SwiftUI 4 | 5 | ```swift 6 | import VercelUI 7 | 8 | struct IndexPage: View { 9 | 10 | @Environment(\.request) var req 11 | 12 | var body: some View { 13 | VStack { 14 | Text("Hello, Swift") 15 | .font(.title) 16 | 17 | Text("This is a server rendered SwiftUI website") 18 | .font(.subheadline) 19 | 20 | Text("Your ip address \(req.clientIPAddress)") 21 | } 22 | } 23 | } 24 | 25 | @main 26 | struct App: ExpressHandler { 27 | 28 | static func configure() async throws -> Router { 29 | Router() 30 | .get("/", IndexPage()) 31 | } 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /Sources/VercelUI/CachePolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CachePolicy.swift 3 | // 4 | // 5 | // Created by Andrew Barba on 1/23/23. 6 | // 7 | 8 | public struct CachePolicy { 9 | 10 | public let maxAge: Int 11 | 12 | public let staleWhileRevalidate: Int 13 | 14 | private init(maxAge: Int, staleWhileRevalidate: Int) { 15 | self.maxAge = maxAge 16 | self.staleWhileRevalidate = staleWhileRevalidate 17 | } 18 | 19 | public static func maxAge(_ ttl: Int, staleWhileRevalidate swr: Int = 0) -> Self { 20 | return .init(maxAge: ttl, staleWhileRevalidate: swr) 21 | } 22 | 23 | public static var none: Self { 24 | return .init(maxAge: 0, staleWhileRevalidate: 0) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/VercelUI/GoogleAnalytics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GoogleAnalytics.swift 3 | // 4 | // 5 | // Created by Andrew Barba on 1/23/23. 6 | // 7 | 8 | import TokamakStaticHTML 9 | 10 | public struct GoogleAnalytics: View { 11 | 12 | public let id: String 13 | 14 | public init(id: String) { 15 | self.id = id 16 | } 17 | 18 | public var body: some View { 19 | Group { 20 | HTML("script", [ 21 | "async": "true", 22 | "src": "https://www.googletagmanager.com/gtag/js?id=\(id)" 23 | ]) 24 | HTML("script", content: 25 | """ 26 | window.dataLayer = window.dataLayer || []; 27 | function gtag(){dataLayer.push(arguments);} 28 | gtag('js', new Date()); 29 | gtag('config', '\(id)'); 30 | """ 31 | ) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/VercelUI/Router+View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Router+View.swift 3 | // 4 | // 5 | // Created by Andrew Barba on 1/23/23. 6 | // 7 | 8 | extension Router { 9 | 10 | public typealias ViewHandler = (Request, Response) async throws -> T 11 | 12 | @discardableResult 13 | public func get( 14 | _ path: String, 15 | _ handler: @autoclosure @escaping () -> T, 16 | cachePolicy: CachePolicy = .none 17 | ) -> Self { 18 | return get(path, cachePolicy: cachePolicy) { _, _ in handler() } 19 | } 20 | 21 | @discardableResult 22 | public func get( 23 | _ path: String, 24 | cachePolicy: CachePolicy = .none, 25 | _ handler: @escaping ViewHandler 26 | ) -> Self { 27 | return get(path, render(cachePolicy: cachePolicy, handler)) 28 | } 29 | 30 | @discardableResult 31 | public func post( 32 | _ path: String, 33 | _ handler: @autoclosure @escaping () -> T, 34 | cachePolicy: CachePolicy = .none 35 | ) -> Self { 36 | return post(path, cachePolicy: cachePolicy) { _, _ in handler() } 37 | } 38 | 39 | @discardableResult 40 | public func post( 41 | _ path: String, 42 | cachePolicy: CachePolicy = .none, 43 | _ handler: @escaping ViewHandler 44 | ) -> Self { 45 | return post(path, render(cachePolicy: cachePolicy, handler)) 46 | } 47 | 48 | private func render( 49 | cachePolicy: CachePolicy = .none, 50 | _ handler: @escaping ViewHandler 51 | ) -> Router.Handler { 52 | return { req, res in 53 | RequestKey.defaultValue = req 54 | ResponseKey.defaultValue = res 55 | let view = try await handler(req, res) 56 | .environment(\.request, req) 57 | .environment(\.response, res) 58 | let html = StaticHTMLRenderer(view).render() 59 | return res 60 | .status(.ok) 61 | .cacheControl(maxAge: cachePolicy.maxAge, staleWhileRevalidate: cachePolicy.staleWhileRevalidate) 62 | .send(html: html) 63 | } 64 | } 65 | } 66 | 67 | // MARK: - Request Environment 68 | 69 | private struct RequestKey: EnvironmentKey { 70 | static var defaultValue: Request? 71 | } 72 | 73 | extension EnvironmentValues { 74 | public var request: Request { 75 | get { self[RequestKey.self] ?? RequestKey.defaultValue! } 76 | set { self[RequestKey.self] = newValue } 77 | } 78 | } 79 | 80 | // MARK: - Response Environment 81 | 82 | private struct ResponseKey: EnvironmentKey { 83 | static var defaultValue: Response? 84 | } 85 | 86 | extension EnvironmentValues { 87 | public var response: Response { 88 | get { self[ResponseKey.self] ?? ResponseKey.defaultValue! } 89 | set { self[ResponseKey.self] = newValue } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/VercelUI/URL+Path.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Path.swift 3 | // 4 | // 5 | // Created by Andrew Barba on 1/23/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | 12 | public static func path(_ location: String) -> URL { 13 | return .init(string: location.starts(with: "/") ? location : "/\(location)")! 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/VercelUI/VercelUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VercelUI.swift 3 | // 4 | // 5 | // Created by Andrew Barba on 1/23/23. 6 | // 7 | 8 | @_exported import Vercel 9 | @_exported import TokamakCore 10 | @_exported import TokamakStaticHTML 11 | --------------------------------------------------------------------------------