62 |
114 | {{ end }}
115 |
--------------------------------------------------------------------------------
/example_test.go:
--------------------------------------------------------------------------------
1 | package live
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "html/template"
7 | "io"
8 | "net/http"
9 | )
10 |
11 | // Model of our thermostat.
12 | type ThermoModel struct {
13 | C float32
14 | }
15 |
16 | // Helper function to get the model from the socket data.
17 | func NewThermoModel(s *Socket) *ThermoModel {
18 | m, ok := s.Assigns().(*ThermoModel)
19 | // If we haven't already initialised set up.
20 | if !ok {
21 | m = &ThermoModel{
22 | C: 19.5,
23 | }
24 | }
25 | return m
26 | }
27 |
28 | // thermoMount initialises the thermostat state. Data returned in the mount function will
29 | // automatically be assigned to the socket.
30 | func thermoMount(ctx context.Context, s *Socket) (any, error) {
31 | return NewThermoModel(s), nil
32 | }
33 |
34 | // tempUp on the temp up event, increase the thermostat temperature by .1 C. An EventHandler function
35 | // is called with the original request context of the socket, the socket itself containing the current
36 | // state and and params that came from the event. Params contain query string parameters and any
37 | // `live-value-` bindings.
38 | func tempUp(ctx context.Context, s *Socket, p Params) (any, error) {
39 | model := NewThermoModel(s)
40 | model.C += 0.1
41 | return model, nil
42 | }
43 |
44 | // tempDown on the temp down event, decrease the thermostat temperature by .1 C.
45 | func tempDown(ctx context.Context, s *Socket, p Params) (any, error) {
46 | model := NewThermoModel(s)
47 | model.C -= 0.1
48 | return model, nil
49 | }
50 |
51 | // Example shows a simple temperature control using the
52 | // "live-click" event.
53 | func Example() {
54 |
55 | // Setup the handler.
56 | h := NewHandler()
57 |
58 | // Mount function is called on initial HTTP load and then initial web
59 | // socket connection. This should be used to create the initial state,
60 | // the socket Connected func will be true if the mount call is on a web
61 | // socket connection.
62 | h.MountHandler = thermoMount
63 |
64 | // Provide a render function. Here we are doing it manually, but there is a
65 | // provided WithTemplateRenderer which can be used to work with `html/template`
66 | h.RenderHandler = func(ctx context.Context, data *RenderContext) (io.Reader, error) {
67 | tmpl, err := template.New("thermo").Parse(`
68 |
{{.Assigns.C}}
69 |
70 |
71 |
72 |
73 | `)
74 | if err != nil {
75 | return nil, err
76 | }
77 | var buf bytes.Buffer
78 | if err := tmpl.Execute(&buf, data); err != nil {
79 | return nil, err
80 | }
81 | return &buf, nil
82 | }
83 |
84 | // This handles the `live-click="temp-up"` button. First we load the model from
85 | // the socket, increment the temperature, and then return the new state of the
86 | // model. Live will now calculate the diff between the last time it rendered and now,
87 | // produce a set of diffs and push them to the browser to update.
88 | h.HandleEvent("temp-up", tempUp)
89 |
90 | // This handles the `live-click="temp-down"` button.
91 | h.HandleEvent("temp-down", tempDown)
92 |
93 | http.Handle("/thermostat", NewHttpHandler(context.Background(), h))
94 |
95 | // This serves the JS needed to make live work.
96 | http.Handle("/live.js", Javascript{})
97 |
98 | http.ListenAndServe(":8080", nil)
99 | }
100 |
--------------------------------------------------------------------------------
/socketstate.go:
--------------------------------------------------------------------------------
1 | package live
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "time"
7 | )
8 |
9 | var ErrNoState = errors.New("no state found for socket ID")
10 |
11 | type SocketState struct {
12 | Render []byte
13 | Data any
14 | }
15 |
16 | type SocketStateStore interface {
17 | Get(SocketID) (SocketState, error)
18 | Set(SocketID, SocketState, time.Duration) error
19 | Delete(SocketID) error
20 | }
21 |
22 | var _ SocketStateStore = &MemorySocketStateStore{}
23 |
24 | // MemorySocketStateStore an in memory store.
25 | type MemorySocketStateStore struct {
26 | janitorFrequency time.Duration
27 |
28 | gets chan mssGetop
29 | sets chan mssSetop
30 | dels chan mssDelop
31 | clean chan bool
32 | }
33 |
34 | func NewMemorySocketStateStore(ctx context.Context) *MemorySocketStateStore {
35 | m := &MemorySocketStateStore{
36 | janitorFrequency: 5 * time.Second,
37 | gets: make(chan mssGetop),
38 | sets: make(chan mssSetop),
39 | dels: make(chan mssDelop),
40 | clean: make(chan bool),
41 | }
42 | go m.operate(ctx)
43 | go m.janitor(ctx)
44 | return m
45 | }
46 |
47 | func (m *MemorySocketStateStore) Get(ID SocketID) (SocketState, error) {
48 | op := mssGetop{
49 | ID: ID,
50 | resp: make(chan SocketState),
51 | err: make(chan error),
52 | }
53 | m.gets <- op
54 | select {
55 | case state := <-op.resp:
56 | return state, nil
57 | case err := <-op.err:
58 | return SocketState{}, err
59 | }
60 | }
61 |
62 | func (m *MemorySocketStateStore) Set(ID SocketID, state SocketState, ttl time.Duration) error {
63 | op := mssSetop{
64 | ID: ID,
65 | State: state,
66 | StaleAt: time.Now().Add(ttl),
67 | resp: make(chan bool),
68 | err: make(chan error),
69 | }
70 | m.sets <- op
71 | select {
72 | case <-op.resp:
73 | return nil
74 | case err := <-op.err:
75 | return err
76 | }
77 | }
78 |
79 | func (m *MemorySocketStateStore) Delete(ID SocketID) error {
80 | op := mssDelop{
81 | ID: ID,
82 | resp: make(chan bool),
83 | err: make(chan error),
84 | }
85 | m.dels <- op
86 | select {
87 | case <-op.resp:
88 | return nil
89 | case err := <-op.err:
90 | return err
91 | }
92 | }
93 |
94 | type mss struct {
95 | entry time.Time
96 | stale time.Time
97 | state SocketState
98 | }
99 |
100 | type mssGetop struct {
101 | ID SocketID
102 |
103 | resp chan SocketState
104 | err chan error
105 | }
106 |
107 | type mssSetop struct {
108 | ID SocketID
109 | State SocketState
110 | StaleAt time.Time
111 |
112 | resp chan bool
113 | err chan error
114 | }
115 |
116 | type mssDelop struct {
117 | ID SocketID
118 |
119 | resp chan bool
120 | err chan error
121 | }
122 |
123 | func (m *MemorySocketStateStore) operate(ctx context.Context) {
124 | store := map[SocketID]mss{}
125 | for {
126 | select {
127 | case get := <-m.gets:
128 | ss, ok := store[get.ID]
129 | if !ok {
130 | get.err <- ErrNoState
131 | } else {
132 | get.resp <- ss.state
133 | }
134 | case set := <-m.sets:
135 | store[set.ID] = mss{
136 | entry: time.Now(),
137 | stale: set.StaleAt,
138 | state: set.State,
139 | }
140 | set.resp <- true
141 | case del := <-m.dels:
142 | delete(store, del.ID)
143 | del.resp <- true
144 | case <-m.clean:
145 | now := time.Now()
146 | for k, v := range store {
147 | if now.Before(v.stale) {
148 | continue
149 | }
150 | delete(store, k)
151 | }
152 | case <-ctx.Done():
153 | return
154 | }
155 | }
156 | }
157 |
158 | func (m *MemorySocketStateStore) janitor(ctx context.Context) {
159 | janitor := time.NewTicker(m.janitorFrequency)
160 | for {
161 | select {
162 | case <-janitor.C:
163 | m.clean <- true
164 | case <-ctx.Done():
165 | return
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/examples/uploads/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "html/template"
7 | "io"
8 | "log"
9 | "log/slog"
10 | "net/http"
11 | "os"
12 | "path/filepath"
13 | "strings"
14 |
15 | "github.com/jfyne/live"
16 | )
17 |
18 | const (
19 | validate = "validate"
20 | save = "save"
21 | )
22 |
23 | type model struct {
24 | Uploads []string
25 | }
26 |
27 | func newModel(s *live.Socket) *model {
28 | m, ok := s.Assigns().(*model)
29 | if !ok {
30 | return &model{
31 | Uploads: []string{},
32 | }
33 | }
34 | return m
35 | }
36 |
37 | // customError formats upload validation errors.
38 | func customError(u *live.Upload, err error) string {
39 | msg := []string{}
40 | if u.Name != "" {
41 | msg = append(msg, u.Name)
42 | }
43 | switch {
44 | case errors.Is(err, live.ErrUploadTooLarge):
45 | msg = append(msg, "This is a custom too large message: "+err.Error())
46 | case errors.Is(err, live.ErrUploadTooManyFiles):
47 | msg = append(msg, "This is a custom too many files message: "+err.Error())
48 | default:
49 | msg = append(msg, err.Error())
50 | }
51 | return strings.Join(msg, " - ")
52 | }
53 |
54 | func main() {
55 |
56 | // Setup the template with some funcs to provide custom error messages.
57 | t, err := template.New("root.html").Funcs(template.FuncMap{
58 | "customError": customError,
59 | }).ParseFiles("root.html", "uploads/view.html")
60 | if err != nil {
61 | log.Fatal(err)
62 | }
63 |
64 | // Create a temporary directory to store uploads
65 | staticPath, err := os.MkdirTemp("", "static-")
66 | if err != nil {
67 | log.Fatal(err)
68 | }
69 |
70 | h := live.NewHandler(live.WithTemplateRenderer(t))
71 |
72 | // In the mount function we call `AllowUploads` on the socket which configures
73 | // what is allowed to be uploaded.
74 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) {
75 | s.AllowUploads(&live.UploadConfig{
76 | // Name refers to the name of the file input field.
77 | Name: "photos",
78 | // We are accepting a maximum of 3 files.
79 | MaxFiles: 3,
80 | // For each of those files we are only allowing them to be 1MB.
81 | MaxSize: 1 * 1024 * 1024,
82 | // We are only accepting .png.
83 | Accept: []string{"image/png"},
84 | })
85 | return newModel(s), nil
86 | }
87 |
88 | // On form change we perform validation.
89 | h.HandleEvent(validate, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) {
90 | m := newModel(s)
91 | // This helper function populates the socket `Uploads` with errors.
92 | live.ValidateUploads(s, p)
93 | return m, nil
94 | })
95 |
96 | // On form save, the client first posts the files then this event handler is called.
97 | // Here we can to consume the files from our staging area.
98 | h.HandleEvent(save, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) {
99 | m := newModel(s)
100 |
101 | // `ConsumeUploads` helper function is used to iterate over the "photos" input files
102 | // that have been uploaded.
103 | errs := live.ConsumeUploads(s, "photos", func(u *live.Upload) error {
104 | // First we get the staged file.
105 | file, err := u.File()
106 | if err != nil {
107 | return err
108 | }
109 | // When we are done close the file, and remove it from staging.
110 | defer func() {
111 | file.Close()
112 | os.Remove(file.Name())
113 | }()
114 |
115 | // Create a new file in our static directory to copy the staged file into.
116 | dst, err := os.Create(filepath.Join(staticPath, u.Name))
117 | if err != nil {
118 | return err
119 | }
120 | defer dst.Close()
121 |
122 | // Do the copy
123 | if _, err := io.Copy(dst, file); err != nil {
124 | return err
125 | }
126 |
127 | // Record the name of the file so we can show the link to it.
128 | m.Uploads = append(m.Uploads, u.Name)
129 |
130 | return nil
131 | })
132 | if len(errs) > 0 {
133 | return nil, errors.Join(errs...)
134 | }
135 |
136 | return m, nil
137 | })
138 |
139 | http.Handle("/", live.NewHttpHandler(
140 | context.Background(),
141 | h,
142 | // Only allow a total of 10MBs to be uploaded.
143 | live.WithMaxUploadSize(10*1024*1024)))
144 |
145 | // Set up the static file handling for the uploads we have consumed.
146 | fs := http.FileServer(http.Dir(staticPath))
147 | http.Handle("/static/", http.StripPrefix("/static/", fs))
148 |
149 | http.Handle("/live.js", live.Javascript{})
150 | http.Handle("/auto.js.map", live.JavascriptMap{})
151 | slog.Info("server", "link", "http://localhost:8080")
152 | http.ListenAndServe(":8080", nil)
153 | }
154 |
--------------------------------------------------------------------------------
/examples/components/page.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 |
8 | "github.com/jfyne/live"
9 | "github.com/jfyne/live/page"
10 | g "github.com/maragudk/gomponents"
11 | c "github.com/maragudk/gomponents/components"
12 | h "github.com/maragudk/gomponents/html"
13 | )
14 |
15 | const (
16 | validateTZ = "validate-tz"
17 | addTime = "add-time"
18 | )
19 |
20 | // PageState the state we are tracking for our page.
21 | type PageState struct {
22 | Title string
23 | ValidationError string
24 | Clocks []*page.Component
25 | }
26 |
27 | // newPageState create a new page state.
28 | func newPageState(title string) *PageState {
29 | return &PageState{
30 | Title: title,
31 | Clocks: []*page.Component{},
32 | }
33 | }
34 |
35 | // pageRegister register the pages events.
36 | func pageRegister(c *page.Component) error {
37 | // Handler for the timezone entry validation.
38 | c.HandleEvent(validateTZ, func(_ context.Context, p live.Params) (any, error) {
39 | // Get the current page component state.
40 | state, _ := c.State.(*PageState)
41 |
42 | // Get the tz coming from the form.
43 | tz := p.String("tz")
44 |
45 | // Try to make a new ClockState, this will return an error if the
46 | // timezone is not real.
47 | if _, err := NewClockState(tz); err != nil {
48 | state.ValidationError = fmt.Sprintf("Timezone %s does not exist", tz)
49 | return state, nil
50 | }
51 |
52 | // If there was no error loading the clock state reset the
53 | // validation error.
54 | state.ValidationError = ""
55 |
56 | return state, nil
57 | })
58 |
59 | // Handler for adding a timezone.
60 | c.HandleEvent(addTime, func(_ context.Context, p live.Params) (any, error) {
61 | // Get the current page component state.
62 | state, _ := c.State.(*PageState)
63 |
64 | // Get the timezone sent from the form input.
65 | tz := p.String("tz")
66 | if tz == "" {
67 | return state, nil
68 | }
69 |
70 | // Use the page.Init function to create a new clock, register it and mount it.
71 | clock, err := page.Init(context.Background(), func() (*page.Component, error) {
72 | // Each clock requires its own unique stable ID. Events for each clock can then find
73 | // their own component.
74 | return NewClock(fmt.Sprintf("clock-%d", len(state.Clocks)+1), c.Handler, c.Socket, tz)
75 | })
76 | if err != nil {
77 | return state, err
78 | }
79 |
80 | // Update the page state with the new clock.
81 | state.Clocks = append(state.Clocks, clock)
82 |
83 | // Return the state to have it persisted.
84 | return state, nil
85 | })
86 |
87 | return nil
88 | }
89 |
90 | // pageMount initialise the page component.
91 | func pageMount(title string) page.MountHandler {
92 | return func(_ context.Context, c *page.Component) error {
93 | // Create a new page state.
94 | c.State = newPageState(title)
95 | return nil
96 | }
97 | }
98 |
99 | // pageRender render the page component.
100 | func pageRender(w io.Writer, cmp *page.Component) error {
101 | state, ok := cmp.State.(*PageState)
102 | if !ok {
103 | return fmt.Errorf("could not get state")
104 | }
105 |
106 | // Here we use the gomponents library to do typed rendering.
107 | // https://github.com/maragudk/gomponents
108 | return c.HTML5(c.HTML5Props{
109 | Title: state.Title,
110 | Language: "en",
111 | Head: []g.Node{
112 | h.StyleEl(h.Type("text/css"),
113 | g.Raw(`body {font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; }`),
114 | ),
115 | },
116 | Body: []g.Node{
117 | h.H1(g.Text("World Clocks")),
118 | h.Form(
119 | h.ID("tz-form"),
120 | g.Attr("live-change", cmp.Event(validateTZ)), // c.Event scopes the events to this component.
121 | g.Attr("live-submit", cmp.Event(addTime)),
122 | h.Div(
123 | h.P(g.Text("Try Europe/London or America/New_York")),
124 | h.Input(h.Name("tz")),
125 | g.If(state.ValidationError != "", h.Span(g.Text(state.ValidationError))),
126 | ),
127 | h.Input(h.Type("submit"), g.If(state.ValidationError != "", h.Disabled())),
128 | ),
129 | h.Div(
130 | g.Group(g.Map(state.Clocks, func(c *page.Component) g.Node {
131 | return page.Render(c)
132 | })),
133 | ),
134 | h.Script(h.Src("/live.js")),
135 | },
136 | }).Render(w)
137 | }
138 |
139 | // NewPage create a new page component.
140 | func NewPage(ID string, h *live.Handler, s *live.Socket, title string) (*page.Component, error) {
141 | return page.NewComponent(ID, h, s,
142 | page.WithRegister(pageRegister),
143 | page.WithMount(pageMount(title)),
144 | page.WithRender(pageRender),
145 | )
146 | }
147 |
--------------------------------------------------------------------------------
/handler.go:
--------------------------------------------------------------------------------
1 | package live
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "log/slog"
8 | )
9 |
10 | //var _ Handler = &BaseHandler{}
11 |
12 | // HandlerConfig applies config to a handler.
13 | type HandlerConfig func(h *Handler) error
14 |
15 | // MountHandler the func that is called by a handler to gather data to
16 | // be rendered in a template. This is called on first GET and then later when
17 | // the web socket first connects. It should return the state to be maintained
18 | // in the socket.
19 | type MountHandler func(ctx context.Context, c *Socket) (any, error)
20 |
21 | // UnmountHandler the func that is called by a handler to report that a connection
22 | // is closed. This is called on websocket close. Can be used to track number of
23 | // connected users.
24 | type UnmountHandler func(c *Socket) error
25 |
26 | // RenderHandler the func that is called to render the current state of the
27 | // data for the socket.
28 | type RenderHandler func(ctx context.Context, rc *RenderContext) (io.Reader, error)
29 |
30 | // ErrorHandler if an error occurs during the mount and render cycle
31 | // a handler of this type will be called.
32 | type ErrorHandler func(ctx context.Context, err error)
33 |
34 | // EventHandler a function to handle events, returns the data that should
35 | // be set to the socket after handling.
36 | type EventHandler func(context.Context, *Socket, Params) (any, error)
37 |
38 | // SelfHandler a function to handle self events, returns the data that should
39 | // be set to the socket after handling.
40 | type SelfHandler func(context.Context, *Socket, any) (any, error)
41 |
42 | // Handler.
43 | type Handler struct {
44 | // MountHandler a user should provide the mount function. This is what
45 | // is called on initial GET request and later when the websocket connects.
46 | // Data to render the handler should be fetched here and returned.
47 | MountHandler MountHandler
48 | // UnmountHandler used to track websocket disconnections.
49 | UnmountHandler UnmountHandler
50 | // Render is called to generate the HTML of a Socket. It is defined
51 | // by default and will render any template provided.
52 | RenderHandler RenderHandler
53 | // Error is called when an error occurs during the mount and render
54 | // stages of the handler lifecycle.
55 | ErrorHandler ErrorHandler
56 | // eventHandlers the map of client event handlers.
57 | eventHandlers map[string]EventHandler
58 | // selfHandlers the map of handler event handlers.
59 | selfHandlers map[string]SelfHandler
60 | // paramsHandlers a slice of handlers which respond to a change in URL parameters.
61 | paramsHandlers []EventHandler
62 | }
63 |
64 | // NewHandler sets up a base handler for live.
65 | func NewHandler(configs ...HandlerConfig) *Handler {
66 | h := &Handler{
67 | eventHandlers: make(map[string]EventHandler),
68 | selfHandlers: make(map[string]SelfHandler),
69 | paramsHandlers: []EventHandler{},
70 | MountHandler: func(ctx context.Context, s *Socket) (any, error) {
71 | return nil, nil
72 | },
73 | UnmountHandler: func(s *Socket) error {
74 | return nil
75 | },
76 | RenderHandler: func(ctx context.Context, rc *RenderContext) (io.Reader, error) {
77 | return nil, ErrNoRenderer
78 | },
79 | ErrorHandler: func(ctx context.Context, err error) {
80 | w := Writer(ctx)
81 | if w != nil {
82 | w.WriteHeader(500)
83 | w.Write([]byte(err.Error()))
84 | }
85 | },
86 | }
87 | for _, conf := range configs {
88 | if err := conf(h); err != nil {
89 | slog.Warn("apply config", "err", err)
90 | }
91 | }
92 | return h
93 | }
94 |
95 | // HandleEvent handles an event that comes from the client. For example a click
96 | // from `live-click="myevent"`.
97 | func (h *Handler) HandleEvent(t string, handler EventHandler) {
98 | h.eventHandlers[t] = handler
99 | }
100 |
101 | // HandleSelf handles an event that comes from the server side socket. For example calling
102 | // h.Self(socket, msg) will be handled here.
103 | func (h *Handler) HandleSelf(t string, handler SelfHandler) {
104 | h.selfHandlers[t] = handler
105 | }
106 |
107 | // HandleParams handles a URL query parameter change. This is useful for handling
108 | // things like pagination, or some filtering.
109 | func (h *Handler) HandleParams(handler EventHandler) {
110 | h.paramsHandlers = append(h.paramsHandlers, handler)
111 | }
112 |
113 | func (h *Handler) getEvent(t string) (EventHandler, error) {
114 | handler, ok := h.eventHandlers[t]
115 | if !ok {
116 | return nil, fmt.Errorf("no event handler for %s: %w", t, ErrNoEventHandler)
117 | }
118 | return handler, nil
119 | }
120 | func (h *Handler) getSelf(t string) (SelfHandler, error) {
121 | handler, ok := h.selfHandlers[t]
122 | if !ok {
123 | return nil, fmt.Errorf("no self handler for %s: %w", t, ErrNoEventHandler)
124 | }
125 | return handler, nil
126 | }
127 |
--------------------------------------------------------------------------------
/web/src/socket.ts:
--------------------------------------------------------------------------------
1 | import { EventDispatch, LiveEvent } from "./event";
2 | import { Patch } from "./patch";
3 | import { Events } from "./events";
4 | import { UpdateURLParams } from "./params";
5 |
6 | const privateSocketID = "_psid"
7 |
8 | /**
9 | * Represents the websocket connection to
10 | * the backend server.
11 | */
12 | export class Socket {
13 | private static id: string | undefined;
14 | private static conn: WebSocket;
15 | private static ready: boolean = false;
16 | private static disconnectNotified: boolean = false;
17 |
18 | private static trackedEvents: {
19 | [id: number]: { ev: LiveEvent; el: HTMLElement };
20 | };
21 |
22 | constructor() {}
23 |
24 | static getID() {
25 | if (this.id) {
26 | return this.id;
27 | }
28 | const value = `; ${document.cookie}`;
29 | const parts = value.split(`; ${privateSocketID}=`);
30 | if (parts && parts.length === 2) {
31 | const val = parts.pop()
32 | if (!val) {
33 | return ""
34 | }
35 | return val.split(';').shift();
36 | }
37 | return "";
38 | }
39 |
40 | static setCookie() {
41 | var date = new Date();
42 | date.setTime(date.getTime() + (60*1000));
43 | document.cookie = `${privateSocketID}=${this.id}; expires=${date.toUTCString()}; path=/`;
44 | }
45 |
46 | static dial() {
47 | this.trackedEvents = {};
48 | this.id = this.getID();
49 | this.setCookie();
50 |
51 | console.debug("Socket.dial called", this.id);
52 | this.conn = new WebSocket(
53 | `${location.protocol === "https:" ? "wss" : "ws"}://${
54 | location.host
55 | }${location.pathname}${location.search}${location.hash}`
56 | );
57 | this.conn.addEventListener("close", (ev) => {
58 | this.ready = false;
59 | console.warn(
60 | `WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`
61 | );
62 | if (ev.code !== 1001) {
63 | if (this.disconnectNotified === false) {
64 | EventDispatch.disconnected();
65 | this.disconnectNotified = true;
66 | }
67 | setTimeout(() => {
68 | Socket.dial();
69 | }, 1000);
70 | }
71 | });
72 | // Ping on open.
73 | this.conn.addEventListener("open", (_) => {
74 | EventDispatch.reconnected();
75 | this.disconnectNotified = false;
76 | this.ready = true;
77 | });
78 | this.conn.addEventListener("message", (ev) => {
79 | if (typeof ev.data !== "string") {
80 | console.error("unexpected message type", typeof ev.data);
81 | return;
82 | }
83 | const e = LiveEvent.fromMessage(ev.data);
84 | switch (e.typ) {
85 | case "patch":
86 | Patch.handle(e);
87 | Events.rewire();
88 | break;
89 | case "params":
90 | UpdateURLParams(`${window.location.pathname}?${e.data}`);
91 | break;
92 | case "redirect":
93 | window.location.replace(e.data);
94 | break;
95 | case "ack":
96 | this.ack(e);
97 | break;
98 | case "err":
99 | EventDispatch.error();
100 | // Fallthrough here.
101 | default:
102 | EventDispatch.handleEvent(e);
103 | }
104 | });
105 | }
106 |
107 | /**
108 | * Send an event and keep track of it until
109 | * the ack event comes back.
110 | */
111 | static sendAndTrack(e: LiveEvent, element: HTMLElement) {
112 | if (this.ready === false) {
113 | console.warn("connection not ready for send of event", e);
114 | return;
115 | }
116 | this.trackedEvents[e.id] = {
117 | ev: e,
118 | el: element,
119 | };
120 | this.conn.send(e.serialize());
121 | }
122 |
123 | static send(e: LiveEvent) {
124 | if (this.ready === false) {
125 | console.warn("connection not ready for send of event", e);
126 | return;
127 | }
128 | this.conn.send(e.serialize());
129 | }
130 |
131 | /**
132 | * Called when a ack event comes in. Complete the loop
133 | * with any outstanding tracked events.
134 | */
135 | static ack(e: LiveEvent) {
136 | if (!(e.id in this.trackedEvents)) {
137 | return;
138 | }
139 | this.trackedEvents[e.id].el.dispatchEvent(new Event("ack"));
140 | delete this.trackedEvents[e.id];
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/web/src/forms.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A value of an existing input in a form.
3 | */
4 | interface inputState {
5 | name: string;
6 | focus: boolean;
7 | value: any;
8 | }
9 |
10 | /**
11 | * A value of a file input for validation.
12 | */
13 | interface fileInput {
14 | name: string;
15 | lastModified: number;
16 | size: number;
17 | type: string;
18 | }
19 |
20 | /**
21 | * Form helper class.
22 | */
23 | export class Forms {
24 | private static upKey = "uploads";
25 |
26 | private static formState: { [id: string]: inputState[] } = {};
27 |
28 | /**
29 | * When we are patching the DOM we need to save the state
30 | * of any forms so that we don't lose input values or
31 | * focus
32 | */
33 | static dehydrate() {
34 | const forms = document.querySelectorAll("form");
35 | forms.forEach((f) => {
36 | if (f.id === "") {
37 | console.error(
38 | "form does not have an ID. DOM updates may be affected",
39 | f
40 | );
41 | return;
42 | }
43 |
44 | this.formState[f.id] = [];
45 | new FormData(f).forEach((value: any, name: string) => {
46 | const i = {
47 | name: name,
48 | value: value,
49 | focus:
50 | f.querySelector(`[name="${name}"]`) ==
51 | document.activeElement,
52 | };
53 | this.formState[f.id].push(i);
54 | });
55 | });
56 | }
57 |
58 | /**
59 | * This sets the form backup to its original state.
60 | */
61 | static hydrate() {
62 | Object.keys(this.formState).map((formID) => {
63 | const form = document.querySelector(`#${formID}`);
64 | if (form === null) {
65 | delete this.formState[formID];
66 | return;
67 | }
68 |
69 | const state = this.formState[formID];
70 | state.map((i) => {
71 | const input = form.querySelector(
72 | `[name="${i.name}"]`
73 | ) as HTMLInputElement;
74 | if (input === null) {
75 | return;
76 | }
77 | switch (input.type) {
78 | case "file":
79 | break;
80 | case "checkbox":
81 | if (i.value === "on") {
82 | input.checked = true;
83 | }
84 | break;
85 | default:
86 | input.value = i.value;
87 | if (i.focus === true) {
88 | input.focus();
89 | }
90 | break;
91 | }
92 | });
93 | });
94 | }
95 |
96 | /**
97 | * serialize form to values.
98 | */
99 | static serialize(form: HTMLFormElement): { [key: string]: string | number | fileInput } {
100 | const values: { [key: string]: any } = {};
101 | const formData = new FormData(form);
102 | formData.forEach((value, key) => {
103 | switch (true) {
104 | case value instanceof File:
105 | const file = value as File;
106 | const fi = {
107 | name: file.name,
108 | type: file.type,
109 | size: file.size,
110 | lastModified: file.lastModified,
111 | }
112 | if (!Reflect.has(values, this.upKey)) {
113 | values[this.upKey] = {};
114 | }
115 | if (!Reflect.has(values[this.upKey], key)) {
116 | values[this.upKey][key] = [];
117 | }
118 | values[this.upKey][key].push(fi);
119 | break;
120 | default:
121 | // If the key doesn't exist set it.
122 | if (!Reflect.has(values, key)) {
123 | values[key] = value;
124 | return;
125 | }
126 | // If it already exists that means this needs to become
127 | // an array.
128 | if (!Array.isArray(values[key])) {
129 | values[key] = [values[key]];
130 | }
131 | // Push the new value onto the array.
132 | values[key].push(value);
133 | }
134 | });
135 | return values;
136 | }
137 |
138 | /**
139 | * does a form have files.
140 | */
141 | static hasFiles(form: HTMLFormElement): boolean {
142 | const formData = new FormData(form);
143 | let hasFiles = false;
144 | formData.forEach((value) => {
145 | if(value instanceof File) {
146 | hasFiles = true;
147 | }
148 | });
149 | return hasFiles;
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/upload.go:
--------------------------------------------------------------------------------
1 | package live
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log/slog"
8 | "os"
9 | )
10 |
11 | const upKey = "uploads"
12 |
13 | type UploadError struct {
14 | additional string
15 | err error
16 | }
17 |
18 | func (u *UploadError) Error() string {
19 | if u.additional != "" {
20 | return fmt.Sprintf("%s: %s", u.additional, u.err)
21 | }
22 | return fmt.Sprintf("%s", u.err)
23 | }
24 |
25 | func (u *UploadError) Unwrap() error {
26 | return u.err
27 | }
28 |
29 | var (
30 | ErrUploadNotFound = errors.New("uploads not found")
31 | ErrUploadTooLarge = errors.New("upload too large")
32 | ErrUploadNotAccepted = errors.New("upload not accepted")
33 | ErrUploadTooManyFiles = errors.New("upload too many files")
34 | ErrUploadMalformed = errors.New("upload malformed")
35 | )
36 |
37 | // UploadConfig describes an upload to accept on the socket.
38 | type UploadConfig struct {
39 | // The form input name to accept from.
40 | Name string
41 | // The max number of files to allow to be uploaded.
42 | MaxFiles int
43 | // The maximum size of all files to accept.
44 | MaxSize int64
45 | // Which type of files to accept.
46 | Accept []string
47 | }
48 |
49 | // Upload describes an upload from the client.
50 | type Upload struct {
51 | Name string
52 | Size int64
53 | Type string
54 | LastModified string
55 | Errors []error
56 | Progress float32
57 |
58 | internalLocation string `json:"-"`
59 | bytesRead int64 `json:"-"`
60 | }
61 |
62 | // File gets an open file reader.
63 | func (u Upload) File() (*os.File, error) {
64 | return os.Open(u.internalLocation)
65 | }
66 |
67 | // UploadContext the context which we render to templates.
68 | type UploadContext map[string][]*Upload
69 |
70 | // HasErrors does the upload context have any errors.
71 | func (u UploadContext) HasErrors() bool {
72 | for _, uploads := range u {
73 | for _, u := range uploads {
74 | if len(u.Errors) > 0 {
75 | return true
76 | }
77 | }
78 | }
79 | return false
80 | }
81 |
82 | // UploadProgress tracks uploads and updates an upload
83 | // object with progress.
84 | type UploadProgress struct {
85 | Upload *Upload
86 | Engine *Engine
87 | Socket *Socket
88 | }
89 |
90 | // Write interface to track progress of an upload.
91 | func (u *UploadProgress) Write(p []byte) (n int, err error) {
92 | n = len(p)
93 | u.Upload.bytesRead += int64(n)
94 | u.Upload.Progress = float32(u.Upload.bytesRead) / float32(u.Upload.Size)
95 | render, err := RenderSocket(context.Background(), u.Engine, u.Socket)
96 | if err != nil {
97 | slog.Error("error in upload progress", "err", err)
98 | return
99 | }
100 | u.Socket.UpdateRender(render)
101 | return
102 | }
103 |
104 | // ValidateUploads checks proposed uploads for errors, should be called
105 | // in a validation check function.
106 | func ValidateUploads(s *Socket, p Params) {
107 | s.ClearUploads()
108 |
109 | input, ok := p[upKey].(map[string]any)
110 | if !ok {
111 | slog.Warn("validate uploads", "err", ErrUploadNotFound)
112 | return
113 | }
114 |
115 | for _, c := range s.UploadConfigs() {
116 | uploads, ok := input[c.Name].([]any)
117 | if !ok {
118 | s.AssignUpload(c.Name, &Upload{Errors: []error{ErrUploadNotFound}})
119 | continue
120 | }
121 | if len(uploads) > c.MaxFiles {
122 | s.AssignUpload(c.Name, &Upload{Errors: []error{&UploadError{err: ErrUploadTooManyFiles}}})
123 | }
124 | for _, u := range uploads {
125 | f, ok := u.(map[string]any)
126 | if !ok {
127 | s.AssignUpload(c.Name, &Upload{Errors: []error{&UploadError{err: ErrUploadNotFound}}})
128 | continue
129 | }
130 | u := &Upload{
131 | Name: mapString(f, "name"),
132 | Size: int64(mapInt(f, "size")),
133 | Type: mapString(f, "type"),
134 | }
135 |
136 | // Check size.
137 | if u.Size > c.MaxSize {
138 | u.Errors = append(u.Errors, &UploadError{err: ErrUploadTooLarge})
139 | }
140 |
141 | // Check Accept.
142 | accepted := false
143 | for _, a := range c.Accept {
144 | if u.Type == a {
145 | accepted = true
146 | }
147 | }
148 | if !accepted {
149 | u.Errors = append(u.Errors, &UploadError{err: ErrUploadNotAccepted})
150 | }
151 | s.AssignUpload(c.Name, u)
152 | }
153 | }
154 | }
155 |
156 | // ConsumeHandler callback type when uploads are consumed.
157 | type ConsumeHandler func(u *Upload) error
158 |
159 | // ConsumeUploads helper function to consume the staged uploads.
160 | func ConsumeUploads(s *Socket, name string, ch ConsumeHandler) []error {
161 | errs := []error{}
162 | all := s.Uploads()
163 | uploads, ok := all[name]
164 | if !ok {
165 | return errs
166 | }
167 | for _, u := range uploads {
168 | if err := ch(u); err != nil {
169 | errs = append(errs, err)
170 | }
171 | s.ClearUpload(name, u)
172 | }
173 | return errs
174 | }
175 |
176 | // WithMaxUploadSize set the handler engine to have a maximum upload size.
177 | func WithMaxUploadSize(size int64) EngineConfig {
178 | return func(e *Engine) error {
179 | e.MaxUploadSize = size
180 | return nil
181 | }
182 | }
183 |
184 | // WithUploadStagingLocation set the handler engine with a specific upload staging location.
185 | func WithUploadStagingLocation(stagingLocation string) EngineConfig {
186 | return func(e *Engine) error {
187 | e.UploadStagingLocation = stagingLocation
188 | return nil
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/page/component.go:
--------------------------------------------------------------------------------
1 | package page
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "io"
8 |
9 | "github.com/jfyne/live"
10 | )
11 |
12 | // RegisterHandler the first part of the component lifecycle, this is called during component creation
13 | // and is used to register any events that the component handles.
14 | type RegisterHandler func(c *Component) error
15 |
16 | // MountHandler the components mount function called on first GET request and again when the socket connects.
17 | type MountHandler func(ctx context.Context, c *Component) error
18 |
19 | // RenderHandler ths component.
20 | type RenderHandler func(w io.Writer, c *Component) error
21 |
22 | // EventHandler for a component, only needs the params as the event is scoped to both the socket and then component
23 | // itself. Returns any component state that needs updating.
24 | type EventHandler func(ctx context.Context, p live.Params) (any, error)
25 |
26 | // SelfHandler for a component, only needs the data as the event is scoped to both the socket and then component
27 | // itself. Returns any component state that needs updating.
28 | type SelfHandler func(ctx context.Context, data any) (any, error)
29 |
30 | // ComponentConstructor a func for creating a new component.
31 | type ComponentConstructor func(ctx context.Context, h *live.Handler, s *live.Socket) (*Component, error)
32 |
33 | // Component is a self contained component on the page. Components can be reused accross the application
34 | // or used to compose complex interfaces by splitting events handlers and render logic into
35 | // smaller pieces.
36 | //
37 | // Remember to use a unique ID and use the Event function which scopes the event-name
38 | // to trigger the event in the right component.
39 | type Component struct {
40 | // ID identifies the component on the page. This should be something stable, so that during the mount
41 | // it can be found again by the socket.
42 | // When reusing the same component this ID should be unique to avoid conflicts.
43 | ID string
44 |
45 | // Handler a reference to the host handler.
46 | Handler *live.Handler
47 |
48 | // Socket a reference to the socket that this component
49 | // is scoped too.
50 | Socket *live.Socket
51 |
52 | // Register the component. This should be used to setup event handling.
53 | Register RegisterHandler
54 |
55 | // Mount the component, this should be used to setup the components initial state.
56 | Mount MountHandler
57 |
58 | // Render the component, this should be used to describe how to render the component.
59 | Render RenderHandler
60 |
61 | // State the components state.
62 | State any
63 |
64 | // Any uploads.
65 | Uploads live.UploadContext
66 | }
67 |
68 | // NewComponent creates a new component and returns it. It does not register it or mount it.
69 | func NewComponent(ID string, h *live.Handler, s *live.Socket, configurations ...ComponentConfig) (*Component, error) {
70 | c := &Component{
71 | ID: ID,
72 | Handler: h,
73 | Socket: s,
74 | Register: defaultRegister,
75 | Mount: defaultMount,
76 | Render: defaultRender,
77 | }
78 | for _, conf := range configurations {
79 | if err := conf(c); err != nil {
80 | return &Component{}, err
81 | }
82 | }
83 |
84 | return c, nil
85 | }
86 |
87 | // Init takes a constructor and then registers and mounts the component.
88 | func Init(ctx context.Context, construct func() (*Component, error)) (*Component, error) {
89 | comp, err := construct()
90 | if err != nil {
91 | return nil, fmt.Errorf("could not install component on construct: %w", err)
92 | }
93 | if err := comp.Register(comp); err != nil {
94 | return nil, fmt.Errorf("could not install component on register: %w", err)
95 | }
96 | if err := comp.Mount(ctx, comp); err != nil {
97 | return nil, fmt.Errorf("could not install component on mount: %w", err)
98 | }
99 | return comp, nil
100 | }
101 |
102 | // Self sends an event scoped not only to this socket, but to this specific component instance. Or any
103 | // components sharing the same ID.
104 | func (c *Component) Self(ctx context.Context, s *live.Socket, event string, data any) error {
105 | return s.Self(ctx, c.Event(event), data)
106 | }
107 |
108 | // HandleSelf handles scoped incoming events send by a components Self function.
109 | func (c *Component) HandleSelf(event string, handler SelfHandler) {
110 | c.Handler.HandleSelf(c.Event(event), func(ctx context.Context, s *live.Socket, d any) (any, error) {
111 | state, err := handler(ctx, d)
112 | if err != nil {
113 | return s.Assigns(), err
114 | }
115 | c.State = state
116 | return s.Assigns(), nil
117 | })
118 | }
119 |
120 | // HandleEvent handles a component event sent from a connected socket.
121 | func (c *Component) HandleEvent(event string, handler EventHandler) {
122 | c.Handler.HandleEvent(c.Event(event), func(ctx context.Context, s *live.Socket, p live.Params) (any, error) {
123 | state, err := handler(ctx, p)
124 | if err != nil {
125 | return s.Assigns(), err
126 | }
127 | c.State = state
128 | return s.Assigns(), nil
129 | })
130 | }
131 |
132 | // HandleParams handles parameter changes. Caution these handlers are not scoped to a specific component.
133 | func (c *Component) HandleParams(handler EventHandler) {
134 | c.Handler.HandleParams(func(ctx context.Context, s *live.Socket, p live.Params) (any, error) {
135 | state, err := handler(ctx, p)
136 | if err != nil {
137 | return s.Assigns(), err
138 | }
139 | c.State = state
140 | return s.Assigns(), nil
141 | })
142 | }
143 |
144 | // Event scopes an event string so that it applies to this instance of this component
145 | // only.
146 | func (c *Component) Event(event string) string {
147 | return c.ID + "--" + event
148 | }
149 |
150 | // String renders the component to a string.
151 | func (c *Component) String() string {
152 | var buf bytes.Buffer
153 | if err := c.Render(&buf, c); err != nil {
154 | return fmt.Sprintf("template rendering failed: %s", err)
155 | }
156 | return buf.String()
157 | }
158 |
159 | // defaultRegister is the default register handler which does nothing.
160 | func defaultRegister(c *Component) error {
161 | return nil
162 | }
163 |
164 | // defaultMount is the default mount handler which does nothing.
165 | func defaultMount(ctx context.Context, c *Component) error {
166 | return nil
167 | }
168 |
169 | // defaultRender is the default render handler which does nothing.
170 | func defaultRender(w io.Writer, c *Component) error {
171 | _, err := w.Write([]byte(fmt.Sprintf("%+v", c.State)))
172 | return err
173 | }
174 |
175 | var _ RegisterHandler = defaultRegister
176 | var _ MountHandler = defaultMount
177 | var _ RenderHandler = defaultRender
178 |
--------------------------------------------------------------------------------
/web/src/event.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from "./socket";
2 | import { LiveElement } from "./element";
3 | import { Hook, Hooks, DOM } from "./interop";
4 |
5 | export const EventMounted = "live:mounted";
6 | export const EventBeforeUpdate = "live:beforeupdate";
7 | export const EventUpdated = "live:updated";
8 | export const EventBeforeDestroy = "live:beforedestroy";
9 | export const EventDestroyed = "live:destroyed";
10 | export const EventDisconnected = "live:disconnected";
11 | export const EventReconnected = "live:reconnected";
12 |
13 | export const ClassConnected = "live-connected";
14 | export const ClassDisconnected = "live-disconnected";
15 | export const ClassError = "live-error";
16 |
17 | /**
18 | * LiveEvent an event that is being passed back and forth
19 | * between the frontend and server.
20 | */
21 | export class LiveEvent {
22 | public typ: string;
23 | public id: number;
24 | public data: any;
25 | private static sequence: number = 1;
26 |
27 | constructor(typ: string, data: any, id?: number) {
28 | this.typ = typ;
29 | this.data = data;
30 | if (id !== undefined) {
31 | this.id = id;
32 | } else {
33 | this.id = 0;
34 | }
35 | }
36 |
37 | /**
38 | * Get an ID for an event.
39 | */
40 | public static GetID(): number {
41 | return this.sequence++;
42 | }
43 |
44 | /**
45 | * Convert the event onto our wire format
46 | */
47 | public serialize(): string {
48 | return JSON.stringify({
49 | t: this.typ,
50 | i: this.id,
51 | d: this.data,
52 | });
53 | }
54 |
55 | /**
56 | * From an incoming message create a live event.
57 | */
58 | public static fromMessage(data: any): LiveEvent {
59 | const e = JSON.parse(data);
60 | return new LiveEvent(e.t, e.d, e.i);
61 | }
62 | }
63 |
64 | /**
65 | * EventDispatch allows the code base to send events
66 | * to hooked elements. Also handles events coming from
67 | * the server.
68 | */
69 | export class EventDispatch {
70 | private static hooks: Hooks;
71 | private static dom?: DOM;
72 | private static eventHandlers: { [e: string]: ((d: any) => void)[] };
73 |
74 | constructor() {}
75 |
76 | /**
77 | * Must be called before usage.
78 | */
79 | static init(hooks: Hooks, dom?: DOM) {
80 | this.hooks = hooks;
81 | this.dom = dom;
82 | this.eventHandlers = {};
83 | }
84 |
85 | /**
86 | * Handle an event pushed from the server.
87 | */
88 | static handleEvent(ev: LiveEvent) {
89 | if (!(ev.typ in this.eventHandlers)) {
90 | return;
91 | }
92 | this.eventHandlers[ev.typ].map((h) => {
93 | h(ev.data);
94 | });
95 | }
96 |
97 | /**
98 | * Handle an element being mounted.
99 | */
100 | static mounted(element: Element) {
101 | const event = new CustomEvent(EventMounted, {});
102 | const h = this.getElementHooks(element);
103 | if (h === null) {
104 | return;
105 | }
106 | this.callHook(event, element, h.mounted);
107 | }
108 |
109 | /**
110 | * Before an element is updated.
111 | */
112 | static beforeUpdate(fromEl: Element, toEl: Element) {
113 | const event = new CustomEvent(EventBeforeUpdate, {});
114 |
115 | const h = this.getElementHooks(fromEl);
116 | if (h !== null) {
117 | this.callHook(event, fromEl, h.beforeUpdate);
118 | }
119 |
120 | if (
121 | this.dom !== undefined &&
122 | this.dom.onBeforeElUpdated !== undefined
123 | ) {
124 | this.dom.onBeforeElUpdated(fromEl, toEl);
125 | }
126 | }
127 |
128 | /**
129 | * After and element has been updated.
130 | */
131 | static updated(element: Element) {
132 | const event = new CustomEvent(EventUpdated, {});
133 | const h = this.getElementHooks(element);
134 | if (h === null) {
135 | return;
136 | }
137 | this.callHook(event, element, h.updated);
138 | }
139 |
140 | /**
141 | * Before an element is destroyed.
142 | */
143 | static beforeDestroy(element: Element) {
144 | const event = new CustomEvent(EventBeforeDestroy, {});
145 | const h = this.getElementHooks(element);
146 | if (h === null) {
147 | return;
148 | }
149 | this.callHook(event, element, h.beforeDestroy);
150 | }
151 |
152 | /**
153 | * After an element has been destroyed.
154 | */
155 | static destroyed(element: Element) {
156 | const event = new CustomEvent(EventDestroyed, {});
157 | const h = this.getElementHooks(element);
158 | if (h === null) {
159 | return;
160 | }
161 | this.callHook(event, element, h.destroyed);
162 | }
163 |
164 | /**
165 | * Handle a disconnection event.
166 | */
167 | static disconnected() {
168 | const event = new CustomEvent(EventDisconnected, {});
169 | document.querySelectorAll(`[live-hook]`).forEach((element: Element) => {
170 | const h = this.getElementHooks(element);
171 | if (h === null) {
172 | return;
173 | }
174 | this.callHook(event, element, h.disconnected);
175 | });
176 | document.body.classList.add(ClassDisconnected);
177 | document.body.classList.remove(ClassConnected);
178 | }
179 |
180 | /**
181 | * Handle a reconnection event.
182 | */
183 | static reconnected() {
184 | const event = new CustomEvent(EventReconnected, {});
185 | document.querySelectorAll(`[live-hook]`).forEach((element: Element) => {
186 | const h = this.getElementHooks(element);
187 | if (h === null) {
188 | return;
189 | }
190 | this.callHook(event, element, h.reconnected);
191 | });
192 | document.body.classList.remove(ClassDisconnected);
193 | document.body.classList.add(ClassConnected);
194 | }
195 |
196 | /**
197 | * Handle an error event.
198 | */
199 | static error() {
200 | document.body.classList.add(ClassError);
201 | }
202 |
203 | private static getElementHooks(element: Element): Hook | null {
204 | const val = LiveElement.hook(element as HTMLElement);
205 | if (val === null) {
206 | return val;
207 | }
208 | return this.hooks[val];
209 | }
210 |
211 | private static callHook(
212 | event: CustomEvent,
213 | el: Element,
214 | f: (() => void) | undefined
215 | ) {
216 | if (f === undefined) {
217 | return;
218 | }
219 | const pushEvent = (e: LiveEvent) => {
220 | Socket.send(e);
221 | };
222 | const handleEvent = (e: string, cb: (d: any) => void) => {
223 | if (!(e in this.eventHandlers)) {
224 | this.eventHandlers[e] = [];
225 | }
226 | this.eventHandlers[e].push(cb);
227 | };
228 | f.bind({ el, pushEvent, handleEvent })();
229 | el.dispatchEvent(event);
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/socket.go:
--------------------------------------------------------------------------------
1 | package live
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | "slices"
10 | "time"
11 |
12 | "github.com/coder/websocket"
13 | "github.com/rs/xid"
14 | "golang.org/x/net/html"
15 | )
16 |
17 | const (
18 | // maxMessageBufferSize the maximum number of messages per socket in a buffer.
19 | maxMessageBufferSize = 16
20 |
21 | // cookieSocketID name for a cookie which holds the current socket ID.
22 | cookieSocketID = "_psid"
23 |
24 | // infiniteTTL
25 | infiniteTTL = 10_000 * (24 * time.Hour)
26 | )
27 |
28 | type SocketID string
29 |
30 | // Socket describes a socket from the outside.
31 | type Socket struct {
32 | id SocketID
33 |
34 | engine *Engine
35 | connected bool
36 | currentRender *html.Node
37 | msgs chan Event
38 | closeSlow func()
39 |
40 | uploadConfigs []*UploadConfig
41 | uploads UploadContext
42 |
43 | selfChan chan socketSelfOp
44 | }
45 |
46 | type socketSelfOp struct {
47 | Event Event
48 | resp chan bool
49 | err chan error
50 | }
51 |
52 | // NewID returns a new ID.
53 | func NewID() string {
54 | return xid.New().String()
55 | }
56 |
57 | // NewSocketFromRequest creates a new default socket from a request.
58 | func NewSocketFromRequest(ctx context.Context, e *Engine, r *http.Request) (*Socket, error) {
59 | sockID, err := socketIDFromReq(r)
60 | if err != nil {
61 | return nil, fmt.Errorf("socket id not found: %w", err)
62 | }
63 |
64 | existingSock, err := e.GetSocket(sockID)
65 | if err == nil {
66 | return existingSock, nil
67 | }
68 |
69 | return NewSocket(ctx, e, sockID), nil
70 | }
71 |
72 | func socketIDFromReq(r *http.Request) (SocketID, error) {
73 | c, err := r.Cookie(cookieSocketID)
74 | if err == nil {
75 | return SocketID(c.Value), nil
76 | }
77 |
78 | v := r.FormValue(cookieSocketID)
79 | if v != "" {
80 | return SocketID(v), nil
81 | }
82 |
83 | return "", fmt.Errorf("socket id not found in cookie or form data")
84 | }
85 |
86 | // NewSocket creates a new default socket.
87 | func NewSocket(ctx context.Context, e *Engine, withID SocketID) *Socket {
88 | s := &Socket{
89 | id: withID,
90 | engine: e,
91 | connected: withID != "",
92 | uploadConfigs: []*UploadConfig{},
93 | msgs: make(chan Event, maxMessageBufferSize),
94 | selfChan: make(chan socketSelfOp),
95 | }
96 | if withID == "" {
97 | s.id = SocketID(NewID())
98 | }
99 | go s.operate(ctx)
100 | return s
101 | }
102 |
103 | func (s *Socket) WriteFlashCookie(w http.ResponseWriter) {
104 | http.SetCookie(w, &http.Cookie{
105 | Name: cookieSocketID,
106 | Value: string(s.id),
107 | Path: "/",
108 | HttpOnly: false,
109 | SameSite: http.SameSiteStrictMode,
110 | MaxAge: 1,
111 | })
112 | }
113 |
114 | // ID gets the socket ID.
115 | func (s *Socket) ID() SocketID {
116 | return s.id
117 | }
118 |
119 | // Assigns returns the data currently assigned to this
120 | // socket.
121 | func (s *Socket) Assigns() any {
122 | state, _ := s.engine.socketStateStore.Get(s.id)
123 | return state.Data
124 | }
125 |
126 | // Assign sets data to this socket. This will happen automatically
127 | // if you return data from an `EventHander`.
128 | func (s *Socket) Assign(data any) {
129 | state, _ := s.engine.socketStateStore.Get(s.id)
130 | state.Data = data
131 | ttl := 10 * time.Second
132 | if s.connected {
133 | ttl = infiniteTTL
134 | }
135 | s.engine.socketStateStore.Set(s.id, state, ttl)
136 | }
137 |
138 | // Connected returns if this socket is connected via the websocket.
139 | func (s *Socket) Connected() bool {
140 | return s.connected
141 | }
142 |
143 | // Self sends an event to this socket itself. Will be handled in the
144 | // handlers HandleSelf function.
145 | func (s *Socket) Self(ctx context.Context, event string, data any) error {
146 | op := socketSelfOp{
147 | Event: Event{T: event, SelfData: data},
148 | resp: make(chan bool),
149 | err: make(chan error),
150 | }
151 | s.selfChan <- op
152 | select {
153 | case <-op.resp:
154 | return nil
155 | case err := <-op.err:
156 | return err
157 | }
158 | }
159 |
160 | func (s *Socket) operate(ctx context.Context) {
161 | for {
162 | select {
163 | case op := <-s.selfChan:
164 | s.engine.self(ctx, s, op.Event)
165 | op.resp <- true
166 | case <-ctx.Done():
167 | return
168 | }
169 | }
170 | }
171 |
172 | // Broadcast sends an event to all sockets on this same engine.
173 | func (s *Socket) Broadcast(event string, data any) error {
174 | return s.engine.Broadcast(event, data)
175 | }
176 |
177 | // Send an event to this socket's client, to be handled there.
178 | func (s *Socket) Send(event string, data any, options ...EventConfig) error {
179 | payload, err := json.Marshal(data)
180 | if err != nil {
181 | return fmt.Errorf("could not encode data for send: %w", err)
182 | }
183 | msg := Event{T: event, Data: payload}
184 | for _, o := range options {
185 | if err := o(&msg); err != nil {
186 | return fmt.Errorf("could not configure event: %w", err)
187 | }
188 | }
189 | select {
190 | case s.msgs <- msg:
191 | default:
192 | go s.closeSlow()
193 | }
194 | return nil
195 | }
196 |
197 | // PatchURL sends an event to the client to update the
198 | // query params in the URL.
199 | func (s *Socket) PatchURL(values url.Values) {
200 | s.Send(EventParams, values.Encode())
201 | }
202 |
203 | // Redirect sends a redirect event to the client. This will trigger the browser to
204 | // redirect to a URL.
205 | func (s *Socket) Redirect(u *url.URL) {
206 | s.Send(EventRedirect, u.String())
207 | }
208 |
209 | // AllowUploads indicates that his socket should accept uploads.
210 | func (s *Socket) AllowUploads(config *UploadConfig) {
211 | s.uploadConfigs = append(s.uploadConfigs, config)
212 | }
213 |
214 | // UploadConfigs returns the configs for this socket.
215 | func (s *Socket) UploadConfigs() []*UploadConfig {
216 | return s.uploadConfigs
217 | }
218 |
219 | // Uploads returns the sockets uploads.
220 | func (s *Socket) Uploads() UploadContext {
221 | return s.uploads
222 | }
223 |
224 | // AssignUpload sets uploads to this socket.
225 | func (s *Socket) AssignUpload(config string, upload *Upload) {
226 | if s.uploads == nil {
227 | s.uploads = map[string][]*Upload{}
228 | }
229 | if _, ok := s.uploads[config]; !ok {
230 | s.uploads[config] = []*Upload{}
231 | }
232 | for idx, u := range s.uploads[config] {
233 | if u.Name == upload.Name {
234 | s.uploads[config][idx] = upload
235 | return
236 | }
237 | }
238 | s.uploads[config] = append(s.uploads[config], upload)
239 | }
240 |
241 | // ClearUploads clears this sockets upload map.
242 | func (s *Socket) ClearUploads() {
243 | s.uploads = map[string][]*Upload{}
244 | }
245 |
246 | // ClearUpload clears a specific upload from this socket.
247 | func (s *Socket) ClearUpload(config string, upload *Upload) {
248 | if s.uploads == nil {
249 | s.uploads = map[string][]*Upload{}
250 | }
251 | if _, ok := s.uploads[config]; !ok {
252 | return
253 | }
254 | for idx, u := range s.uploads[config] {
255 | if u.Name == upload.Name {
256 | s.uploads[config] = slices.Delete(s.uploads[config], idx, idx+1)
257 | return
258 | }
259 | }
260 | }
261 |
262 | // LastRender returns the last render result of this socket.
263 | func (s *Socket) LatestRender() *html.Node {
264 | return s.currentRender
265 | }
266 |
267 | // UpdateRender replaces the last render result of this socket.
268 | func (s *Socket) UpdateRender(render *html.Node) {
269 | s.currentRender = render
270 | }
271 |
272 | // Messages returns a channel of event messages sent and received by this socket.
273 | func (s *Socket) Messages() chan Event {
274 | return s.msgs
275 | }
276 |
277 | // assignWS connect a web socket to a socket.
278 | func (s *Socket) assignWS(ws *websocket.Conn) {
279 | s.closeSlow = func() {
280 | ws.Close(websocket.StatusPolicyViolation, "socket too slow to keep up with messages")
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/diff.go:
--------------------------------------------------------------------------------
1 | package live
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "log/slog"
7 | "strings"
8 |
9 | "github.com/google/go-cmp/cmp"
10 | "golang.org/x/net/html"
11 | )
12 |
13 | const _debug = false
14 |
15 | // LiveRendered an attribute key to show that a DOM has been rendered by live.
16 | const LiveRendered = "live-rendered"
17 |
18 | // liveAnchorPrefix prefixes injected anchors.
19 | const liveAnchorPrefix = "_l"
20 | const liveAnchorSep = -1
21 |
22 | // PatchAction available actions to take by a patch.
23 | type PatchAction uint32
24 |
25 | // Actions available.
26 | const (
27 | Noop PatchAction = iota
28 | Replace
29 | Append
30 | Prepend
31 | )
32 |
33 | // anchorGenerator generates an ID for a node in the tree.
34 | type anchorGenerator struct {
35 | idx []int
36 | }
37 |
38 | func newAnchorGenerator() anchorGenerator {
39 | return anchorGenerator{idx: []int{}}
40 | }
41 |
42 | // inc increment the current index.
43 | func (n anchorGenerator) inc() anchorGenerator {
44 | o := make([]int, len(n.idx))
45 | copy(o, n.idx)
46 | o[len(o)-1]++
47 | return anchorGenerator{idx: o}
48 | }
49 |
50 | // level increase the depth.
51 | func (n anchorGenerator) level() anchorGenerator {
52 | o := make([]int, len(n.idx))
53 | copy(o, n.idx)
54 | o = append(o, liveAnchorSep, 0)
55 | return anchorGenerator{idx: o}
56 | }
57 |
58 | func (n anchorGenerator) String() string {
59 | out := liveAnchorPrefix
60 | for _, i := range n.idx {
61 | if i == liveAnchorSep {
62 | out += "_"
63 | } else {
64 | out += fmt.Sprintf("%d", i)
65 | }
66 | }
67 | return out
68 | }
69 |
70 | // Patch a location in the frontend dom.
71 | type Patch struct {
72 | Anchor string
73 | Action PatchAction
74 | HTML string
75 | }
76 |
77 | func (p Patch) String() string {
78 | action := ""
79 | switch p.Action {
80 | case Noop:
81 | action = "NO"
82 | case Replace:
83 | action = "RE"
84 | case Append:
85 | action = "AP"
86 | case Prepend:
87 | action = "PR"
88 | }
89 |
90 | return fmt.Sprintf("%s %s %s", p.Anchor, action, p.HTML)
91 | }
92 |
93 | // Diff compare two node states and return patches.
94 | func Diff(current, proposed *html.Node) ([]Patch, error) {
95 | patches := diffTrees(current, proposed)
96 | output := make([]Patch, len(patches))
97 |
98 | for idx, p := range patches {
99 | var buf bytes.Buffer
100 | if p.Node != nil {
101 | if err := html.Render(&buf, p.Node); err != nil {
102 | return nil, fmt.Errorf("failed to render patch: %w", err)
103 | }
104 | } else {
105 | if _, err := buf.WriteString(""); err != nil {
106 | return nil, fmt.Errorf("failed to render blank patch: %w", err)
107 | }
108 | }
109 |
110 | output[idx] = Patch{
111 | Anchor: p.Anchor,
112 | //Path: p.Path[2:],
113 | Action: p.Action,
114 | HTML: buf.String(),
115 | }
116 | }
117 |
118 | return output, nil
119 | }
120 |
121 | // patch describes how to modify a dom.
122 | type patch struct {
123 | Anchor string
124 | Action PatchAction
125 | Node *html.Node
126 | }
127 |
128 | // differ handles state for recursive diffing.
129 | type differ struct {
130 | // `live-update` handler.
131 | updateNode *html.Node
132 | updateModifier PatchAction
133 | }
134 |
135 | // diffTrees compares two html Nodes and outputs patches.
136 | func diffTrees(current, proposed *html.Node) []patch {
137 | d := &differ{}
138 | anchorTree(current, newAnchorGenerator())
139 | anchorTree(proposed, newAnchorGenerator())
140 | return d.compareNodes(current, proposed, "")
141 | }
142 |
143 | func anchorTree(root *html.Node, id anchorGenerator) {
144 | // Check this node.
145 | if root.NextSibling != nil {
146 | anchorTree(root.NextSibling, id.inc())
147 | }
148 | if root.FirstChild != nil {
149 | anchorTree(root.FirstChild, id.level())
150 | }
151 |
152 | if nodeRelevant(root) && !hasAnchor(root) {
153 | root.Attr = append(root.Attr, html.Attribute{Key: id.String()})
154 | }
155 | }
156 |
157 | func shapeTree(root *html.Node) {
158 | // Check this node.
159 | if root.NextSibling != nil {
160 | shapeTree(root.NextSibling)
161 | }
162 | if root.FirstChild != nil {
163 | shapeTree(root.FirstChild)
164 | }
165 |
166 | // Live is rendering this DOM tree so indicate that it has done so
167 | // so that the client side knows to attempt to connect.
168 | if root.Type == html.ElementNode && root.Data == "body" {
169 | if !hasAttr(root, LiveRendered) {
170 | root.Attr = append(root.Attr, html.Attribute{Key: LiveRendered})
171 | }
172 | }
173 |
174 | debugNodeLog("checking", root)
175 | if !nodeRelevant(root) {
176 | if root.Parent != nil {
177 | debugNodeLog("removingNode", root)
178 | root.Parent.RemoveChild(root)
179 | }
180 | }
181 | }
182 |
183 | func hasAnchor(node *html.Node) bool {
184 | for _, a := range node.Attr {
185 | if strings.HasPrefix(a.Key, liveAnchorPrefix) {
186 | return true
187 | }
188 | }
189 | return false
190 | }
191 |
192 | func hasAttr(node *html.Node, key string) bool {
193 | for _, a := range node.Attr {
194 | if a.Key == key {
195 | return true
196 | }
197 | }
198 | return false
199 | }
200 |
201 | func (d *differ) compareNodes(oldNode, newNode *html.Node, parentAnchor string) []patch {
202 | debugNodeLog("compareNodes oldNode", oldNode)
203 | debugNodeLog("compareNodes newNode", newNode)
204 | patches := []patch{}
205 |
206 | // Same so no patch.
207 | if oldNode == nil && newNode == nil {
208 | return patches
209 | }
210 |
211 | // If oldNode is nothing we need to append the new node.
212 | if oldNode == nil {
213 | if !nodeRelevant(newNode) {
214 | return []patch{}
215 | }
216 | return append(
217 | patches,
218 | d.generatePatch(newNode, parentAnchor, Append),
219 | )
220 | }
221 |
222 | // If newNode does not exist, we need to patch a removal.
223 | if newNode == nil {
224 | if !nodeRelevant(oldNode) {
225 | return []patch{}
226 | }
227 | return append(patches, d.generatePatch(newNode, findAnchor(oldNode), Replace))
228 | }
229 |
230 | // Check for `live-update` modifiers.
231 | d.liveUpdateCheck(newNode)
232 |
233 | // If nodes at this position are not equal patch a replacement.
234 | if !nodeEqual(oldNode, newNode) {
235 | return append(patches, d.generatePatch(newNode, parentAnchor, Replace))
236 | }
237 |
238 | newChildren := generateNodeList(newNode.FirstChild)
239 | oldChildren := generateNodeList(oldNode.FirstChild)
240 |
241 | for i := 0; i < len(newChildren) || i < len(oldChildren); i++ {
242 | if i >= len(newChildren) {
243 | patches = append(patches, d.compareNodes(oldChildren[i], nil, findAnchor(oldNode))...)
244 | } else if i >= len(oldChildren) {
245 | patches = append(patches, d.compareNodes(nil, newChildren[i], findAnchor(oldNode))...)
246 | } else {
247 | patches = append(patches, d.compareNodes(oldChildren[i], newChildren[i], findAnchor(oldNode))...)
248 | }
249 | }
250 |
251 | return patches
252 | }
253 |
254 | func (d *differ) generatePatch(node *html.Node, target string, action PatchAction) patch {
255 | if node == nil {
256 | return patch{
257 | Anchor: d.patchAnchor(target),
258 | Action: d.patchAction(action),
259 | Node: nil,
260 | }
261 | }
262 | debugNodeLog("generatePatch", node)
263 | switch {
264 | case node.Type == html.TextNode:
265 | return patch{
266 | Anchor: d.patchAnchor(target),
267 | Action: d.patchAction(action),
268 | Node: node.Parent,
269 | }
270 | case action == Append:
271 | return patch{
272 | Anchor: d.patchAnchor(target),
273 | Action: d.patchAction(action),
274 | Node: node,
275 | }
276 | default:
277 | return patch{
278 | Anchor: d.patchAnchor(findAnchor(node)),
279 | Action: d.patchAction(action),
280 | Node: node,
281 | }
282 | }
283 | }
284 |
285 | func findAnchor(node *html.Node) string {
286 | for _, a := range node.Attr {
287 | if strings.HasPrefix(a.Key, liveAnchorPrefix) {
288 | return a.Key
289 | }
290 | }
291 | return ""
292 | }
293 |
294 | // liveUpdateCheck check for an update modifier for this node.
295 | func (d *differ) liveUpdateCheck(node *html.Node) {
296 | for _, attr := range node.Attr {
297 | if attr.Key != "live-update" {
298 | continue
299 | }
300 | d.updateNode = node
301 |
302 | switch attr.Val {
303 | case "replace":
304 | d.updateModifier = Replace
305 | case "ignore":
306 | d.updateModifier = Noop
307 | case "append":
308 | d.updateModifier = Append
309 | case "prepend":
310 | d.updateModifier = Prepend
311 | }
312 | break
313 | }
314 | }
315 |
316 | // patchAction in the current state of the differ get the patch
317 | // action.
318 | func (d *differ) patchAction(action PatchAction) PatchAction {
319 | if d.updateNode != nil {
320 | return d.updateModifier
321 | }
322 | return action
323 | }
324 |
325 | // patchAnchor in the current state of the differ get the patch
326 | // anchor.
327 | func (d *differ) patchAnchor(path string) string {
328 | if d.updateNode != nil {
329 | return findAnchor(d.updateNode)
330 | }
331 | return path
332 | }
333 |
334 | // nodeRelevant check if this node is relevant.
335 | func nodeRelevant(node *html.Node) bool {
336 | if node.Type == html.TextNode {
337 | debugNodeLog("textNode", node)
338 | }
339 | if node.Type == html.TextNode && len(strings.TrimSpace(node.Data)) == 0 {
340 | return false
341 | }
342 | return true
343 | }
344 |
345 | // nodeEqual check if one node is equal to another.
346 | func nodeEqual(oldNode *html.Node, newNode *html.Node) bool {
347 | // Type check
348 | if oldNode.Type != newNode.Type {
349 | return false
350 | }
351 | if len(oldNode.Attr) != len(newNode.Attr) {
352 | return false
353 | }
354 | // Deep attr check
355 | for _, c := range newNode.Attr {
356 | found := false
357 | for _, l := range oldNode.Attr {
358 | if cmp.Equal(c, l) {
359 | found = true
360 | break
361 | }
362 | }
363 | if found {
364 | continue
365 | }
366 | return false
367 | }
368 | // Data check
369 | return strings.TrimSpace(oldNode.Data) == strings.TrimSpace(newNode.Data)
370 | }
371 |
372 | // generateNodeList create a list of sibling nodes.
373 | func generateNodeList(node *html.Node) []*html.Node {
374 | list := []*html.Node{}
375 | if node == nil {
376 | return list
377 | }
378 |
379 | current := getFirstSibling(node)
380 | for {
381 | list = append(list, current)
382 | if current.NextSibling == nil {
383 | break
384 | } else {
385 | current = current.NextSibling
386 | }
387 | }
388 | return list
389 | }
390 |
391 | // getFirstSibling takes a node and finds the "first" node in the sibling
392 | // list.
393 | func getFirstSibling(node *html.Node) *html.Node {
394 | if node.PrevSibling == nil {
395 | return node
396 | }
397 | return getFirstSibling(node.PrevSibling)
398 | }
399 |
400 | func debugNodeLog(msg string, node *html.Node) {
401 | if !_debug {
402 | return
403 | }
404 |
405 | if node == nil {
406 | return
407 | }
408 |
409 | var d bytes.Buffer
410 | html.Render(&d, node)
411 | slog.Debug(msg, "type", node.Type, "data", `s"`+node.Data+`"e`, "render", `s"`+d.String()+`"e`)
412 | }
413 |
--------------------------------------------------------------------------------
/web/browser/auto.js:
--------------------------------------------------------------------------------
1 | (()=>{var _=Object.defineProperty;var x=Object.getOwnPropertySymbols;var j=Object.prototype.hasOwnProperty,J=Object.prototype.propertyIsEnumerable;var M=(i,t,e)=>t in i?_(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e,h=(i,t)=>{for(var e in t||(t={}))j.call(t,e)&&M(i,e,t[e]);if(x)for(var e of x(t))J.call(t,e)&&M(i,e,t[e]);return i};var y=class{static hook(t){return t.getAttribute===void 0?null:t.getAttribute("live-hook")}};var X="live:mounted",Q="live:beforeupdate",V="live:updated",Y="live:beforedestroy",Z="live:destroyed",tt="live:disconnected",et="live:reconnected",T="live-connected",A="live-disconnected",st="live-error",k=class{constructor(t,e,s){this.typ=t,this.data=e,s!==void 0?this.id=s:this.id=0}static GetID(){return this.sequence++}serialize(){return JSON.stringify({t:this.typ,i:this.id,d:this.data})}static fromMessage(t){let e=JSON.parse(t);return new k(e.t,e.d,e.i)}},o=k;o.sequence=1;var a=class{constructor(){}static init(t,e){this.hooks=t,this.dom=e,this.eventHandlers={}}static handleEvent(t){t.typ in this.eventHandlers&&this.eventHandlers[t.typ].map(e=>{e(t.data)})}static mounted(t){let e=new CustomEvent(X,{}),s=this.getElementHooks(t);s!==null&&this.callHook(e,t,s.mounted)}static beforeUpdate(t,e){let s=new CustomEvent(Q,{}),n=this.getElementHooks(t);n!==null&&this.callHook(s,t,n.beforeUpdate),this.dom!==void 0&&this.dom.onBeforeElUpdated!==void 0&&this.dom.onBeforeElUpdated(t,e)}static updated(t){let e=new CustomEvent(V,{}),s=this.getElementHooks(t);s!==null&&this.callHook(e,t,s.updated)}static beforeDestroy(t){let e=new CustomEvent(Y,{}),s=this.getElementHooks(t);s!==null&&this.callHook(e,t,s.beforeDestroy)}static destroyed(t){let e=new CustomEvent(Z,{}),s=this.getElementHooks(t);s!==null&&this.callHook(e,t,s.destroyed)}static disconnected(){let t=new CustomEvent(tt,{});document.querySelectorAll("[live-hook]").forEach(e=>{let s=this.getElementHooks(e);s!==null&&this.callHook(t,e,s.disconnected)}),document.body.classList.add(A),document.body.classList.remove(T)}static reconnected(){let t=new CustomEvent(et,{});document.querySelectorAll("[live-hook]").forEach(e=>{let s=this.getElementHooks(e);s!==null&&this.callHook(t,e,s.reconnected)}),document.body.classList.remove(A),document.body.classList.add(T)}static error(){document.body.classList.add(st)}static getElementHooks(t){let e=y.hook(t);return e===null?e:this.hooks[e]}static callHook(t,e,s){if(s===void 0)return;let n=d=>{c.send(d)},r=(d,m)=>{d in this.eventHandlers||(this.eventHandlers[d]=[]),this.eventHandlers[d].push(m)};s.bind({el:e,pushEvent:n,handleEvent:r})(),e.dispatchEvent(t)}};var l=class{static dehydrate(){document.querySelectorAll("form").forEach(e=>{if(e.id===""){console.error("form does not have an ID. DOM updates may be affected",e);return}this.formState[e.id]=[],new FormData(e).forEach((s,n)=>{let r={name:n,value:s,focus:e.querySelector(`[name="${n}"]`)==document.activeElement};this.formState[e.id].push(r)})})}static hydrate(){Object.keys(this.formState).map(t=>{let e=document.querySelector(`#${t}`);if(e===null){delete this.formState[t];return}this.formState[t].map(n=>{let r=e.querySelector(`[name="${n.name}"]`);if(r!==null)switch(r.type){case"file":break;case"checkbox":n.value==="on"&&(r.checked=!0);break;default:r.value=n.value,n.focus===!0&&r.focus();break}})})}static serialize(t){let e={};return new FormData(t).forEach((n,r)=>{switch(!0){case n instanceof File:let d=n,m={name:d.name,type:d.type,size:d.size,lastModified:d.lastModified};Reflect.has(e,this.upKey)||(e[this.upKey]={}),Reflect.has(e[this.upKey],r)||(e[this.upKey][r]=[]),e[this.upKey][r].push(m);break;default:if(!Reflect.has(e,r)){e[r]=n;return}Array.isArray(e[r])||(e[r]=[e[r]]),e[r].push(n)}}),e}static hasFiles(t){let e=new FormData(t),s=!1;return e.forEach(n=>{n instanceof File&&(s=!0)}),s}};l.upKey="uploads",l.formState={};var p=class{static handle(t){l.dehydrate(),t.data.map(p.applyPatch),l.hydrate()}static applyPatch(t){let e=document.querySelector(`*[${t.Anchor}]`);if(e===null)return;let s=p.html2Node(t.HTML);switch(t.Action){case 0:return;case 1:t.HTML===""?a.beforeDestroy(e):a.beforeUpdate(e,s),e.outerHTML=t.HTML,t.HTML===""?a.destroyed(e):a.updated(e);break;case 2:a.beforeUpdate(e,s),e.append(s),a.updated(e);break;case 3:a.beforeUpdate(e,s),e.prepend(s),a.updated(e);break}}static html2Node(t){let e=document.createElement("template");return t=t.trim(),e.innerHTML=t,e.content.firstChild===null?document.createTextNode(t):e.content.firstChild}};function E(i){let t={};if(new URLSearchParams(window.location.search).forEach((n,r)=>{t[r]=n}),i===void 0||!i.hasAttributes())return t;let s=i.attributes;for(let n=0;n{s[r]=n}),s}function b(i,t){if(window.history.pushState({},"",i),t===void 0)c.send(new o("params",h({},w(i))));else{let e=E(t);c.sendAndTrack(new o("params",h(h({},e),w(i)),o.GetID()),t)}}var u=class{constructor(t,e){this.event=t;this.attribute=e;this.limiter=new L}isWired(t){return t.hasAttribute(`${this.attribute}-wired`)?!0:(t.setAttribute(`${this.attribute}-wired`,""),!1)}attach(){document.querySelectorAll(`*[${this.attribute}]`).forEach(t=>{if(this.isWired(t)==!0)return;let e=E(t);t.addEventListener(this.event,s=>{this.limiter.hasDebounce(t)?this.limiter.debounce(t,s,this.handler(t,e)):this.handler(t,e)(s)}),t.addEventListener("ack",s=>{t.classList.remove(`${this.attribute}-loading`)})})}windowAttach(){document.querySelectorAll(`*[${this.attribute}]`).forEach(t=>{if(this.isWired(t)===!0)return;let e=E(t);window.addEventListener(this.event,this.handler(t,e)),window.addEventListener("ack",s=>{t.classList.remove(`${this.attribute}-loading`)})})}handler(t,e){return s=>{let n=t==null?void 0:t.getAttribute(this.attribute);n!==null&&(t.classList.add(`${this.attribute}-loading`),c.sendAndTrack(new o(n,e,o.GetID()),t))}}},f=class extends u{handler(t,e){return s=>{let n=s,r=t==null?void 0:t.getAttribute(this.attribute);if(r===null)return;let d=t.getAttribute("live-key");if(d!==null&&n.key!==d)return;t.classList.add(`${this.attribute}-loading`);let m={key:n.key,altKey:n.altKey,ctrlKey:n.ctrlKey,shiftKey:n.shiftKey,metaKey:n.metaKey};c.sendAndTrack(new o(r,h(h({},e),m),o.GetID()),t)}}},L=class{constructor(){this.debounceAttr="live-debounce"}hasDebounce(t){return t.hasAttribute(this.debounceAttr)}debounce(t,e,s){if(clearTimeout(this.debounceEvent),!this.hasDebounce(t)){s(e);return}let n=t.getAttribute(this.debounceAttr);if(n===null){s(e);return}if(n==="blur"){this.debounceEvent=s,t.addEventListener("blur",()=>{this.debounceEvent()});return}this.debounceEvent=setTimeout(()=>{s(e)},parseInt(n))}},D=class extends u{constructor(){super("click","live-click")}},S=class extends u{constructor(){super("contextmenu","live-contextmenu")}},$=class extends u{constructor(){super("mousedown","live-mousedown")}},P=class extends u{constructor(){super("mouseup","live-mouseup")}},F=class extends u{constructor(){super("focus","live-focus")}},K=class extends u{constructor(){super("blur","live-blur")}},C=class extends u{constructor(){super("focus","live-window-focus")}attach(){this.windowAttach()}},U=class extends u{constructor(){super("blur","live-window-blur")}attach(){this.windowAttach()}},q=class extends f{constructor(){super("keydown","live-keydown")}},W=class extends f{constructor(){super("keyup","live-keyup")}},I=class extends f{constructor(){super("keydown","live-window-keydown")}attach(){this.windowAttach()}},R=class extends f{constructor(){super("keyup","live-window-keyup")}attach(){this.windowAttach()}},N=class{constructor(){this.attribute="live-change";this.limiter=new L}isWired(t){return t.hasAttribute(`${this.attribute}-wired`)?!0:(t.setAttribute(`${this.attribute}-wired`,""),!1)}attach(){let t=[];document.querySelectorAll(`form[${this.attribute}]`).forEach(e=>{e.addEventListener("ack",s=>{e.classList.remove(`${this.attribute}-loading`)}),t.push(e),e.querySelectorAll("input,select,textarea").forEach(s=>{this.addEvent(e,s)})}),t.forEach(e=>{document.querySelectorAll(`[form=${e.getAttribute("id")}]`).forEach(s=>{this.addEvent(e,s)})})}addEvent(t,e){this.isWired(e)||e.addEventListener("input",s=>{this.limiter.hasDebounce(e)?this.limiter.debounce(e,s,()=>{this.handler(t)}):this.handler(t)})}handler(t){let e=t==null?void 0:t.getAttribute(this.attribute);if(e===null)return;let s=l.serialize(t);t.classList.add(`${this.attribute}-loading`),c.sendAndTrack(new o(e,s,o.GetID()),t)}},O=class extends u{constructor(){super("submit","live-submit")}handler(t,e){return s=>{if(s.preventDefault&&s.preventDefault(),l.hasFiles(t)===!0){let r=new XMLHttpRequest;r.open("POST",""),r.addEventListener("load",()=>{this.sendEvent(t,e)}),r.send(new FormData(t))}else this.sendEvent(t,e);return!1}}sendEvent(t,e){let s=t==null?void 0:t.getAttribute(this.attribute);if(s===null)return;var n=h({},e);let r=l.serialize(t);Object.keys(r).map(d=>{n[d]=r[d]}),t.classList.add(`${this.attribute}-loading`),c.sendAndTrack(new o(s,n,o.GetID()),t)}},B=class extends u{constructor(){super("","live-hook")}attach(){document.querySelectorAll(`[${this.attribute}]`).forEach(t=>{this.isWired(t)!=!0&&a.mounted(t)})}},G=class extends u{constructor(){super("click","live-patch")}handler(t,e){return s=>{s.preventDefault&&s.preventDefault();let n=t.getAttribute("href");if(n!==null)return b(n,t),!1}}},v=class{static init(){this.clicks=new D,this.contextmenu=new S,this.mousedown=new $,this.mouseup=new P,this.focus=new F,this.blur=new K,this.windowFocus=new C,this.windowBlur=new U,this.keydown=new q,this.keyup=new W,this.windowKeydown=new I,this.windowKeyup=new R,this.change=new N,this.submit=new O,this.hook=new B,this.patch=new G,this.handleBrowserNav()}static rewire(){this.clicks.attach(),this.contextmenu.attach(),this.mousedown.attach(),this.mouseup.attach(),this.focus.attach(),this.blur.attach(),this.windowFocus.attach(),this.windowBlur.attach(),this.keydown.attach(),this.keyup.attach(),this.windowKeyup.attach(),this.windowKeydown.attach(),this.change.attach(),this.submit.attach(),this.hook.attach(),this.patch.attach()}static handleBrowserNav(){window.onpopstate=function(t){c.send(new o("params",w(document.location.search),o.GetID()))}}};var z="_psid",g=class{constructor(){}static getID(){if(this.id)return this.id;let e=`; ${document.cookie}`.split(`; ${z}=`);if(e&&e.length===2){let s=e.pop();return s?s.split(";").shift():""}return""}static setCookie(){var t=new Date;t.setTime(t.getTime()+60*1e3),document.cookie=`${z}=${this.id}; expires=${t.toUTCString()}; path=/`}static dial(){this.trackedEvents={},this.id=this.getID(),this.setCookie(),console.debug("Socket.dial called",this.id),this.conn=new WebSocket(`${location.protocol==="https:"?"wss":"ws"}://${location.host}${location.pathname}${location.search}${location.hash}`),this.conn.addEventListener("close",t=>{this.ready=!1,console.warn(`WebSocket Disconnected code: ${t.code}, reason: ${t.reason}`),t.code!==1001&&(this.disconnectNotified===!1&&(a.disconnected(),this.disconnectNotified=!0),setTimeout(()=>{g.dial()},1e3))}),this.conn.addEventListener("open",t=>{a.reconnected(),this.disconnectNotified=!1,this.ready=!0}),this.conn.addEventListener("message",t=>{if(typeof t.data!="string"){console.error("unexpected message type",typeof t.data);return}let e=o.fromMessage(t.data);switch(e.typ){case"patch":p.handle(e),v.rewire();break;case"params":b(`${window.location.pathname}?${e.data}`);break;case"redirect":window.location.replace(e.data);break;case"ack":this.ack(e);break;case"err":a.error();default:a.handleEvent(e)}})}static sendAndTrack(t,e){if(this.ready===!1){console.warn("connection not ready for send of event",t);return}this.trackedEvents[t.id]={ev:t,el:e},this.conn.send(t.serialize())}static send(t){if(this.ready===!1){console.warn("connection not ready for send of event",t);return}this.conn.send(t.serialize())}static ack(t){t.id in this.trackedEvents&&(this.trackedEvents[t.id].el.dispatchEvent(new Event("ack")),delete this.trackedEvents[t.id])}},c=g;c.ready=!1,c.disconnectNotified=!1;var H=class{constructor(t,e){this.hooks=t;this.dom=e}init(){document.querySelector("[live-rendered]")!==null&&(a.init(this.hooks,this.dom),c.dial(),v.init(),v.rewire())}send(t,e,s){let n=new o(t,e,s);c.send(n)}};document.addEventListener("DOMContentLoaded",i=>{window.Live!==void 0&&console.error("window.Live already defined");let t=window.Hooks||{};window.Live=new H(t),window.Live.init()});})();
2 | //# sourceMappingURL=auto.js.map
3 |
--------------------------------------------------------------------------------
/web/src/events.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from "./socket";
2 | import { Forms } from "./forms";
3 | import { UpdateURLParams, GetParams, GetURLParams, Params } from "./params";
4 | import { EventDispatch, LiveEvent } from "./event";
5 |
6 | /**
7 | * Standard event handler class. Clicks, focus and blur.
8 | */
9 | class LiveHandler {
10 | protected limiter = new Limiter();
11 |
12 | constructor(protected event: string, protected attribute: string) {}
13 |
14 | public isWired(element: Element): boolean {
15 | if (element.hasAttribute(`${this.attribute}-wired`)) {
16 | return true;
17 | }
18 | element.setAttribute(`${this.attribute}-wired`, "");
19 | return false;
20 | }
21 |
22 | public attach() {
23 | document
24 | .querySelectorAll(`*[${this.attribute}]`)
25 | .forEach((element: Element) => {
26 | if (this.isWired(element) == true) {
27 | return;
28 | }
29 | const params = GetParams(element as HTMLElement);
30 | element.addEventListener(this.event, (e) => {
31 | if (this.limiter.hasDebounce(element)) {
32 | this.limiter.debounce(
33 | element,
34 | e,
35 | this.handler(element as HTMLFormElement, params)
36 | );
37 | } else {
38 | this.handler(element as HTMLFormElement, params)(e);
39 | }
40 | });
41 | element.addEventListener("ack", (_) => {
42 | element.classList.remove(`${this.attribute}-loading`);
43 | });
44 | });
45 | }
46 |
47 | protected windowAttach() {
48 | document
49 | .querySelectorAll(`*[${this.attribute}]`)
50 | .forEach((element: Element) => {
51 | if (this.isWired(element) === true) {
52 | return;
53 | }
54 | const params = GetParams(element as HTMLElement);
55 | window.addEventListener(
56 | this.event,
57 | this.handler(element as HTMLElement, params)
58 | );
59 | window.addEventListener("ack", (_) => {
60 | element.classList.remove(`${this.attribute}-loading`);
61 | });
62 | });
63 | }
64 |
65 | protected handler(element: HTMLElement, params: Params): EventListener {
66 | return (_: Event) => {
67 | const t = element?.getAttribute(this.attribute);
68 | if (t === null) {
69 | return;
70 | }
71 | element.classList.add(`${this.attribute}-loading`);
72 | Socket.sendAndTrack(
73 | new LiveEvent(t, params, LiveEvent.GetID()),
74 | element
75 | );
76 | };
77 | }
78 | }
79 |
80 | /**
81 | * KeyHandler handle key events.
82 | */
83 | export class KeyHandler extends LiveHandler {
84 | protected handler(element: HTMLElement, params: Params): EventListener {
85 | return (ev: Event) => {
86 | const ke = ev as KeyboardEvent;
87 | const t = element?.getAttribute(this.attribute);
88 | if (t === null) {
89 | return;
90 | }
91 | const filter = element.getAttribute("live-key");
92 | if (filter !== null) {
93 | if (ke.key !== filter) {
94 | return;
95 | }
96 | }
97 | element.classList.add(`${this.attribute}-loading`);
98 | const keyData = {
99 | key: ke.key,
100 | altKey: ke.altKey,
101 | ctrlKey: ke.ctrlKey,
102 | shiftKey: ke.shiftKey,
103 | metaKey: ke.metaKey,
104 | };
105 | Socket.sendAndTrack(
106 | new LiveEvent(t, { ...params, ...keyData }, LiveEvent.GetID()),
107 | element
108 | );
109 | };
110 | }
111 | }
112 |
113 | class Limiter {
114 | private debounceAttr = "live-debounce";
115 | private debounceEvent: any;
116 |
117 | public hasDebounce(element: Element): boolean {
118 | return element.hasAttribute(this.debounceAttr);
119 | }
120 |
121 | public debounce(element: Element, e: Event, fn: EventListener) {
122 | clearTimeout(this.debounceEvent);
123 | if (!this.hasDebounce(element)) {
124 | fn(e);
125 | return;
126 | }
127 | const debounce = element.getAttribute(this.debounceAttr);
128 | if (debounce === null) {
129 | fn(e);
130 | return;
131 | }
132 | if (debounce === "blur") {
133 | this.debounceEvent = fn;
134 | element.addEventListener("blur", () => {
135 | this.debounceEvent();
136 | });
137 | return;
138 | }
139 | this.debounceEvent = setTimeout(() => {
140 | fn(e);
141 | }, parseInt(debounce));
142 | }
143 | }
144 |
145 | /**
146 | * live-click attribute handling.
147 | */
148 | class Click extends LiveHandler {
149 | constructor() {
150 | super("click", "live-click");
151 | }
152 | }
153 |
154 | /**
155 | * live-contextmenu attribute handling.
156 | */
157 | class Contextmenu extends LiveHandler {
158 | constructor() {
159 | super("contextmenu", "live-contextmenu");
160 | }
161 | }
162 |
163 | /**
164 | * live-mousedown attribute handling.
165 | */
166 | class Mousedown extends LiveHandler {
167 | constructor() {
168 | super("mousedown", "live-mousedown");
169 | }
170 | }
171 |
172 | /**
173 | * live-mouseup attribute handling.
174 | */
175 | class Mouseup extends LiveHandler {
176 | constructor() {
177 | super("mouseup", "live-mouseup");
178 | }
179 | }
180 |
181 | /**
182 | * live-focus event handling.
183 | */
184 | class Focus extends LiveHandler {
185 | constructor() {
186 | super("focus", "live-focus");
187 | }
188 | }
189 |
190 | /**
191 | * live-blur event handling.
192 | */
193 | class Blur extends LiveHandler {
194 | constructor() {
195 | super("blur", "live-blur");
196 | }
197 | }
198 |
199 | /**
200 | * live-window-focus event handler.
201 | */
202 | class WindowFocus extends LiveHandler {
203 | constructor() {
204 | super("focus", "live-window-focus");
205 | }
206 |
207 | public attach() {
208 | this.windowAttach();
209 | }
210 | }
211 |
212 | /**
213 | * live-window-blur event handler.
214 | */
215 | class WindowBlur extends LiveHandler {
216 | constructor() {
217 | super("blur", "live-window-blur");
218 | }
219 |
220 | public attach() {
221 | this.windowAttach();
222 | }
223 | }
224 |
225 | /**
226 | * live-keydown event handler.
227 | */
228 | class Keydown extends KeyHandler {
229 | constructor() {
230 | super("keydown", "live-keydown");
231 | }
232 | }
233 |
234 | /**
235 | * live-keyup event handler.
236 | */
237 | class Keyup extends KeyHandler {
238 | constructor() {
239 | super("keyup", "live-keyup");
240 | }
241 | }
242 |
243 | /**
244 | * live-window-keydown event handler.
245 | */
246 | class WindowKeydown extends KeyHandler {
247 | constructor() {
248 | super("keydown", "live-window-keydown");
249 | }
250 |
251 | public attach() {
252 | this.windowAttach();
253 | }
254 | }
255 |
256 | /**
257 | * live-window-keyup event handler.
258 | */
259 | class WindowKeyup extends KeyHandler {
260 | constructor() {
261 | super("keyup", "live-window-keyup");
262 | }
263 |
264 | public attach() {
265 | this.windowAttach();
266 | }
267 | }
268 |
269 | /**
270 | * live-change form handler.
271 | */
272 | class Change {
273 | protected attribute = "live-change";
274 | protected limiter = new Limiter();
275 |
276 | constructor() {}
277 |
278 | public isWired(element: Element): boolean {
279 | if (element.hasAttribute(`${this.attribute}-wired`)) {
280 | return true;
281 | }
282 | element.setAttribute(`${this.attribute}-wired`, "");
283 | return false;
284 | }
285 |
286 | public attach() {
287 | let forms: Element[] = [];
288 | document
289 | .querySelectorAll(`form[${this.attribute}]`)
290 | .forEach((element: Element) => {
291 | element.addEventListener("ack", (_) => {
292 | element.classList.remove(`${this.attribute}-loading`);
293 | });
294 | forms.push(element);
295 | element
296 | .querySelectorAll(`input,select,textarea`)
297 | .forEach((childElement: Element) => {
298 | this.addEvent(element, childElement);
299 | });
300 | });
301 | forms.forEach((element: Element) => {
302 | document
303 | .querySelectorAll(`[form=${element.getAttribute("id")}]`)
304 | .forEach((childElement) => {
305 | this.addEvent(element, childElement);
306 | });
307 | });
308 | };
309 |
310 | private addEvent(element: Element, childElement: Element) {
311 | if (this.isWired(childElement)) {
312 | return;
313 | }
314 | childElement.addEventListener("input", (e) => {
315 | if (this.limiter.hasDebounce(childElement)) {
316 | this.limiter.debounce(childElement, e, () => {
317 | this.handler(element as HTMLFormElement);
318 | });
319 | } else {
320 | this.handler(element as HTMLFormElement);
321 | }
322 | });
323 | }
324 |
325 | private handler(element: HTMLFormElement) {
326 | const t = element?.getAttribute(this.attribute);
327 | if (t === null) {
328 | return;
329 | }
330 | const values: { [key: string]: any } = Forms.serialize(element);
331 | element.classList.add(`${this.attribute}-loading`);
332 | Socket.sendAndTrack(
333 | new LiveEvent(t, values, LiveEvent.GetID()),
334 | element
335 | );
336 | }
337 | }
338 |
339 | /**
340 | * live-submit form handler.
341 | */
342 | class Submit extends LiveHandler {
343 | constructor() {
344 | super("submit", "live-submit");
345 | }
346 |
347 | protected handler(element: HTMLElement, params: Params): EventListener {
348 | return (e: Event) => {
349 | if (e.preventDefault) e.preventDefault();
350 |
351 | const hasFiles = Forms.hasFiles(element as HTMLFormElement);
352 | if (hasFiles === true) {
353 | const request = new XMLHttpRequest();
354 | request.open("POST", "");
355 | request.addEventListener('load', () => {
356 | this.sendEvent(element, params);
357 | });
358 |
359 | request.send(new FormData(element as HTMLFormElement));
360 | } else {
361 | this.sendEvent(element, params);
362 | }
363 | return false;
364 | };
365 | }
366 |
367 | protected sendEvent(element: HTMLElement, params: Params) {
368 | const t = element?.getAttribute(this.attribute);
369 | if (t === null) {
370 | return;
371 | }
372 |
373 | var vals = { ...params };
374 |
375 | const data: { [key: string]: any } = Forms.serialize(
376 | element as HTMLFormElement
377 | );
378 | Object.keys(data).map((k) => {
379 | vals[k] = data[k];
380 | });
381 | element.classList.add(`${this.attribute}-loading`);
382 | Socket.sendAndTrack(
383 | new LiveEvent(t, vals, LiveEvent.GetID()),
384 | element
385 | );
386 | }
387 | }
388 |
389 | /**
390 | * live-hook event handler.
391 | */
392 | class Hook extends LiveHandler {
393 | constructor() {
394 | super("", "live-hook");
395 | }
396 |
397 | public attach() {
398 | document
399 | .querySelectorAll(`[${this.attribute}]`)
400 | .forEach((element: Element) => {
401 | if (this.isWired(element) == true) {
402 | return;
403 | }
404 | EventDispatch.mounted(element);
405 | });
406 | }
407 | }
408 |
409 | /**
410 | * live-patch event handler.
411 | */
412 | class Patch extends LiveHandler {
413 | constructor() {
414 | super("click", "live-patch");
415 | }
416 |
417 | protected handler(element: HTMLElement, _: Params): EventListener {
418 | return (e: Event) => {
419 | if (e.preventDefault) e.preventDefault();
420 | const path = element.getAttribute("href");
421 | if (path === null) {
422 | return;
423 | }
424 | UpdateURLParams(path, element);
425 | return false;
426 | };
427 | }
428 | }
429 |
430 | /**
431 | * Handle all events.
432 | */
433 | export class Events {
434 | private static clicks: Click;
435 | private static contextmenu: Contextmenu;
436 | private static mousedown: Mousedown;
437 | private static mouseup: Mouseup;
438 | private static focus: Focus;
439 | private static blur: Blur;
440 | private static windowFocus: WindowFocus;
441 | private static windowBlur: WindowBlur;
442 | private static keydown: Keydown;
443 | private static keyup: Keyup;
444 | private static windowKeydown: WindowKeydown;
445 | private static windowKeyup: WindowKeyup;
446 | private static change: Change;
447 | private static submit: Submit;
448 | private static hook: Hook;
449 | private static patch: Patch;
450 |
451 | /**
452 | * Initialise all the event wiring.
453 | */
454 | public static init() {
455 | this.clicks = new Click();
456 | this.contextmenu = new Contextmenu();
457 | this.mousedown = new Mousedown();
458 | this.mouseup = new Mouseup();
459 | this.focus = new Focus();
460 | this.blur = new Blur();
461 | this.windowFocus = new WindowFocus();
462 | this.windowBlur = new WindowBlur();
463 | this.keydown = new Keydown();
464 | this.keyup = new Keyup();
465 | this.windowKeydown = new WindowKeydown();
466 | this.windowKeyup = new WindowKeyup();
467 | this.change = new Change();
468 | this.submit = new Submit();
469 | this.hook = new Hook();
470 | this.patch = new Patch();
471 |
472 | this.handleBrowserNav();
473 | }
474 |
475 | /**
476 | * Re-attach all events when we have re-rendered.
477 | */
478 | public static rewire() {
479 | this.clicks.attach();
480 | this.contextmenu.attach();
481 | this.mousedown.attach();
482 | this.mouseup.attach();
483 | this.focus.attach();
484 | this.blur.attach();
485 | this.windowFocus.attach();
486 | this.windowBlur.attach();
487 | this.keydown.attach();
488 | this.keyup.attach();
489 | this.windowKeyup.attach();
490 | this.windowKeydown.attach();
491 | this.change.attach();
492 | this.submit.attach();
493 | this.hook.attach();
494 | this.patch.attach();
495 | }
496 |
497 | /**
498 | * Watch the browser popstate so that we can send a params
499 | * change event to the server.
500 | */
501 | private static handleBrowserNav() {
502 | window.onpopstate = function (_: any) {
503 | Socket.send(
504 | new LiveEvent(
505 | "params",
506 | GetURLParams(document.location.search),
507 | LiveEvent.GetID()
508 | )
509 | );
510 | };
511 | }
512 | }
513 |
--------------------------------------------------------------------------------
/examples/go.sum:
--------------------------------------------------------------------------------
1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 | cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
3 | cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
4 | cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
5 | cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
6 | cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
7 | cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
8 | cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
9 | cloud.google.com/go/iam v1.4.2 h1:4AckGYAYsowXeHzsn/LCKWIwSWLkdb0eGjH8wWkd27Q=
10 | cloud.google.com/go/iam v1.4.2/go.mod h1:REGlrt8vSlh4dfCJfSEcNjLGq75wW75c5aU3FLOYq34=
11 | cloud.google.com/go/pubsub v1.48.0 h1:ntFpQVrr10Wj/GXSOpxGmexGynldv/bFp25H0jy8aOs=
12 | cloud.google.com/go/pubsub v1.48.0/go.mod h1:AAtyjyIT/+zaY1ERKFJbefOvkUxRDNp3nD6TdfdqUZk=
13 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
14 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
15 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
16 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
17 | github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
18 | github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
21 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
22 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
23 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
24 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
25 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
26 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
27 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
28 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
29 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
30 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
31 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
32 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
33 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
34 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
35 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
36 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
37 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
38 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
39 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
40 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
41 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
42 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
43 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
44 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
45 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
46 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
47 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
48 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
49 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
50 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
51 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
52 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
53 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
54 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
55 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
56 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
57 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
58 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
59 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
60 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
61 | github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
62 | github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
63 | github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
64 | github.com/maragudk/gomponents v0.22.0 h1:0gNrSDC1nM6w0Vxj5wgGXqV8frDH9UVPE+dEyy4ApPQ=
65 | github.com/maragudk/gomponents v0.22.0/go.mod h1:nHkNnZL6ODgMBeJhrZjkMHVvNdoYsfmpKB2/hjdQ0Hg=
66 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
67 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
68 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
69 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
70 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
71 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
72 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
73 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
74 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
75 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
76 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
77 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
78 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
79 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
80 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
81 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
82 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
83 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
84 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
85 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
86 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
87 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
88 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
89 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
90 | go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
91 | go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
92 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
93 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
94 | gocloud.dev v0.41.0 h1:qBKd9jZkBKEghYbP/uThpomhedK5s2Gy6Lz7h/zYYrM=
95 | gocloud.dev v0.41.0/go.mod h1:IetpBcWLUwroOOxKr90lhsZ8vWxeSkuszBnW62sbcf0=
96 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
97 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
98 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
99 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
100 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
101 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
102 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
103 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
104 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
105 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
106 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
107 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
108 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
109 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
110 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
111 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
112 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
113 | golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
114 | golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
115 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
116 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
117 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
118 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
119 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
120 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
121 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
122 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
123 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
124 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
125 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
126 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
127 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
128 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
129 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
130 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
131 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
132 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
133 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
134 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
135 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
136 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
137 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
138 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
139 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
140 | google.golang.org/api v0.228.0 h1:X2DJ/uoWGnY5obVjewbp8icSL5U4FzuCfy9OjbLSnLs=
141 | google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4=
142 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
143 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
144 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
145 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
146 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
147 | google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 h1:qEFnJI6AnfZk0NNe8YTyXQh5i//Zxi4gBHwRgp76qpw=
148 | google.golang.org/genproto v0.0.0-20250324211829-b45e905df463/go.mod h1:SqIx1NV9hcvqdLHo7uNZDS5lrUJybQ3evo3+z/WBfA0=
149 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM=
150 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8=
151 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a h1:GIqLhp/cYUkuGuiT+vJk8vhOP86L4+SP5j8yXgeVpvI=
152 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
153 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
154 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
155 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
156 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
157 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
158 | google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
159 | google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
160 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
161 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
162 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
163 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
164 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
165 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
166 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
167 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
168 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
169 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
170 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
171 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
172 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
173 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
174 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
175 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
176 |
--------------------------------------------------------------------------------
/engine.go:
--------------------------------------------------------------------------------
1 | package live
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "errors"
8 | "fmt"
9 | "io"
10 | "log/slog"
11 | "mime/multipart"
12 | "net/http"
13 | "os"
14 | "path/filepath"
15 | "slices"
16 | "strings"
17 | "time"
18 |
19 | "github.com/coder/websocket"
20 | "golang.org/x/net/html"
21 | "golang.org/x/time/rate"
22 | )
23 |
24 | // EngineConfig applies configuration to an engine.
25 | type EngineConfig func(e *Engine) error
26 |
27 | // WithWebsocketAcceptOptions apply websocket accept options to the HTTP engine.
28 | func WithWebsocketAcceptOptions(options *websocket.AcceptOptions) EngineConfig {
29 | return func(e *Engine) error {
30 | e.acceptOptions = options
31 | return nil
32 | }
33 | }
34 |
35 | // WithSocketStateStore set the engines socket state store.
36 | func WithSocketStateStore(sss SocketStateStore) EngineConfig {
37 | return func(e *Engine) error {
38 | e.socketStateStore = sss
39 | return nil
40 | }
41 | }
42 |
43 | func WithWebsocketMaxMessageSize(n int64) EngineConfig {
44 | return func(e *Engine) error {
45 | n = max(n, -1)
46 | e.MaxMessageSize = n
47 | return nil
48 | }
49 | }
50 |
51 | // BroadcastHandler a way for processes to communicate.
52 | type BroadcastHandler func(ctx context.Context, e *Engine, msg Event)
53 |
54 | // Engine handles live inner workings.
55 | type Engine struct {
56 | // Handler implements all the developer defined logic.
57 | Handler *Handler
58 |
59 | // BroadcastLimiter limit broadcast ratehandler.
60 | BroadcastLimiter *rate.Limiter
61 | // broadcast handle a broadcast.
62 | BroadcastHandler BroadcastHandler
63 |
64 | // socket handling channels.
65 | addSocketC chan engineAddSocket
66 | getSocketC chan engineGetSocket
67 | deleteSocketC chan engineDeleteSocket
68 | iterateSocketsC chan engineIterateSockets
69 |
70 | // IgnoreFaviconRequest setting to ignore requests for /favicon.ico.
71 | IgnoreFaviconRequest bool
72 |
73 | // MaxUploadSize the maximum upload size in bytes to allow. This defaults
74 | // too 100MB.
75 | MaxUploadSize int64
76 |
77 | // MaxMessageSize is the maximum size of websocket messages before they are rejected. Defaults to 32K (32768). Can be set to -1 to disable.
78 | MaxMessageSize int64
79 |
80 | // UploadStagingLocation where uploads are stored before they are consumed. This defaults
81 | // too the default OS temp directory.
82 | UploadStagingLocation string
83 |
84 | acceptOptions *websocket.AcceptOptions
85 | socketStateStore SocketStateStore
86 | }
87 |
88 | type engineAddSocket struct {
89 | Socket *Socket
90 | resp chan struct{}
91 | }
92 |
93 | type engineGetSocket struct {
94 | ID SocketID
95 | resp chan *Socket
96 | err chan error
97 | }
98 |
99 | type engineDeleteSocket struct {
100 | ID SocketID
101 | resp chan struct{}
102 | }
103 |
104 | type engineIterateSockets struct {
105 | resp chan *Socket
106 | done chan bool
107 | }
108 |
109 | func (e *Engine) operate(ctx context.Context) {
110 | socketMap := map[SocketID]*Socket{}
111 | for {
112 | select {
113 | case op := <-e.addSocketC:
114 | socketMap[op.Socket.ID()] = op.Socket
115 | op.resp <- struct{}{}
116 | case op := <-e.getSocketC:
117 | s, ok := socketMap[op.ID]
118 | if !ok {
119 | op.err <- ErrNoSocket
120 | continue
121 | }
122 | op.resp <- s
123 | case op := <-e.deleteSocketC:
124 | delete(socketMap, op.ID)
125 | op.resp <- struct{}{}
126 | case op := <-e.iterateSocketsC:
127 | for _, s := range socketMap {
128 | op.resp <- s
129 | }
130 | op.done <- true
131 | case <-ctx.Done():
132 | return
133 | }
134 | }
135 | }
136 |
137 | // NewHttpHandler serve the handler.
138 | func NewHttpHandler(ctx context.Context, h *Handler, configs ...EngineConfig) *Engine {
139 | const maxUploadSize = 100 * 1024 * 1024
140 | e := &Engine{
141 | BroadcastLimiter: rate.NewLimiter(rate.Every(time.Millisecond*100), 8),
142 | BroadcastHandler: func(ctx context.Context, h *Engine, msg Event) {
143 | h.self(ctx, nil, msg)
144 | },
145 | IgnoreFaviconRequest: true,
146 | MaxUploadSize: maxUploadSize,
147 | MaxMessageSize: 32768,
148 | Handler: h,
149 | addSocketC: make(chan engineAddSocket),
150 | getSocketC: make(chan engineGetSocket),
151 | deleteSocketC: make(chan engineDeleteSocket),
152 | iterateSocketsC: make(chan engineIterateSockets),
153 | }
154 | for _, conf := range configs {
155 | if err := conf(e); err != nil {
156 | slog.Warn(fmt.Sprintf("could not apply config to engine: %s", err))
157 | }
158 | }
159 | if e.socketStateStore == nil {
160 | e.socketStateStore = NewMemorySocketStateStore(ctx)
161 | }
162 | go e.operate(ctx)
163 | return e
164 | }
165 |
166 | // Broadcast send a message to all sockets connected to this engine.
167 | func (e *Engine) Broadcast(event string, data any) error {
168 | ev := Event{T: event, SelfData: data}
169 | ctx := context.Background()
170 | e.BroadcastLimiter.Wait(ctx)
171 | e.BroadcastHandler(ctx, e, ev)
172 | return nil
173 | }
174 |
175 | // self sends a message to the socket on this engine.
176 | func (e *Engine) self(ctx context.Context, sock *Socket, msg Event) {
177 | // If the socket is nil, this is broadcast message.
178 | if sock == nil {
179 | op := engineIterateSockets{
180 | resp: make(chan *Socket),
181 | done: make(chan bool),
182 | }
183 | e.iterateSocketsC <- op
184 | for {
185 | select {
186 | case socket := <-op.resp:
187 | e.handleEmittedEvent(ctx, socket, msg)
188 | case <-op.done:
189 | return
190 | }
191 | }
192 | } else {
193 | if err := e.hasSocket(sock); err != nil {
194 | return
195 | }
196 | e.handleEmittedEvent(ctx, sock, msg)
197 | }
198 | }
199 |
200 | func (e *Engine) handleEmittedEvent(ctx context.Context, s *Socket, msg Event) {
201 | if err := e.handleSelf(ctx, msg.T, s, msg); err != nil {
202 | slog.Error("server event error", "err", err)
203 | }
204 | render, err := RenderSocket(ctx, e, s)
205 | if err != nil {
206 | slog.Error("socket render error", "err", err)
207 | }
208 | s.UpdateRender(render)
209 | }
210 |
211 | // AddSocket add a socket to the engine.
212 | func (e *Engine) AddSocket(sock *Socket) {
213 | op := engineAddSocket{
214 | Socket: sock,
215 | resp: make(chan struct{}),
216 | }
217 | defer close(op.resp)
218 | e.addSocketC <- op
219 | <-op.resp
220 | }
221 |
222 | // GetSocket get a socket from a session.
223 | func (e *Engine) GetSocket(ID SocketID) (*Socket, error) {
224 | op := engineGetSocket{
225 | ID: ID,
226 | resp: make(chan *Socket),
227 | err: make(chan error),
228 | }
229 | defer close(op.resp)
230 | defer close(op.err)
231 | e.getSocketC <- op
232 | select {
233 | case s := <-op.resp:
234 | return s, nil
235 | case err := <-op.err:
236 | return nil, err
237 | }
238 | }
239 |
240 | // DeleteSocket remove a socket from the engine.
241 | func (e *Engine) DeleteSocket(sock *Socket) {
242 | op := engineDeleteSocket{
243 | ID: sock.ID(),
244 | resp: make(chan struct{}),
245 | }
246 | defer close(op.resp)
247 | e.deleteSocketC <- op
248 | <-op.resp
249 | if err := e.Handler.UnmountHandler(sock); err != nil {
250 | slog.Error("socket unmount error", "err", err)
251 | }
252 | e.socketStateStore.Delete(sock.ID())
253 | }
254 |
255 | // CallEvent route an event to the correct handler.
256 | func (e *Engine) CallEvent(ctx context.Context, t string, sock *Socket, msg Event) error {
257 | handler, err := e.Handler.getEvent(t)
258 | if err != nil {
259 | return err
260 | }
261 |
262 | params, err := msg.Params()
263 | if err != nil {
264 | return fmt.Errorf("received message and could not extract params: %w", err)
265 | }
266 |
267 | data, err := handler(ctx, sock, params)
268 | if err != nil {
269 | return err
270 | }
271 | sock.Assign(data)
272 |
273 | return nil
274 | }
275 |
276 | // handleSelf route an event to the correct handler.
277 | func (e *Engine) handleSelf(ctx context.Context, t string, sock *Socket, msg Event) error {
278 | handler, err := e.Handler.getSelf(t)
279 | if err != nil {
280 | return fmt.Errorf("no self event handler for %s: %w", t, ErrNoEventHandler)
281 | }
282 |
283 | data, err := handler(ctx, sock, msg.SelfData)
284 | if err != nil {
285 | return fmt.Errorf("handler self event handler error [%s]: %w", t, err)
286 | }
287 | sock.Assign(data)
288 |
289 | return nil
290 | }
291 |
292 | // CallParams on params change run the handler.
293 | func (e *Engine) CallParams(ctx context.Context, sock *Socket, msg Event) error {
294 | params, err := msg.Params()
295 | if err != nil {
296 | return fmt.Errorf("received params message and could not extract params: %w", err)
297 | }
298 |
299 | for _, ph := range e.Handler.paramsHandlers {
300 | data, err := ph(ctx, sock, params)
301 | if err != nil {
302 | return fmt.Errorf("handler params handler error: %w", err)
303 | }
304 | sock.Assign(data)
305 | }
306 |
307 | return nil
308 | }
309 |
310 | // hasSocket check a socket is there error if it isn't connected or
311 | // doesn't exist.
312 | func (e *Engine) hasSocket(s *Socket) error {
313 | _, err := e.GetSocket(s.ID())
314 | if err != nil {
315 | return ErrNoSocket
316 | }
317 | return nil
318 | }
319 |
320 | // ServeHTTP serves this handler.
321 | func (e *Engine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
322 | if r.URL.Path == "/favicon.ico" {
323 | if e.IgnoreFaviconRequest {
324 | w.WriteHeader(404)
325 | return
326 | }
327 | }
328 |
329 | // Check if we are going to upgrade to a websocket.
330 | upgrade := slices.Contains(r.Header["Upgrade"], "websocket")
331 |
332 | ctx := httpContext(w, r)
333 |
334 | if !upgrade {
335 | switch r.Method {
336 | case http.MethodPost:
337 | e.post(ctx, w, r)
338 | default:
339 | e.get(ctx, w, r)
340 | }
341 | return
342 | }
343 |
344 | // Upgrade to the websocket version.
345 | e.serveWS(ctx, w, r)
346 | }
347 |
348 | // post handler.
349 | func (e *Engine) post(ctx context.Context, w http.ResponseWriter, r *http.Request) {
350 | // Get socket.
351 | sock, err := NewSocketFromRequest(ctx, e, r)
352 | if err != nil {
353 | e.Handler.ErrorHandler(ctx, err)
354 | return
355 | }
356 |
357 | r.Body = http.MaxBytesReader(w, r.Body, e.MaxUploadSize)
358 | if err := r.ParseMultipartForm(e.MaxUploadSize); err != nil {
359 | e.Handler.ErrorHandler(ctx, fmt.Errorf("could not parse form for uploads: %w", err))
360 | return
361 | }
362 |
363 | uploadDir := filepath.Join(e.UploadStagingLocation, string(sock.ID()))
364 | if e.UploadStagingLocation == "" {
365 | uploadDir, err = os.MkdirTemp("", string(sock.ID()))
366 | if err != nil {
367 | e.Handler.ErrorHandler(ctx, fmt.Errorf("%s upload dir creation failed: %w", sock.ID(), err))
368 | return
369 | }
370 | }
371 |
372 | for _, config := range sock.UploadConfigs() {
373 | for _, fileHeader := range r.MultipartForm.File[config.Name] {
374 | u := uploadFromFileHeader(fileHeader)
375 | sock.AssignUpload(config.Name, u)
376 | handleFileUpload(e, sock, config, u, uploadDir, fileHeader)
377 |
378 | render, err := RenderSocket(ctx, e, sock)
379 | if err != nil {
380 | e.Handler.ErrorHandler(ctx, err)
381 | return
382 | }
383 | sock.UpdateRender(render)
384 | }
385 | }
386 | }
387 |
388 | func uploadFromFileHeader(fh *multipart.FileHeader) *Upload {
389 | return &Upload{
390 | Name: fh.Filename,
391 | Size: fh.Size,
392 | }
393 | }
394 |
395 | func handleFileUpload(h *Engine, sock *Socket, config *UploadConfig, u *Upload, uploadDir string, fileHeader *multipart.FileHeader) {
396 | // Check file claims to be within the max size.
397 | if fileHeader.Size > config.MaxSize {
398 | u.Errors = append(u.Errors, fmt.Errorf("%s greater than max allowed size of %d", fileHeader.Filename, config.MaxSize))
399 | return
400 | }
401 |
402 | // Open the incoming file.
403 | file, err := fileHeader.Open()
404 | if err != nil {
405 | u.Errors = append(u.Errors, fmt.Errorf("could not open %s for upload: %w", fileHeader.Filename, err))
406 | return
407 | }
408 | defer file.Close()
409 |
410 | // Check the actual filetype.
411 | buff := make([]byte, 512)
412 | _, err = file.Read(buff)
413 | if err != nil {
414 | u.Errors = append(u.Errors, fmt.Errorf("could not check %s for type: %w", fileHeader.Filename, err))
415 | return
416 | }
417 | filetype := http.DetectContentType(buff)
418 | allowed := slices.Contains(config.Accept, filetype)
419 | if !allowed {
420 | u.Errors = append(u.Errors, fmt.Errorf("%s filetype is not allowed", fileHeader.Filename))
421 | return
422 | }
423 | u.Type = filetype
424 |
425 | // Rewind to start of the
426 | _, err = file.Seek(0, io.SeekStart)
427 | if err != nil {
428 | u.Errors = append(u.Errors, fmt.Errorf("%s rewind error: %w", fileHeader.Filename, err))
429 | return
430 | }
431 |
432 | f, err := os.Create(filepath.Join(uploadDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), filepath.Ext(fileHeader.Filename))))
433 | if err != nil {
434 | u.Errors = append(u.Errors, fmt.Errorf("%s upload file creation failed: %w", fileHeader.Filename, err))
435 | return
436 | }
437 | defer f.Close()
438 | u.internalLocation = f.Name()
439 | u.Name = fileHeader.Filename
440 |
441 | written, err := io.Copy(f, io.TeeReader(file, &UploadProgress{Upload: u, Engine: h, Socket: sock}))
442 | if err != nil {
443 | u.Errors = append(u.Errors, fmt.Errorf("%s upload failed: %w", fileHeader.Filename, err))
444 | return
445 | }
446 | u.Size = written
447 | }
448 |
449 | // get renderer.
450 | func (e *Engine) get(ctx context.Context, w http.ResponseWriter, r *http.Request) {
451 | // Get socket.
452 | sock := NewSocket(ctx, e, "")
453 |
454 | // Write ID to cookie.
455 | sock.WriteFlashCookie(w)
456 |
457 | // Run mount, this generates the state for the page we are on.
458 | data, err := e.Handler.MountHandler(ctx, sock)
459 | if err != nil {
460 | e.Handler.ErrorHandler(ctx, err)
461 | return
462 | }
463 | sock.Assign(data)
464 |
465 | // Handle any query parameters that are on the page.
466 | for _, ph := range e.Handler.paramsHandlers {
467 | data, err := ph(ctx, sock, NewParamsFromRequest(r))
468 | if err != nil {
469 | e.Handler.ErrorHandler(ctx, err)
470 | return
471 | }
472 | sock.Assign(data)
473 | }
474 |
475 | // Render the HTML to display the page.
476 | render, err := RenderSocket(ctx, e, sock)
477 | if err != nil {
478 | e.Handler.ErrorHandler(ctx, err)
479 | return
480 | }
481 | sock.UpdateRender(render)
482 |
483 | var rendered bytes.Buffer
484 | html.Render(&rendered, render)
485 |
486 | w.WriteHeader(200)
487 | io.Copy(w, &rendered)
488 | }
489 |
490 | // serveWS serve a websocket request to the handler.
491 | func (e *Engine) serveWS(ctx context.Context, w http.ResponseWriter, r *http.Request) {
492 | if strings.Contains(r.UserAgent(), "Safari") {
493 | if e.acceptOptions == nil {
494 | e.acceptOptions = &websocket.AcceptOptions{}
495 | }
496 | e.acceptOptions.CompressionMode = websocket.CompressionDisabled
497 | }
498 |
499 | c, err := websocket.Accept(w, r, e.acceptOptions)
500 | if err != nil {
501 | e.Handler.ErrorHandler(ctx, err)
502 | return
503 | }
504 | defer c.Close(websocket.StatusInternalError, "")
505 | c.SetReadLimit(e.MaxMessageSize)
506 | writeTimeout(ctx, time.Second*5, c, Event{T: EventConnect})
507 | {
508 | err := e._serveWS(ctx, r, c)
509 | if errors.Is(err, context.Canceled) {
510 | return
511 | }
512 | switch websocket.CloseStatus(err) {
513 | case websocket.StatusNormalClosure:
514 | return
515 | case websocket.StatusGoingAway:
516 | return
517 | case -1:
518 | return
519 | default:
520 | slog.Error("ws closed", "err", fmt.Errorf("ws closed with status (%d): %w", websocket.CloseStatus(err), err))
521 | return
522 | }
523 | }
524 | }
525 |
526 | // _serveWS implement the logic for a web socket connection.
527 | func (e *Engine) _serveWS(ctx context.Context, r *http.Request, c *websocket.Conn) error {
528 | // Get the sessions socket and register it with the server.
529 | sock, err := NewSocketFromRequest(ctx, e, r)
530 | if err != nil {
531 | return fmt.Errorf("failed precondition: %w", err)
532 | }
533 | sock.assignWS(c)
534 | e.AddSocket(sock)
535 | defer e.DeleteSocket(sock)
536 |
537 | // Internal errors.
538 | internalErrors := make(chan error)
539 |
540 | // Event errors.
541 | eventErrors := make(chan ErrorEvent)
542 |
543 | // Handle events coming from the websocket connection.
544 | go func() {
545 | for {
546 | t, d, err := c.Read(ctx)
547 | if err != nil {
548 | internalErrors <- err
549 | break
550 | }
551 | switch t {
552 | case websocket.MessageText:
553 | var m Event
554 | if err := json.Unmarshal(d, &m); err != nil {
555 | internalErrors <- err
556 | break
557 | }
558 | switch m.T {
559 | case EventParams:
560 | if err := e.CallParams(ctx, sock, m); err != nil {
561 | switch {
562 | case errors.Is(err, ErrNoEventHandler):
563 | slog.Error("event params error", "event", m, "err", err)
564 | default:
565 | eventErrors <- ErrorEvent{Source: m, Err: err.Error()}
566 | }
567 | }
568 | default:
569 | if err := e.CallEvent(ctx, m.T, sock, m); err != nil {
570 | switch {
571 | case errors.Is(err, ErrNoEventHandler):
572 | slog.Error("event default error", "event", m, "err", err)
573 | default:
574 | eventErrors <- ErrorEvent{Source: m, Err: err.Error()}
575 | }
576 | }
577 | }
578 | render, err := RenderSocket(ctx, e, sock)
579 | if err != nil {
580 | internalErrors <- fmt.Errorf("socket handle error: %w", err)
581 | } else {
582 | sock.UpdateRender(render)
583 | }
584 | if err := sock.Send(EventAck, nil, WithID(m.ID)); err != nil {
585 | internalErrors <- fmt.Errorf("socket send error: %w", err)
586 | }
587 | case websocket.MessageBinary:
588 | slog.Warn("binary messages unhandled")
589 | }
590 | }
591 | close(internalErrors)
592 | close(eventErrors)
593 | }()
594 |
595 | // Run mount again now that eh socket is connected, passing true indicating
596 | // a connection has been made.
597 | data, err := e.Handler.MountHandler(ctx, sock)
598 | if err != nil {
599 | return fmt.Errorf("socket mount error: %w", err)
600 | }
601 | sock.Assign(data)
602 |
603 | // Run params again now that the socket is connected.
604 | for _, ph := range e.Handler.paramsHandlers {
605 | data, err := ph(ctx, sock, NewParamsFromRequest(r))
606 | if err != nil {
607 | return fmt.Errorf("socket params error: %w", err)
608 | }
609 | sock.Assign(data)
610 | }
611 |
612 | // Run render now that we are connected for the first time and we have just
613 | // mounted again. This will generate and send any patches if there have
614 | // been changes.
615 | render, err := RenderSocket(ctx, e, sock)
616 | if err != nil {
617 | return fmt.Errorf("socket render error: %w", err)
618 | }
619 | sock.UpdateRender(render)
620 |
621 | // Send events to the websocket connection.
622 | for {
623 | select {
624 | case msg := <-sock.msgs:
625 | if err := writeTimeout(ctx, time.Second*5, c, msg); err != nil {
626 | return fmt.Errorf("writing to socket error: %w", err)
627 | }
628 | case ee := <-eventErrors:
629 | d, err := json.Marshal(ee)
630 | if err != nil {
631 | return fmt.Errorf("writing to socket error: %w", err)
632 | }
633 | if err := writeTimeout(ctx, time.Second*5, c, Event{T: EventError, Data: d}); err != nil {
634 | return fmt.Errorf("writing to socket error: %w", err)
635 | }
636 | case err := <-internalErrors:
637 | if err != nil {
638 | d, err := json.Marshal(err.Error())
639 | if err != nil {
640 | return fmt.Errorf("writing to socket error: %w", err)
641 | }
642 | if err := writeTimeout(ctx, time.Second*5, c, Event{T: EventError, Data: d}); err != nil {
643 | return fmt.Errorf("writing to socket error: %w", err)
644 | }
645 | // Something catastrophic has happened.
646 | return fmt.Errorf("internal error: %w", err)
647 | }
648 | case <-ctx.Done():
649 | return nil
650 | }
651 | }
652 | }
653 |
--------------------------------------------------------------------------------