//
// 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
**
** 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 := 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 = cfg.copy { 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'>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)
}
}