// // Copyright (c) 2007, Brian Frank and Andy Frank // Licensed under the Academic Free License version 3.0 // // History: // 21 Dec 07 Brian Frank Creation // using concurrent using web using inet using util ** ** Simple web server services HTTP/HTTPS requests to a top-level root WebMod. ** A given instance of WispService can be only be used through one ** start/stop lifecycle. ** ** Example: ** WispService { httpPort = 8080; root = MyWebMod() }.start ** const class WispService : Service { ** ** Standard log for web service ** internal static const Log log := Log.get("web") ** ** Which IpAddr to bind to or null for the default. ** const IpAddr? addr := null ** ** Well known TCP port for HTTP traffic. The port is enabled if non-null ** and disabled if null. ** const Int? httpPort := null ** ** Well known TCP port for HTTPS traffic. The port is enabled if non-null ** and disabled if null. If the http and https ports are both non-null ** then all http traffic will be redirected to the https port. ** const Int? httpsPort := null ** ** Root WebMod used to service requests. ** const WebMod root := WispDefaultRootMod() ** ** Pluggable interface for managing web session state. ** Default implementation stores sessions in main memory. ** const WispSessionStore sessionStore := MemWispSessionStore(this) ** ** Max number of threads which are used for concurrent ** web request processing. ** const Int maxThreads := 500 ** ** WebMod which is called on internal server error to return an 500 ** error response. The exception raised is available in 'req.stash["err"]'. ** The 'onService' method is called after clearing all headers and setting ** the response code to 500. The default error mod may be configured ** via 'errMod' property in etc/web/config.props. ** const WebMod errMod := initErrMod ** The `inet::SocketConfig` to use for creating sockets const SocketConfig socketConfig := SocketConfig.cur ** Return 'true' if service is successfully listening on registered port. @NoDoc Bool isListening() { isListeningRef.val } private const AtomicBool isListeningRef := AtomicBool(false) private static WebMod initErrMod() { try return (WebMod)Type.find(Pod.find("web").config("errMod", "wisp::WispDefaultErrMod")).make catch (Err e) log.err("Cannot init errMod", e) return WispDefaultErrMod() } ** ** Map of HTTP headers to include in every response. These are ** initialized from etc/web/config.props with the key "extraResHeaders" ** as a set of "key:value" pairs separated by semicolons. ** const [Str:Str] extraResHeaders := initExtraResHeaders private static Str:Str initExtraResHeaders() { acc := CaseInsensitiveMap<Str,Str>()//[:] { caseInsensitive = true } try parseExtraHeaders(acc, Pod.find("web").config("extraResHeaders", "")) catch (Err e) log.err("Cannot init resHeaders", e) return acc.toImmutable } ** Parse extra headers taking quoted values into account internal static Void parseExtraHeaders([Str:Str] acc, Str str) { // trim and remove trailing semicolon str = str.trim if (str.endsWith(";")) str = str[0..-2] if (str.isEmpty) return // split by semicolons taking into account quotes pairs := Str[,] s := 0 inStr := false for (i := 0; i<str.size; ++i) { ch := str[i] if (ch == '"') inStr = !inStr if (ch == ';' && !inStr) { pairs.add(str[s..<i].trim); s = i+1 } } if (s < str.size) pairs.add(str[s..-1].trim) // add to accumulator pairs.each |pair| { colon := pair.index(":") ?: throw Err("Missing colon: $pair") key := pair[0..<colon].trim val := pair[colon+1..-1].trim if (val.startsWith("\"") && val.endsWith("\"")) val = val[1..-2] if (key.isEmpty || val.isEmpty) throw Err("Invalid header: $pair") acc[key] = val } } ** ** Cookie name to use for built-in session management. ** Initialized from etc/web/config.props with the key "sessionCookieName" ** otherwise defaults to "fanws" ** const Str sessionCookieName := Pod.find("web").config("sessionCookieName", "fanws") ** ** Constructor with it-block ** new make(|This|? f := null) { if (f != null) f(this) if (httpPort == null && httpsPort == null) throw ArgErr("httpPort and httpsPort are both null. At least one port must be configured.") if (httpPort == httpsPort) throw ArgErr("httpPort '${httpPort}' cannot be the same as httpsPort '${httpsPort}'") if (httpPort != null && httpsPort != null) root = WispHttpsRedirectMod(this, root) listenerPool = ActorPool { it.name = "WispServiceListener" } httpListenerRef = AtomicRef() httpsListenerRef = AtomicRef() processorPool = ActorPool { it.name = "WispService"; it.maxThreads = this.maxThreads } } override Void onStart() { if (listenerPool.isStopped) throw Err("WispService is already stopped, use to new instance to restart") if (httpPort != null) Actor(listenerPool, |->| { listen(makeListener(httpListenerRef), httpPort) }).send(null) if (httpsPort != null) Actor(listenerPool, |->| { listen(makeListener(httpsListenerRef), httpsPort) }).send(null) sessionStore.onStart root.onStart } override Void onStop() { try root.onStop; catch (Err e) log.err("WispService stop root WebMod", e) try listenerPool.stop; catch (Err e) log.err("WispService stop listener pool", e) try closeListener(httpListenerRef); catch (Err e) log.err("WispService stop http listener socket", e) try closeListener(httpsListenerRef); catch (Err e) log.err("WispService stop https listener socket", e) try processorPool.stop; catch (Err e) log.err("WispService stop processor pool", e) try sessionStore.onStop; catch (Err e) log.err("WispService stop session store", e) } private Void closeListener(AtomicRef listenerRef) { listenerRef.val?->val?->close } internal Void listen(TcpListener listener, Int port) { portType := port == httpPort ? "http" : "https" // loop until we successfully bind to port while (true) { try { listener.bind(addr, port) break } catch (Err e) { log.err("WispService cannot bind to ${portType} port ${port}", e) Actor.sleep(10sec) } } log.info("${portType} started on port ${port}") isListeningRef.val = true // loop until stopped accepting incoming TCP connections while (!listenerPool.isStopped && !listener.isClosed) { try { socket := listener.accept WispActor(this).send(Unsafe(socket)) } catch (Err e) { if (!listenerPool.isStopped && !listener.isClosed) { log.err("WispService accept on ${portType} port ${port}", e) Actor.sleep(5sec) } } } // socket should be closed by onStop, but do it again to be really sure isListeningRef.val = false try { listener.close } catch {} log.info("${portType} stopped on port ${port}") } private TcpListener makeListener(AtomicRef storage) { try { // force reuseAddr cfg := this.socketConfig if (!cfg.reuseAddr) cfg = SocketConfig.makeCopy(cfg) { it.reuseAddr = true } TcpListener listener := TcpListener(cfg) storage.val = Unsafe(listener) return listener } catch (Err e) { log.err("Could not make listener", e) throw e } } internal const ActorPool listenerPool internal const AtomicRef httpListenerRef internal const AtomicRef httpsListenerRef internal const ActorPool processorPool @NoDoc static Void main() { WispService { httpPort = 8080 }.start Actor.sleep(Duration.maxVal) } ** Create instance for Test.setup easy to use via reflection (service is not started automatically) @NoDoc static WispService testSetup(WebMod root) { log.level = LogLevel.err return WispService { it.root = root it.httpPort = (10_000..60_000).random } } ** Teardown instance from tesetSetup @NoDoc static Void testTeardown(WispService service) { service.stop } } ************************************************************************** ** WispDefaultRootMod ************************************************************************** internal const class WispDefaultRootMod : WebMod { override Void onGet() { res.headers["Content-Type"] = "text/html; charset=utf-8" out := res.out out.html .head .title.w("Wisp").titleEnd .headEnd .body .h1.w("Wisp").h1End .p.w("Wisp is running!").pEnd .p.w("Currently there is no WebMod installed on this server.").pEnd .p.w("See <a href='https://fantom.org/doc/wisp/pod-doc.html'>wisp::pod-doc</a> to configure a WebMod for the server.").pEnd .bodyEnd .htmlEnd } } ************************************************************************** ** WispHttpsRedirectMod ************************************************************************** ** ** Redirects all http traffic to https ** internal const class WispHttpsRedirectMod : WebMod { new make(WispService service, WebMod root) { this.service = service this.root = root } override Void onService() { if (req.socket.localPort == service.httpPort) { redirectUri := `https://${req.absUri.host}:${service.httpsPort}${req.uri}` res.redirect(redirectUri) } else { root.onService } } const WispService service const WebMod root } ************************************************************************** ** WispDefaultErrMod ************************************************************************** const class WispDefaultErrMod : WebMod { override Void onService() { err := (Err)req.stash["err"] res.headers["Content-Type"] = "text/plain" str := "$res.statusCode INTERNAL SERVER ERROR\n\n$req.uri\n$err.traceToStr".replace("<", ">") res.out.print(str) } }