Browse Source

add livereload supports

tags/v0.9.0
jimzhan 9 years ago
parent
commit
a02aa67d13
10 changed files with 776 additions and 7 deletions
  1. +92
    -0
      cmd/rex/main.go
  2. +108
    -0
      cmd/rex/new.go
  3. +164
    -0
      cmd/rex/run.go
  4. +109
    -0
      modules/livereload.go
  5. +28
    -0
      modules/livereload/javascript.go
  6. +147
    -0
      modules/livereload/livereload.go
  7. +42
    -0
      modules/livereload/message.go
  8. +79
    -0
      modules/livereload/tunnel.go
  9. +6
    -6
      rex.go
  10. +1
    -1
      router.go

+ 92
- 0
cmd/rex/main.go View File

@@ -0,0 +1,92 @@
/* ----------------------------------------------------------------------
* ______ ___ __
* / ____/___ / | ____ __ ___ __/ /_ ___ ________
* / / __/ __ \/ /| | / __ \/ / / / | /| / / __ \/ _ \/ ___/ _ \
* / /_/ / /_/ / ___ |/ / / / /_/ /| |/ |/ / / / / __/ / / __/
* \____/\____/_/ |_/_/ /_/\__. / |__/|__/_/ /_/\___/_/ \___/
* /____/
*
* (C) Copyright 2015 GoAnywhere (http://goanywhere.io).
* ----------------------------------------------------------------------
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------*/

package main

import (
"fmt"
"os"
"runtime"

"github.com/codegangsta/cli"
"github.com/goanywhere/x/crypto"
)

var (
cwd string
)

var commands = []cli.Command{
// rex project template supports
/*
*{
* Name: "new",
* Usage: "create a skeleton web application project",
* Action: New,
*},
*/
// rex server (with livereload supports)
{
Name: "run",
Usage: "start application server with livereload supports",
Action: Run,
Flags: []cli.Flag{
cli.IntFlag{
Name: "port",
Value: 5000,
Usage: "port to run the application server",
},
},
},
// helper to generate a secret key.
{
Name: "secret",
Usage: "generate a new application secret key",
Action: func(ctx *cli.Context) {
fmt.Println(crypto.Random(ctx.Int("length")))
},
Flags: []cli.Flag{
cli.IntFlag{
Name: "length",
Value: 64,
Usage: "length of the secret key",
},
},
},
}

func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
cmd := cli.NewApp()
cmd.Name = "rex"
cmd.Usage = "manage rex application"
cmd.Version = "0.9.0"
cmd.Author = "GoAnywhere"
cmd.Email = "code@goanywhere.io"
cmd.Commands = commands
cmd.Run(os.Args)
}

func init() {
cwd, _ = os.Getwd()
}

+ 108
- 0
cmd/rex/new.go View File

@@ -0,0 +1,108 @@
/* ----------------------------------------------------------------------
* ______ ___ __
* / ____/___ / | ____ __ ___ __/ /_ ___ ________
* / / __/ __ \/ /| | / __ \/ / / / | /| / / __ \/ _ \/ ___/ _ \
* / /_/ / /_/ / ___ |/ / / / /_/ /| |/ |/ / / / / __/ / / __/
* \____/\____/_/ |_/_/ /_/\__. / |__/|__/_/ /_/\___/_/ \___/
* /____/
*
* (C) Copyright 2015 GoAnywhere (http://goanywhere.io).
* ----------------------------------------------------------------------
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------*/
package main

import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"

log "github.com/Sirupsen/logrus"
"github.com/codegangsta/cli"

"github.com/goanywhere/x/cmd"
"github.com/goanywhere/x/crypto"
)

const endpoint = "https://github.com/goanywhere/rex"

var secrets = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*(-_+)")

type project struct {
name string
root string
}

func (self *project) create() {
cmd.Prompt("Fetching project template\n")
var done = make(chan bool)
cmd.Loading(done)

command := exec.Command("git", "clone", "-b", "scaffolds", endpoint, self.name)
command.Dir = cwd
if e := command.Run(); e == nil {
self.root = filepath.Join(cwd, self.name)
// create dotenv under project's root.
filename := filepath.Join(self.root, ".env")
if dotenv, err := os.Create(filename); err == nil {
defer dotenv.Close()
buffer := bufio.NewWriter(dotenv)
buffer.WriteString(fmt.Sprintf("export Rex_Secret_Keys=\"%s, %s\"\n", crypto.Random(64), crypto.Random(32)))
buffer.Flush()
// close loading here as nodejs will take over prompt.
done <- true
// initialize project packages via nodejs.
self.setup()
}
os.RemoveAll(filepath.Join(self.root, ".git"))
os.Remove(filepath.Join(self.root, "README.md"))
} else {
// loading prompt should be closed in anyway.
done <- true
}
}

func (self *project) setup() {
if e := exec.Command("npm", "-v").Run(); e == nil {
cmd.Prompt("Fetching project dependencies\n")
command := exec.Command("npm", "install")
command.Dir = self.root
command.Stdout = os.Stdout
command.Stderr = os.Stderr
command.Run()
} else {
log.Fatalf("Failed to setup project dependecies: nodejs is missing.")
}
}

func New(context *cli.Context) {
var pattern *regexp.Regexp
if runtime.GOOS == "windows" {
pattern = regexp.MustCompile(`\A(?:[0-9a-zA-Z\.\_\-]+\\?)+\z`)
} else {
pattern = regexp.MustCompile(`\A(?:[0-9a-zA-Z\.\_\-]+\/?)+\z`)
}

args := context.Args()
if len(args) != 1 || !pattern.MatchString(args[0]) {
log.Fatal("Please provide a valid project name/path")
} else {
project := new(project)
project.name = args[0]
project.create()
}
}

+ 164
- 0
cmd/rex/run.go View File

@@ -0,0 +1,164 @@
/* ----------------------------------------------------------------------
* ______ ___ __
* / ____/___ / | ____ __ ___ __/ /_ ___ ________
* / / __/ __ \/ /| | / __ \/ / / / | /| / / __ \/ _ \/ ___/ _ \
* / /_/ / /_/ / ___ |/ / / / /_/ /| |/ |/ / / / / __/ / / __/
* \____/\____/_/ |_/_/ /_/\__. / |__/|__/_/ /_/\___/_/ \___/
* /____/
*
* (C) Copyright 2015 GoAnywhere (http://goanywhere.io).
* ----------------------------------------------------------------------
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------*/
package main

import (
"fmt"
"go/build"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"syscall"

log "github.com/Sirupsen/logrus"
"github.com/codegangsta/cli"

"github.com/goanywhere/rex/internal"
"github.com/goanywhere/rex/modules/livereload"

"github.com/goanywhere/x/cmd"
"github.com/goanywhere/x/env"
"github.com/goanywhere/x/fs"
)

var (
port int
watchList = regexp.MustCompile(`\.(go|html|atom|rss|xml)$`)
)

type app struct {
dir string
binary string
args []string

task string // script for npm.
}

// build compiles the application into rex-bin executable
// to run & optionally compiles static assets using npm.
func (self *app) build() {
var done = make(chan bool)
cmd.Loading(done)

// * try build the application into rex-bin(.exe)
command := exec.Command("go", "build", "-o", self.binary)
command.Dir = self.dir
if e := command.Run(); e != nil {
log.Fatalf("Failed to compile the application: %v", e)
}

done <- true
}

// run executes the runnerable executable under package binary root.
func (self *app) run() (gorun chan bool) {
gorun = make(chan bool)
go func() {
var proc *os.Process
for start := range gorun {
if proc != nil {
// try soft kill before hard one.
if err := proc.Signal(os.Interrupt); err != nil {
proc.Kill()
}
proc.Wait()
}
if !start {
continue
}
command := exec.Command(self.binary, fmt.Sprintf("--port=%d", port))
command.Dir = self.dir
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Start(); err != nil {
log.Fatalf("Failed to start the process: %v\n", err)
}
proc = command.Process
}
}()
return
}

func (self *app) rerun(gorun chan bool) {
self.build()
livereload.Reload()
gorun <- true
}

// Starts activates the application server along with
// a daemon watcher for monitoring the files's changes.
func (self *app) Start() {
// ctrl-c: listen removes binary package when application stopped.
channel := make(chan os.Signal, 2)
signal.Notify(channel, os.Interrupt, syscall.SIGTERM)
go func() {
<-channel
// remove the binary package on stop.
os.Remove(self.binary)
os.Exit(1)
}()

// start waiting the signal to start running.
var gorun = self.run()
self.build()
gorun <- true

watcher := fs.NewWatcher(self.dir)
log.Infof("Start watching: %s", self.dir)
watcher.Add(watchList, func(filename string) {
relpath, _ := filepath.Rel(self.dir, filename)
log.Infof("Changes on %s detected", relpath)
self.rerun(gorun)
})
watcher.Start()
}

// Run creates an executable application package with livereload supports.
func Run(ctx *cli.Context) {
port = ctx.Int("port")

if len(ctx.Args()) == 1 {
cwd = ctx.Args()[0]
}
if abspath, err := filepath.Abs(cwd); err == nil {
env.Set(internal.Root, abspath)
} else {
log.Fatalf("Failed to retrieve the directory: %v", err)
}

pkg, err := build.ImportDir(cwd, build.AllowBinary)
if err != nil || pkg.Name != "main" {
log.Fatalf("No buildable Go source files found")
}
app := new(app)
app.dir = cwd
app.binary = filepath.Join(os.TempDir(), "rex-bin")
if runtime.GOOS == "windows" {
app.binary += ".exe"
}
app.task = ctx.String("task")
app.Start()
}

+ 109
- 0
modules/livereload.go View File

@@ -0,0 +1,109 @@
/* ----------------------------------------------------------------------
* ______ ___ __
* / ____/___ / | ____ __ ___ __/ /_ ___ ________
* / / __/ __ \/ /| | / __ \/ / / / | /| / / __ \/ _ \/ ___/ _ \
* / /_/ / /_/ / ___ |/ / / / /_/ /| |/ |/ / / / / __/ / / __/
* \____/\____/_/ |_/_/ /_/\__. / |__/|__/_/ /_/\___/_/ \___/
* /____/
*
* (C) Copyright 2015 GoAnywhere (http://goanywhere.io).
* ----------------------------------------------------------------------
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------*/
package modules

import (
"bytes"
"compress/gzip"
"compress/zlib"
"fmt"
"io"
"net/http"
"regexp"
"strings"

"github.com/goanywhere/rex/modules/livereload"
"github.com/goanywhere/x/env"
)

type writer struct {
http.ResponseWriter
host string
}

func (self *writer) addJavaScript(data []byte) []byte {
javascript := fmt.Sprintf(`<script src="//%s%s"></script>
</head>`, self.host, livereload.URL.JavaScript)
return regexp.MustCompile(`</head>`).ReplaceAll(data, []byte(javascript))
}

func (self *writer) Write(data []byte) (size int, e error) {
if strings.Contains(self.Header().Get("Content-Type"), "html") {
var encoding = self.Header().Get("Content-Encoding")
if encoding == "" {
data = self.addJavaScript(data)
} else {
var reader io.ReadCloser
var buffer *bytes.Buffer = new(bytes.Buffer)

if encoding == "gzip" {
// decode to add javascript reference.
reader, _ = gzip.NewReader(bytes.NewReader(data))
io.Copy(buffer, reader)
output := self.addJavaScript(buffer.Bytes())
reader.Close()
buffer.Reset()
// encode back to HTML with added javascript reference.
writer := gzip.NewWriter(buffer)
writer.Write(output)
writer.Close()
data = buffer.Bytes()

} else if encoding == "deflate" {
// decode to add javascript reference.
reader, _ = zlib.NewReader(bytes.NewReader(data))
io.Copy(buffer, reader)
output := self.addJavaScript(buffer.Bytes())
reader.Close()
buffer.Reset()
// encode back to HTML with added javascript reference.
writer := zlib.NewWriter(buffer)
writer.Write(output)
writer.Close()
data = buffer.Bytes()
}
}
}
return self.ResponseWriter.Write(data)
}

func LiveReload(next http.Handler) http.Handler {
// ONLY run this under debug mode.
if !env.Bool("DEBUG", true) {
return next
}
livereload.Start()
fn := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == livereload.URL.WebSocket {
livereload.ServeWebSocket(w, r)

} else if r.URL.Path == livereload.URL.JavaScript {
livereload.ServeJavaScript(w, r)

} else {
writer := &writer{w, r.Host}
next.ServeHTTP(writer, r)
}
}
return http.HandlerFunc(fn)
}

+ 28
- 0
modules/livereload/javascript.go
File diff suppressed because it is too large
View File


+ 147
- 0
modules/livereload/livereload.go View File

@@ -0,0 +1,147 @@
/* ----------------------------------------------------------------------
* ______ ___ __
* / ____/___ / | ____ __ ___ __/ /_ ___ ________
* / / __/ __ \/ /| | / __ \/ / / / | /| / / __ \/ _ \/ ___/ _ \
* / /_/ / /_/ / ___ |/ / / / /_/ /| |/ |/ / / / / __/ / / __/
* \____/\____/_/ |_/_/ /_/\__. / |__/|__/_/ /_/\___/_/ \___/
* /____/
*
* (C) Copyright 2015 GoAnywhere (http://goanywhere.io).
* ----------------------------------------------------------------------
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------*/
package livereload

import (
"encoding/json"
"net/http"
"sync"

"github.com/gorilla/websocket"
)

/* ----------------------------------------------------------------------
* WebSocket Server
* ----------------------------------------------------------------------*/
var (
once sync.Once

broadcast chan []byte
tunnels map[*tunnel]bool

in chan *tunnel
out chan *tunnel

mutex sync.RWMutex

upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}

URL = struct {
WebSocket string
JavaScript string
}{
WebSocket: "/livereload",
JavaScript: "/livereload.js",
}
)

// Alert sends a notice message to browser's livereload.js.
func Alert(message string) {
go func() {
var bytes, _ = json.Marshal(&alert{
Command: "alert",
Message: message,
})
broadcast <- bytes
}()
}

// Reload sends a reload message to browser's livereload.js.
func Reload() {
go func() {
var bytes, _ = json.Marshal(&reload{
Command: "reload",
Path: URL.WebSocket,
LiveCSS: true,
})
broadcast <- bytes
}()
}

// run watches/dispatches all tunnel & tunnel messages.
func run() {
for {
select {
case tunnel := <-in:
mutex.Lock()
defer mutex.Unlock()
tunnels[tunnel] = true

case tunnel := <-out:
mutex.Lock()
defer mutex.Unlock()
delete(tunnels, tunnel)
close(tunnel.message)

case m := <-broadcast:
for tunnel := range tunnels {
select {
case tunnel.message <- m:
default:
mutex.Lock()
defer mutex.Unlock()
delete(tunnels, tunnel)
close(tunnel.message)
}
}
}
}
}

// Serve serves as a livereload server for accepting I/O tunnel messages.
func ServeWebSocket(w http.ResponseWriter, r *http.Request) {
var socket, err = upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
tunnel := new(tunnel)
tunnel.socket = socket
tunnel.message = make(chan []byte, 256)

in <- tunnel
defer func() { out <- tunnel }()

tunnel.connect()
}

// ServeJavaScript serves livereload.js for browser.
func ServeJavaScript(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
w.Write(javascript)
}

// Start activates livereload server for accepting tunnel messages.
func Start() {
once.Do(func() {
broadcast = make(chan []byte)
tunnels = make(map[*tunnel]bool)

in = make(chan *tunnel)
out = make(chan *tunnel)

go run()
})
}

+ 42
- 0
modules/livereload/message.go View File

@@ -0,0 +1,42 @@
/* ----------------------------------------------------------------------
* ______ ___ __
* / ____/___ / | ____ __ ___ __/ /_ ___ ________
* / / __/ __ \/ /| | / __ \/ / / / | /| / / __ \/ _ \/ ___/ _ \
* / /_/ / /_/ / ___ |/ / / / /_/ /| |/ |/ / / / / __/ / / __/
* \____/\____/_/ |_/_/ /_/\__. / |__/|__/_/ /_/\___/_/ \___/
* /____/
*
* (C) Copyright 2015 GoAnywhere (http://goanywhere.io).
* ----------------------------------------------------------------------
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------*/
package livereload

type (
hello struct {
Command string `json:"command"`
Protocols []string `json:"protocols"`
ServerName string `json:"serverName"`
}

alert struct {
Command string `json:"command"`
Message string `json:"message"`
}

reload struct {
Command string `json:"command"`
Path string `json:"path"` // as full as possible/known, absolute path preferred, file name only is OK
LiveCSS bool `json:"liveCSS"` // false to disable live CSS refresh
}
)

+ 79
- 0
modules/livereload/tunnel.go View File

@@ -0,0 +1,79 @@
/* ----------------------------------------------------------------------
* ______ ___ __
* / ____/___ / | ____ __ ___ __/ /_ ___ ________
* / / __/ __ \/ /| | / __ \/ / / / | /| / / __ \/ _ \/ ___/ _ \
* / /_/ / /_/ / ___ |/ / / / /_/ /| |/ |/ / / / / __/ / / __/
* \____/\____/_/ |_/_/ /_/\__. / |__/|__/_/ /_/\___/_/ \___/
* /____/
*
* (C) Copyright 2015 GoAnywhere (http://goanywhere.io).
* ----------------------------------------------------------------------
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------*/
package livereload

import (
"encoding/json"
"regexp"

"github.com/gorilla/websocket"
)

var regexHandshake = regexp.MustCompile(`"command"\s*:\s*"hello"`)

/* ----------------------------------------------------------------------
* WebSocket Server Tunnel
* ----------------------------------------------------------------------*/
type tunnel struct {
socket *websocket.Conn
message chan []byte
}

// connect reads/writes message for livereload.js.
func (self *tunnel) connect() {
// ***********************
// WebSocket Tunnel#Write
// ***********************
go func() {
for message := range self.message {
if err := self.socket.WriteMessage(websocket.TextMessage, message); err != nil {
break
} else {
if regexHandshake.Find(message) != nil {
// Keep the tunnel opened after handshake(hello command).
Reload()
}
}
}
self.socket.Close()
}()
// ***********************
// WebSocket Tunnel#Read
// ***********************
for {
_, message, err := self.socket.ReadMessage()
if err != nil {
break
}
switch true {
case regexHandshake.Find(message) != nil:
var bytes, _ = json.Marshal(&hello{
Command: "hello",
Protocols: []string{"http://livereload.com/protocols/official-7"},
ServerName: "Rex#Livereload",
})
self.message <- bytes
}
}
self.socket.Close()
}

+ 6
- 6
rex.go View File

@@ -51,38 +51,38 @@ var (
// Get is a shortcut for mux.HandleFunc(pattern, handler).Methods("GET"),
// it also fetch the full function name of the handler (with package) to name the route.
func Get(pattern string, handler interface{}) {
DefaultMux.register("GET", pattern, handler)
DefaultMux.Get(pattern, handler)
}

// Head is a shortcut for mux.HandleFunc(pattern, handler).Methods("HEAD")
// it also fetch the full function name of the handler (with package) to name the route.
func Head(pattern string, handler interface{}) {
DefaultMux.register("HEAD", pattern, handler)
DefaultMux.Head(pattern, handler)
}

// Options is a shortcut for mux.HandleFunc(pattern, handler).Methods("OPTIONS")
// it also fetch the full function name of the handler (with package) to name the route.
// NOTE method OPTIONS is **NOT** cachable, beware of what you are going to do.
func Options(pattern string, handler interface{}) {
DefaultMux.register("OPTIONS", pattern, handler)
DefaultMux.Options(pattern, handler)
}

// Post is a shortcut for mux.HandleFunc(pattern, handler).Methods("POST")
// it also fetch the full function name of the handler (with package) to name the route.
func Post(pattern string, handler interface{}) {
DefaultMux.register("POST", pattern, handler)
DefaultMux.Post(pattern, handler)
}

// Put is a shortcut for mux.HandleFunc(pattern, handler).Methods("PUT")
// it also fetch the full function name of the handler (with package) to name the route.
func Put(pattern string, handler interface{}) {
DefaultMux.register("PUT", pattern, handler)
DefaultMux.Put(pattern, handler)
}

// Delete is a shortcut for mux.HandleFunc(pattern, handler).Methods("DELETE")
// it also fetch the full function name of the handler (with package) to name the route.
func Delete(pattern string, handler interface{}) {
DefaultMux.register("Delete", pattern, handler)
DefaultMux.Delete(pattern, handler)
}

// Group creates a new application group under the given path.

+ 1
- 1
router.go View File

@@ -44,7 +44,7 @@ type Router struct {
func New() *Router {
return &Router{
mod: new(internal.Module),
mux: mux.NewRouter(),
mux: mux.NewRouter().StrictSlash(true),
}
}


Loading…
Cancel
Save