├── .gitignore ├── examples ├── .gitignore ├── alpine │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── index.js │ ├── root.html │ ├── main.go │ └── view.html ├── clock │ ├── view.html │ └── main.go ├── chat │ ├── cmd │ │ └── chat │ │ │ └── main.go │ ├── view.html │ ├── chat.go │ └── layout.html ├── error │ ├── view.html │ └── main.go ├── buttons │ ├── view.html │ └── main.go ├── pagination │ ├── view.html │ └── main.go ├── clocks │ └── main.go ├── root.html ├── prefill │ ├── view.html │ └── main.go ├── go.mod ├── todo │ ├── view.html │ └── main.go ├── chart │ ├── view.html │ └── chart.go ├── uploads │ ├── view.html │ └── main.go ├── components │ ├── clock.go │ └── page.go ├── cluster │ └── main.go └── go.sum ├── web ├── .gitignore ├── .npmignore ├── src │ ├── index.ts │ ├── element.ts │ ├── auto.ts │ ├── live.ts │ ├── patch.spec.ts │ ├── interop.ts │ ├── params.ts │ ├── patch.ts │ ├── socket.ts │ ├── forms.ts │ ├── event.ts │ └── events.ts ├── build.go ├── jest.config.json ├── README.md ├── tsconfig.json ├── package.json └── browser │ └── auto.js ├── go.mod ├── justfile ├── event_test.go ├── .github ├── workflows │ ├── release.yaml │ └── test.yaml └── dependabot.yml ├── http.go ├── errors.go ├── CONTRIBUTING.md ├── go.sum ├── javascript.go ├── socketstate_test.go ├── LICENSE.md ├── context.go ├── page ├── render.go ├── example_test.go ├── configuration.go └── component.go ├── handler_test.go ├── event.go ├── render.go ├── params.go ├── params_test.go ├── pubsub.go ├── example_test.go ├── socketstate.go ├── handler.go ├── upload.go ├── socket.go ├── diff.go └── engine.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.out 2 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | go.work 2 | go.work.sum 3 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.tgz 4 | -------------------------------------------------------------------------------- /examples/alpine/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | main.js 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /web/.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | build.go 3 | jest.config.js 4 | browser 5 | src 6 | -------------------------------------------------------------------------------- /web/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Hooks, Hook } from "./interop"; 2 | export { Live } from "./live"; 3 | -------------------------------------------------------------------------------- /web/build.go: -------------------------------------------------------------------------------- 1 | //go:generate npm run-script build 2 | 3 | //+build generate 4 | 5 | package main 6 | -------------------------------------------------------------------------------- /web/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "testEnvironment": "jsdom", 4 | "roots": ["./src"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/clock/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} {{.Assigns.FormattedTime}} {{ end }} 2 | {{ define "view" }} 3 | 4 | {{ end }} 5 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # jfyne/live companion package 2 | 3 | This package is a companion to the go package [jfyne/live](https://github.com/jfyne/live). 4 | 5 | For correct usage please see the root of that repository. 6 | -------------------------------------------------------------------------------- /examples/alpine/README.md: -------------------------------------------------------------------------------- 1 | # Alpine example 2 | 3 | Demonstrates importing the `@jfyne/live` package, integrating with alpine.js and compiling it with `esbuild`. This 4 | produces a `main.js` which we then serve using go. 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jfyne/live 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/coder/websocket v1.8.13 9 | github.com/google/go-cmp v0.7.0 10 | github.com/rs/xid v1.6.0 11 | golang.org/x/net v0.39.0 12 | golang.org/x/time v0.11.0 13 | ) 14 | -------------------------------------------------------------------------------- /web/src/element.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Element helper class. 3 | */ 4 | export class LiveElement { 5 | static hook(element: HTMLElement): string | null { 6 | if (element.getAttribute === undefined) { 7 | return null; 8 | } 9 | return element.getAttribute("live-hook"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | help: 2 | @ just -l 3 | 4 | build: 5 | #!/bin/bash 6 | set -e 7 | if ! [ -d web/node_modules ]; then 8 | cd web && npm install 9 | cd - 10 | fi 11 | go generate web/build.go 12 | if ! command -v embedmd &> /dev/null 13 | then 14 | GO111MODULE=off go get github.com/campoy/embedmd 15 | fi 16 | embedmd -w README.md 17 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "ES6", 5 | "outDir": "./dist", 6 | "declaration": true, 7 | "declarationDir": "./dist/", 8 | "declarationMap": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestEventParams(t *testing.T) { 8 | e := Event{} 9 | p, err := e.Params() 10 | if err != nil { 11 | t.Fatal("unexpected error", err) 12 | } 13 | if len(p) != 0 { 14 | t.Fatal("expected zero length map, got", p) 15 | } 16 | 17 | e.Data = []byte("wrong") 18 | _, err = e.Params() 19 | if err != ErrMessageMalformed { 20 | t.Error("expected ErrMessageMalformed, got", err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/src/auto.ts: -------------------------------------------------------------------------------- 1 | import { Live } from "./live"; 2 | import { Hooks } from "./interop"; 3 | 4 | declare global { 5 | interface Window { 6 | Hooks: Hooks; 7 | Live: Live; 8 | } 9 | } 10 | 11 | document.addEventListener("DOMContentLoaded", (_) => { 12 | if (window.Live !== undefined) { 13 | console.error("window.Live already defined"); 14 | } 15 | const hooks = window.Hooks || {}; 16 | window.Live = new Live(hooks); 17 | window.Live.init(); 18 | }); 19 | -------------------------------------------------------------------------------- /examples/alpine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live-npm-example", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "description": "An example of importing the live package.", 6 | "scripts": { 7 | "prepare": "npx esbuild --bundle index.js --outfile=main.js" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@jfyne/live": "^0.9.2", 13 | "alpinejs": "^2.8.1" 14 | }, 15 | "devDependencies": { 16 | "esbuild": "^0.25.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/chat/cmd/chat/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/jfyne/live" 9 | "github.com/jfyne/live/examples/chat" 10 | ) 11 | 12 | func main() { 13 | // Run the server. 14 | http.Handle("/", live.NewHttpHandler(context.Background(), chat.NewHandler())) 15 | http.Handle("/live.js", live.Javascript{}) 16 | http.Handle("/auto.js.map", live.JavascriptMap{}) 17 | slog.Info("server", "link", "http://localhost:8080") 18 | http.ListenAndServe(":8080", nil) 19 | } 20 | -------------------------------------------------------------------------------- /examples/error/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} Error event {{ end }} 2 | {{ define "view" }} 3 | 4 |
5 | 6 |
7 | 8 | 20 | 21 | {{ end }} 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | name: Release 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | defaults: 9 | run: 10 | working-directory: web 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: "16.x" 16 | registry-url: "https://registry.npmjs.org" 17 | - run: npm install 18 | - run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} 19 | - run: npm publish 20 | env: 21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | -------------------------------------------------------------------------------- /examples/buttons/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Example buttons{{ end }} {{ define "view" }} 2 | 13 |
14 | 17 |
{{.Assigns.Value}}
18 | 21 |
22 | {{ end }} 23 | -------------------------------------------------------------------------------- /examples/pagination/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Pagination Example{{ end }} 2 | {{ define "view" }} 3 | 8 |
9 |

Showing page {{.Assigns.Page}}

10 | {{range .Assigns.Items}} 11 |
{{.}}
12 | {{end}} 13 |
14 |
15 |
16 | Next page via client patch 17 |
18 |
19 | 20 |
21 |
22 | {{ end }} 23 | -------------------------------------------------------------------------------- /examples/alpine/index.js: -------------------------------------------------------------------------------- 1 | import { Live } from "@jfyne/live"; 2 | import "alpinejs"; 3 | 4 | document.addEventListener("DOMContentLoaded", (_) => { 5 | const hooks = { 6 | "example-hook": { 7 | mounted: () => { 8 | console.log( 9 | "This is an example of passing hooks into live anywhere you want." 10 | ); 11 | }, 12 | }, 13 | }; 14 | const dom = { 15 | onBeforeElUpdated: (from, to) => { 16 | if (from.__x) { 17 | window.Alpine.clone(from.__x, to); 18 | } 19 | }, 20 | }; 21 | const l = new Live(hooks, dom); 22 | l.init(); 23 | }); 24 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/coder/websocket" 11 | ) 12 | 13 | func httpContext(w http.ResponseWriter, r *http.Request) context.Context { 14 | ctx := r.Context() 15 | ctx = contextWithRequest(ctx, r) 16 | ctx = contextWithWriter(ctx, w) 17 | return ctx 18 | } 19 | 20 | func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg Event) error { 21 | ctx, cancel := context.WithTimeout(ctx, timeout) 22 | defer cancel() 23 | 24 | data, err := json.Marshal(&msg) 25 | if err != nil { 26 | return fmt.Errorf("failed writeTimeout: %w", err) 27 | } 28 | 29 | return c.Write(ctx, websocket.MessageText, data) 30 | } 31 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "npm" 13 | directory: "/web" 14 | schedule: 15 | interval: "weekly" 16 | open-pull-requests-limit: 1 17 | - package-ecosystem: "gomod" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import "errors" 4 | 5 | // ErrNoRenderer returned when no renderer has been set on the handler. 6 | var ErrNoRenderer = errors.New("no renderer has been set on the handler") 7 | 8 | // ErrNoEventHandler returned when a handler has no event handler for that event. 9 | var ErrNoEventHandler = errors.New("view missing event handler") 10 | 11 | // ErrMessageMalformed returned when a message could not be parsed correctly. 12 | var ErrMessageMalformed = errors.New("message malformed") 13 | 14 | // ErrNoSocket returned when a socket doesn't exist. 15 | var ErrNoSocket = errors.New("no socket") 16 | 17 | // ErrNotImplemented returned when an interface has not been implemented correctly. 18 | var ErrNotImplemented = errors.New("not implemented") 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Thanks for reading this, and I look forward to seeing your contribution. 4 | 5 | ## Bugs 6 | 7 | If you find a bug please open up an issue and I can look at fixing it. If you find a bug and you figure out how to fix it even better! Open up a PR 8 | and I will review and hopefully merge 9 | 10 | ## Design discussions 11 | 12 | I am very open to discussing the design of the API and what can be made better. The discussions tab is perfect for this. 13 | 14 | ## TODO List 15 | 16 | - Make the javascript interop better than the Phoenix design. 17 | - Missing events, throttle, debounce etc. 18 | - File uploads 19 | - Tests... Lots of tests 20 | 21 | ## Coding conventions 22 | 23 | - gofmt 24 | - Document at least all the public interfaces etc. 25 | -------------------------------------------------------------------------------- /examples/alpine/root.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ template "title" . }} 5 | 6 | 7 | 14 | 15 | 16 | {{ template "view" . }} 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/clocks/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/jfyne/live" 9 | "github.com/jfyne/live/examples/components" 10 | "github.com/jfyne/live/page" 11 | ) 12 | 13 | func main() { 14 | // Setup handler. 15 | h := live.NewHandler( 16 | page.WithComponentMount(func(ctx context.Context, h *live.Handler, s *live.Socket) (*page.Component, error) { 17 | return components.NewPage("app", h, s, "Clocks") 18 | }), 19 | page.WithComponentRenderer(), 20 | ) 21 | 22 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 23 | http.Handle("/live.js", live.Javascript{}) 24 | http.Handle("/auto.js.map", live.JavascriptMap{}) 25 | slog.Info("server", "link", "http://localhost:8080") 26 | http.ListenAndServe(":8080", nil) 27 | } 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= 2 | github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 3 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 4 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 5 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 6 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 7 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 8 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 9 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 10 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 11 | -------------------------------------------------------------------------------- /examples/root.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ template "title" . }} 5 | 6 | 10 | 20 | 21 | 22 | {{ template "view" . }} 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /javascript.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | _ "embed" 5 | "net/http" 6 | ) 7 | 8 | var ( 9 | // JS is the contents of auto.js 10 | //go:embed web/browser/auto.js 11 | JS []byte 12 | 13 | // JSMap is the contents of auto.js.map 14 | //go:embed web/browser/auto.js.map 15 | JSMap []byte 16 | ) 17 | 18 | // Javascript handles serving the client side 19 | // portion of live. 20 | type Javascript struct { 21 | } 22 | 23 | // ServeHTTP. 24 | func (j Javascript) ServeHTTP(w http.ResponseWriter, r *http.Request) { 25 | w.Header().Add("Content-Type", "text/javascript") 26 | w.Write(JS) 27 | } 28 | 29 | // JavascriptMap handles serving source map. 30 | type JavascriptMap struct { 31 | } 32 | 33 | // ServeHTTP. 34 | func (j JavascriptMap) ServeHTTP(w http.ResponseWriter, r *http.Request) { 35 | w.Header().Add("Content-Type", "application/json") 36 | w.Write(JSMap) 37 | } 38 | -------------------------------------------------------------------------------- /web/src/live.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "./socket"; 2 | import { Events } from "./events"; 3 | import { EventDispatch, LiveEvent } from "./event"; 4 | import { Hooks, DOM } from "./interop"; 5 | 6 | export class Live { 7 | constructor(private hooks: Hooks, private dom?: DOM) {} 8 | 9 | public init() { 10 | // Check that this document has been rendered by live. 11 | if (document.querySelector(`[live-rendered]`) === null) { 12 | return; 13 | } 14 | // Initialise the event dispatch. 15 | EventDispatch.init(this.hooks, this.dom); 16 | 17 | // Dial the server. 18 | Socket.dial(); 19 | 20 | // Initialise our live bindings. 21 | Events.init(); 22 | 23 | // Rewire all the events. 24 | Events.rewire(); 25 | } 26 | 27 | public send(typ: string, data: any, id?: number) { 28 | const e = new LiveEvent(typ, data, id); 29 | Socket.send(e); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | on: [pull_request, push] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.x] 8 | node-version: [16.x, 17.x] 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | steps: 12 | - name: Install Go 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: ${{ matrix.go-version }} 16 | - name: Install Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | - run: npm install 23 | working-directory: ./web 24 | - run: npm run build --if-present 25 | working-directory: ./web 26 | - run: npm test 27 | working-directory: ./web 28 | env: 29 | CI: true 30 | - name: Test go 31 | run: go test -race ./... 32 | -------------------------------------------------------------------------------- /examples/chat/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} Live Chat {{ end }} 2 | {{ define "view" }} 3 | 4 |
5 |
6 | {{ range $msg := .Assigns.Messages }} 7 |
8 | {{$msg.User}} 9 |
{{$msg.Msg}}
10 |
11 | {{ end }} 12 |
13 |
14 |
15 | 16 | 17 |
18 |
19 |
20 | 21 | 32 | 33 | {{ end }} 34 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jfyne/live", 3 | "version": "0.0.0", 4 | "description": "Go live handler sibling package", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "test": "npx jest --silent=false", 9 | "build": "npx tsc -noemit && npx esbuild --bundle src/auto.ts --outfile=browser/auto.js --minify --sourcemap", 10 | "prepublishOnly": "npx tsc", 11 | "localpackage": "rm -r dist || true && npx tsc && npm pack" 12 | }, 13 | "keywords": [ 14 | "serverside", 15 | "liveview", 16 | "golang", 17 | "go" 18 | ], 19 | "author": "jfyne", 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/jfyne/live" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^27.0.2", 27 | "esbuild": "^0.25.0", 28 | "jest": "^27.3.1", 29 | "prettier": "^2.4.1", 30 | "ts-jest": "^27.0.7", 31 | "typescript": "^4.4.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /socketstate_test.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestMemorySocketStateStore(t *testing.T) { 11 | ctx := context.Background() 12 | m := &MemorySocketStateStore{ 13 | janitorFrequency: 50 * time.Millisecond, 14 | gets: make(chan mssGetop), 15 | sets: make(chan mssSetop), 16 | dels: make(chan mssDelop), 17 | clean: make(chan bool), 18 | } 19 | go m.operate(ctx) 20 | go m.janitor(ctx) 21 | 22 | ID := SocketID("a") 23 | state := SocketState{ 24 | Render: []byte("test"), 25 | } 26 | 27 | if err := m.Set(ID, state, 100*time.Millisecond); err != nil { 28 | t.Error(err) 29 | } 30 | s, err := m.Get(ID) 31 | if err != nil { 32 | t.Error(fmt.Errorf("initial get: %w", err)) 33 | } 34 | if string(s.Render) != string(state.Render) { 35 | t.Error("state doesnt match") 36 | } 37 | time.Sleep(150 * time.Millisecond) 38 | _, err = m.Get(ID) 39 | if err == nil { 40 | t.Error(fmt.Errorf("state should be clear: %w", err)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/prefill/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} Example Prefill {{ end }} 2 | {{ define "view" }} 3 | 13 | 14 |

User edit form

15 | 16 | 17 |
18 | 19 | {{ if len .Assigns.Validation }} 20 |
{{ .Assigns.Validation }}
21 | {{ end }} 22 |
23 |
24 | 28 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 | Name: {{ .Assigns.Name }}, {{ .Assigns.Age }} 40 |
41 | 42 | {{ end }} 43 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jfyne/live/examples 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/jfyne/live v0.15.5 9 | github.com/maragudk/gomponents v0.22.0 10 | gocloud.dev v0.41.0 11 | ) 12 | 13 | require ( 14 | github.com/coder/websocket v1.8.13 // indirect 15 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 16 | github.com/google/go-cmp v0.7.0 // indirect 17 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 18 | github.com/rs/xid v1.6.0 // indirect 19 | go.opencensus.io v0.24.0 // indirect 20 | golang.org/x/net v0.39.0 // indirect 21 | golang.org/x/sync v0.13.0 // indirect 22 | golang.org/x/sys v0.32.0 // indirect 23 | golang.org/x/text v0.24.0 // indirect 24 | golang.org/x/time v0.11.0 // indirect 25 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 26 | google.golang.org/api v0.228.0 // indirect 27 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect 28 | google.golang.org/grpc v1.71.1 // indirect 29 | google.golang.org/protobuf v1.36.6 // indirect 30 | ) 31 | 32 | replace github.com/jfyne/live => ../. 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2020 Josh Fyne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type contextKey string 9 | 10 | const ( 11 | requestKey contextKey = "context_request" 12 | writerKey contextKey = "context_writer" 13 | ) 14 | 15 | // contextWithRequest embed the initiating request within the context. 16 | func contextWithRequest(ctx context.Context, r *http.Request) context.Context { 17 | return context.WithValue(ctx, requestKey, r) 18 | } 19 | 20 | // Request pulls out an initiating request from a context. 21 | func Request(ctx context.Context) *http.Request { 22 | data := ctx.Value(requestKey) 23 | r, ok := data.(*http.Request) 24 | if !ok { 25 | return nil 26 | } 27 | return r 28 | } 29 | 30 | // contextWithWriter embed the response writer within the context. 31 | func contextWithWriter(ctx context.Context, w http.ResponseWriter) context.Context { 32 | return context.WithValue(ctx, writerKey, w) 33 | } 34 | 35 | // Writer pulls out a response writer from a context. 36 | func Writer(ctx context.Context) http.ResponseWriter { 37 | data := ctx.Value(writerKey) 38 | w, ok := data.(http.ResponseWriter) 39 | if !ok { 40 | return nil 41 | } 42 | return w 43 | } 44 | -------------------------------------------------------------------------------- /page/render.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | ) 7 | 8 | // HTML render some html with added template functions to support components. This 9 | // passes the component state to be rendered. 10 | // 11 | // Template functions 12 | // - "Event" takes an event string and scopes it for the component. 13 | func HTML(layout string, c *Component) RenderFunc { 14 | t := template.Must(template.New("").Funcs(templateFuncs(c)).Parse(layout)) 15 | return RenderFunc(func(w io.Writer) error { 16 | if err := t.Execute(w, c.State); err != nil { 17 | return err 18 | } 19 | return nil 20 | }) 21 | } 22 | 23 | func templateFuncs(c *Component) template.FuncMap { 24 | return template.FuncMap{ 25 | "Event": c.Event, 26 | } 27 | } 28 | 29 | // RenderFunc a helper function to ease the rendering of nodes. 30 | type RenderFunc func(io.Writer) error 31 | 32 | // Render take a writer and render the func. 33 | func (r RenderFunc) Render(w io.Writer) error { 34 | return r(w) 35 | } 36 | 37 | // Render wrap a component and provide a RenderFunc. 38 | func Render(c *Component) RenderFunc { 39 | return RenderFunc(func(w io.Writer) error { 40 | return c.Render(w, c) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /page/example_test.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/jfyne/live" 9 | ) 10 | 11 | // NewGreeter creates a new greeter component. 12 | func NewGreeter(ID string, h *live.Handler, s *live.Socket, name string) (*Component, error) { 13 | return NewComponent( 14 | ID, 15 | h, 16 | s, 17 | WithMount(func(ctx context.Context, c *Component) error { 18 | c.State = name 19 | return nil 20 | }), 21 | WithRender(func(w io.Writer, c *Component) error { 22 | // Render the greeter, here we are including the script just to make this toy example work. 23 | return HTML(` 24 |
Hello {{.}}
25 | 26 | `, c).Render(w) 27 | }), 28 | ) 29 | } 30 | 31 | func Example() { 32 | h := live.NewHandler( 33 | WithComponentMount(func(ctx context.Context, h *live.Handler, s *live.Socket) (*Component, error) { 34 | return NewGreeter("hello-id", h, s, "World!") 35 | }), 36 | WithComponentRenderer(), 37 | ) 38 | 39 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 40 | http.Handle("/live.js", live.Javascript{}) 41 | http.ListenAndServe(":8080", nil) 42 | } 43 | -------------------------------------------------------------------------------- /examples/todo/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} Example form {{ end }} 2 | {{ define "view" }} 3 | 18 | 19 |

Todo list

20 | 21 | 22 |
23 | {{ if index .Assigns.Form.Errors "message" }} 24 |
{{index .Assigns.Form.Errors "message"}}
25 | {{ end }} 26 |
27 | 31 |
32 |
33 | 34 |
35 |
36 | 37 | {{ range $t := .Assigns.Tasks }} 38 | 39 |
{{$t.Name}}
40 |
41 | {{ end }} 42 | 43 | {{ end }} 44 | -------------------------------------------------------------------------------- /examples/error/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | 11 | "github.com/jfyne/live" 12 | ) 13 | 14 | const ( 15 | problem = "problem" 16 | ) 17 | 18 | func main() { 19 | t, err := template.ParseFiles("root.html", "error/view.html") 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | h := live.NewHandler(live.WithTemplateRenderer(t)) 25 | 26 | // Uncomment the below to see the server respond with an error immediately. 27 | 28 | //h.HandleMount(func(ctx context.Context, s live.Socket) (any, error) { 29 | // return nil, fmt.Errorf("mount failure") 30 | //}) 31 | 32 | h.ErrorHandler = func(ctx context.Context, err error) { 33 | w := live.Writer(ctx) 34 | w.WriteHeader(http.StatusBadRequest) 35 | w.Write([]byte("this is a bad request: " + err.Error())) 36 | } 37 | 38 | h.HandleEvent(problem, func(ctx context.Context, s *live.Socket, _ live.Params) (any, error) { 39 | return nil, fmt.Errorf("hello") 40 | }) 41 | 42 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 43 | http.Handle("/live.js", live.Javascript{}) 44 | http.Handle("/auto.js.map", live.JavascriptMap{}) 45 | slog.Info("server", "link", "http://localhost:8080") 46 | http.ListenAndServe(":8080", nil) 47 | } 48 | -------------------------------------------------------------------------------- /examples/chart/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Example chart{{ end }} {{ define "view" }} 2 | 3 | 16 |
17 |
18 |
19 | 20 | 34 | 35 | 42 | 43 | 52 | {{ end }} 53 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestHandler(t *testing.T) { 13 | output := `test` 14 | 15 | h := NewHandler() 16 | h.RenderHandler = func(ctx context.Context, data *RenderContext) (io.Reader, error) { 17 | return strings.NewReader(output), nil 18 | } 19 | 20 | e := NewHttpHandler(context.Background(), h) 21 | 22 | req, err := http.NewRequest("GET", "/test", nil) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | rr := httptest.NewRecorder() 28 | ctx := httpContext(rr, req) 29 | e.get(ctx, rr, req) 30 | 31 | if rr.Code != http.StatusOK { 32 | t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK) 33 | return 34 | } 35 | if rr.Body.String() != output { 36 | t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), output) 37 | } 38 | } 39 | 40 | func TestHandlerErrorNoRenderer(t *testing.T) { 41 | h := NewHandler() 42 | 43 | e := NewHttpHandler(context.Background(), h) 44 | 45 | req, err := http.NewRequest("GET", "/test", nil) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | rr := httptest.NewRecorder() 51 | ctx := httpContext(rr, req) 52 | e.get(ctx, rr, req) 53 | 54 | if rr.Code != http.StatusInternalServerError { 55 | t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusInternalServerError) 56 | return 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // EventConfig configures an event. 8 | type EventConfig func(e *Event) error 9 | 10 | const ( 11 | // EventError indicates an error has occurred. 12 | EventError = "err" 13 | // EventPatch a patch event containing a diff. 14 | EventPatch = "patch" 15 | // EventAck sent when an event is acknowledged. 16 | EventAck = "ack" 17 | // EventConnect sent as soon as the server accepts the 18 | // WS connection. 19 | EventConnect = "connect" 20 | // EventParams sent for a URL parameter update. Can be 21 | // sent both directions. 22 | EventParams = "params" 23 | // EventRedirect sent in order to trigger a browser 24 | // redirect. 25 | EventRedirect = "redirect" 26 | ) 27 | 28 | // Event messages that are sent and received by the 29 | // socket. 30 | type Event struct { 31 | T string `json:"t"` 32 | ID int `json:"i,omitempty"` 33 | Data json.RawMessage `json:"d,omitempty"` 34 | SelfData any `json:"s,omitempty"` 35 | } 36 | 37 | // Params extract params from inbound message. 38 | func (e Event) Params() (Params, error) { 39 | if e.Data == nil { 40 | return Params{}, nil 41 | } 42 | var p Params 43 | if err := json.Unmarshal(e.Data, &p); err != nil { 44 | return nil, ErrMessageMalformed 45 | } 46 | return p, nil 47 | } 48 | 49 | // WithID sets an ID on an event. 50 | func WithID(ID int) EventConfig { 51 | return func(e *Event) error { 52 | e.ID = ID 53 | return nil 54 | } 55 | } 56 | 57 | type ErrorEvent struct { 58 | Source Event `json:"source"` 59 | Err string `json:"err"` 60 | } 61 | -------------------------------------------------------------------------------- /render.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "html/template" 8 | "io" 9 | 10 | "golang.org/x/net/html" 11 | ) 12 | 13 | // RenderContext contains the sockets current data for rendering. 14 | type RenderContext struct { 15 | Socket *Socket 16 | Uploads UploadContext 17 | Assigns any 18 | } 19 | 20 | // RenderSocket takes the engine and current socket and renders it to html. 21 | func RenderSocket(ctx context.Context, e *Engine, s *Socket) (*html.Node, error) { 22 | rc := &RenderContext{ 23 | Socket: s, 24 | Uploads: s.Uploads(), 25 | Assigns: s.Assigns(), 26 | } 27 | 28 | output, err := e.Handler.RenderHandler(ctx, rc) 29 | if err != nil { 30 | return nil, fmt.Errorf("render error: %w", err) 31 | } 32 | render, err := html.Parse(output) 33 | if err != nil { 34 | return nil, fmt.Errorf("html parse error: %w", err) 35 | } 36 | shapeTree(render) 37 | 38 | if s.LatestRender() != nil { 39 | patches, err := Diff(s.LatestRender(), render) 40 | if err != nil { 41 | return nil, fmt.Errorf("diff error: %w", err) 42 | } 43 | if len(patches) != 0 { 44 | s.Send(EventPatch, patches) 45 | } 46 | } else { 47 | anchorTree(render, newAnchorGenerator()) 48 | } 49 | 50 | return render, nil 51 | } 52 | 53 | // WithTemplateRenderer set the handler to use an `html/template` renderer. 54 | func WithTemplateRenderer(t *template.Template) HandlerConfig { 55 | return func(h *Handler) error { 56 | h.RenderHandler = func(ctx context.Context, rc *RenderContext) (io.Reader, error) { 57 | var buf bytes.Buffer 58 | if err := t.Execute(&buf, rc); err != nil { 59 | return nil, err 60 | } 61 | return &buf, nil 62 | } 63 | return nil 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /examples/uploads/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} Example file upload {{ end }} 2 | {{ define "view" }} 3 | 18 | 19 |

Upload .pngs less than a MB

20 | 21 | 22 |
23 |
24 | 25 | 42 |
43 |
44 | 49 |
50 |
51 | 52 | 57 | 58 | {{ end }} 59 | -------------------------------------------------------------------------------- /examples/buttons/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "html/template" 6 | "log" 7 | "log/slog" 8 | "net/http" 9 | 10 | "github.com/jfyne/live" 11 | ) 12 | 13 | const ( 14 | inc = "inc" 15 | dec = "dec" 16 | ) 17 | 18 | type counter struct { 19 | Value int 20 | } 21 | 22 | func newCounter(s *live.Socket) *counter { 23 | c, ok := s.Assigns().(*counter) 24 | if !ok { 25 | return &counter{} 26 | } 27 | return c 28 | } 29 | 30 | func main() { 31 | t, err := template.ParseFiles("root.html", "buttons/view.html") 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | h := live.NewHandler(live.WithTemplateRenderer(t)) 37 | 38 | // Set the mount function for this handler. 39 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { 40 | // This will initialise the counter if needed. 41 | return newCounter(s), nil 42 | } 43 | 44 | // Client side events. 45 | 46 | // Increment event. Each click will increment the count by one. 47 | h.HandleEvent(inc, func(ctx context.Context, s *live.Socket, _ live.Params) (any, error) { 48 | // Get this sockets counter struct. 49 | c := newCounter(s) 50 | 51 | // Increment the value by one. 52 | c.Value += 1 53 | 54 | // Set the counter struct back to the socket data. 55 | return c, nil 56 | }) 57 | 58 | // Decrement event. Each click will increment the count by one. 59 | h.HandleEvent(dec, func(ctx context.Context, s *live.Socket, _ live.Params) (any, error) { 60 | // Get this sockets counter struct. 61 | c := newCounter(s) 62 | 63 | // Decrement the value by one. 64 | c.Value -= 1 65 | 66 | // Set the counter struct back to the socket data. 67 | return c, nil 68 | }) 69 | 70 | // Run the server. 71 | ctx := context.Background() 72 | http.Handle("/", live.NewHttpHandler(ctx, h)) 73 | http.Handle("/live.js", live.Javascript{}) 74 | http.Handle("/auto.js.map", live.JavascriptMap{}) 75 | slog.Info("server", "link", "http://localhost:8080") 76 | http.ListenAndServe(":8080", nil) 77 | } 78 | -------------------------------------------------------------------------------- /web/src/patch.spec.ts: -------------------------------------------------------------------------------- 1 | import { Patch } from "./patch"; 2 | import { LiveEvent } from "./event"; 3 | 4 | test("simple replace", () => { 5 | document.body.innerHTML = `
Hello
`; 6 | const event = new LiveEvent("patch", [ 7 | { 8 | Anchor: "_l0", 9 | Action: 1, 10 | HTML: `
World
`, 11 | }, 12 | ]); 13 | 14 | Patch.handle(event); 15 | expect(document.body.innerHTML).toEqual(`
World
`); 16 | }); 17 | 18 | test("double update", () => { 19 | document.body.innerHTML = `
Hello
World
`; 20 | const p = new LiveEvent("patch", [ 21 | { 22 | Anchor: "_l0", 23 | Action: 1, 24 | HTML: `
World
`, 25 | }, 26 | { 27 | Anchor: "_l1", 28 | Action: 1, 29 | HTML: `
Hello
`, 30 | }, 31 | ]); 32 | Patch.handle(p); 33 | expect(document.body.innerHTML).toEqual(`
World
Hello
`); 34 | }); 35 | 36 | test("nested update", () => { 37 | document.body.innerHTML = `
`; 38 | const p = new LiveEvent("patch", [ 39 | { 40 | Anchor: "_l0", 41 | Action: 3, 42 | HTML: `
Error
`, 43 | }, 44 | ]); 45 | Patch.handle(p); 46 | 47 | expect(document.body.innerHTML).toEqual( 48 | `
Error
` 49 | ); 50 | }); 51 | 52 | test("head update", () => { 53 | document.head.innerHTML = `1`; 54 | const p = new LiveEvent("patch", [ 55 | { 56 | Anchor: "_l0", 57 | Action: 1, 58 | HTML: `2`, 59 | }, 60 | ]); 61 | Patch.handle(p); 62 | 63 | expect(document.head.innerHTML).toEqual(`2`); 64 | }); 65 | -------------------------------------------------------------------------------- /examples/clock/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "html/template" 6 | "log" 7 | "log/slog" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/jfyne/live" 12 | ) 13 | 14 | const ( 15 | tick = "tick" 16 | ) 17 | 18 | type clock struct { 19 | Time time.Time 20 | } 21 | 22 | func newClock(s *live.Socket) *clock { 23 | c, ok := s.Assigns().(*clock) 24 | if !ok { 25 | return &clock{ 26 | Time: time.Now(), 27 | } 28 | } 29 | return c 30 | } 31 | 32 | func (c clock) FormattedTime() string { 33 | return c.Time.Format("15:04:05") 34 | } 35 | 36 | func mount(ctx context.Context, s *live.Socket) (any, error) { 37 | // Take the socket data and tranform it into our view model if it is 38 | // available. 39 | c := newClock(s) 40 | 41 | // If we are mouting the websocket connection, trigger the first tick 42 | // event. 43 | if s.Connected() { 44 | go func() { 45 | time.Sleep(1 * time.Second) 46 | s.Self(ctx, tick, time.Now()) 47 | }() 48 | } 49 | return c, nil 50 | } 51 | 52 | func main() { 53 | t, err := template.ParseFiles("root.html", "clock/view.html") 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | 58 | h := live.NewHandler(live.WithTemplateRenderer(t)) 59 | 60 | // Set the mount function for this handler. 61 | h.MountHandler = mount 62 | 63 | // Server side events. 64 | 65 | // tick event updates the clock every second. 66 | h.HandleSelf(tick, func(ctx context.Context, s *live.Socket, d any) (any, error) { 67 | // Get our model 68 | c := newClock(s) 69 | // Update the time. 70 | c.Time = d.(time.Time) 71 | // Send ourselves another tick in a second. 72 | go func(sock *live.Socket) { 73 | time.Sleep(1 * time.Second) 74 | sock.Self(ctx, tick, time.Now()) 75 | }(s) 76 | return c, nil 77 | }) 78 | 79 | // Run the server. 80 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 81 | http.Handle("/live.js", live.Javascript{}) 82 | http.Handle("/auto.js.map", live.JavascriptMap{}) 83 | slog.Info("server", "link", "http://localhost:8080") 84 | http.ListenAndServe(":8080", nil) 85 | } 86 | -------------------------------------------------------------------------------- /examples/chart/chart.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "html/template" 6 | "log" 7 | "log/slog" 8 | "math/rand" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/jfyne/live" 13 | ) 14 | 15 | const ( 16 | regenerate = "regenerate" 17 | ) 18 | 19 | type RandomEngine struct { 20 | *live.Engine 21 | } 22 | 23 | func NewRandomEngine(h *live.Handler) *RandomEngine { 24 | e := &RandomEngine{ 25 | live.NewHttpHandler(context.Background(), h), 26 | } 27 | return e 28 | } 29 | 30 | func (e *RandomEngine) Start() { 31 | go func() { 32 | ticker := time.NewTicker(2 * time.Second) 33 | for { 34 | <-ticker.C 35 | e.Broadcast(regenerate, rand.Perm(9)) 36 | } 37 | }() 38 | } 39 | 40 | type chartData struct { 41 | Sales []int 42 | } 43 | 44 | func newChartData(s *live.Socket) *chartData { 45 | d, ok := s.Assigns().(*chartData) 46 | if !ok { 47 | return &chartData{ 48 | Sales: rand.Perm(9), 49 | } 50 | } 51 | return d 52 | } 53 | 54 | func main() { 55 | t, err := template.ParseFiles("root.html", "chart/view.html") 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | h := live.NewHandler(live.WithTemplateRenderer(t)) 61 | 62 | // Set the mount function for this handler. 63 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { 64 | // This will initialise the chart data if needed. 65 | return newChartData(s), nil 66 | } 67 | 68 | // Client side events. 69 | 70 | // Regenerate event, creates new random sales data. 71 | h.HandleSelf(regenerate, func(ctx context.Context, s *live.Socket, d any) (any, error) { 72 | // Get this sockets counter struct. 73 | c := newChartData(s) 74 | 75 | // Generate new sales data. 76 | c.Sales = d.([]int) 77 | 78 | // Set the new chart data back to the socket. 79 | return c, nil 80 | }) 81 | 82 | e := NewRandomEngine(h) 83 | e.Start() 84 | 85 | // Run the server. 86 | http.Handle("/", e) 87 | http.Handle("/live.js", live.Javascript{}) 88 | http.Handle("/auto.js.map", live.JavascriptMap{}) 89 | slog.Info("server", "link", "http://localhost:8080") 90 | http.ListenAndServe(":8080", nil) 91 | } 92 | -------------------------------------------------------------------------------- /web/src/interop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Hooks supplied for interop. 3 | */ 4 | export interface Hooks { 5 | [id: string]: Hook; 6 | } 7 | 8 | /** 9 | * A hook for running external JS. 10 | */ 11 | export interface Hook { 12 | /** 13 | * The element has been added to the DOM and its server 14 | * LiveHandler has finished mounting 15 | */ 16 | mounted?: () => void; 17 | 18 | /** 19 | * The element is about to be updated in the DOM. 20 | * Note: any call here must be synchronous as the operation 21 | * cannot be deferred or cancelled. 22 | */ 23 | beforeUpdate?: () => void; 24 | 25 | /** 26 | * The element has been updated in the DOM by the server 27 | */ 28 | updated?: () => void; 29 | 30 | /** 31 | * The element is about to be removed from the DOM. 32 | * Note: any call here must be synchronous as the operation 33 | * cannot be deferred or cancelled. 34 | */ 35 | beforeDestroy?: () => void; 36 | 37 | /** 38 | * The element has been removed from the page, either by 39 | * a parent update, or by the parent being removed entirely 40 | */ 41 | destroyed?: () => void; 42 | 43 | /** 44 | * The element's parent LiveHandler has disconnected from 45 | * the server 46 | */ 47 | disconnected?: () => void; 48 | 49 | /** 50 | * The element's parent LiveHandler has reconnected to the 51 | * server 52 | */ 53 | reconnected?: () => void; 54 | } 55 | 56 | /** 57 | * The DOM management interface. This allows external JS libraries to 58 | * interop with Live. 59 | */ 60 | export interface DOM { 61 | /** 62 | * The fromEl and toEl DOM nodes are passed to the function 63 | * just before the DOM patch operations occurs in Live. This 64 | * allows external libraries to (re)initialize DOM elements 65 | * or copy attributes as necessary as Live performs its own 66 | * patch operations. The update operation cannot be cancelled 67 | * or deferred, and the return value is ignored. 68 | */ 69 | onBeforeElUpdated?: (fromEl: Element, toEl: Element) => void; 70 | } 71 | -------------------------------------------------------------------------------- /examples/prefill/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | 11 | "github.com/jfyne/live" 12 | ) 13 | 14 | const ( 15 | validate = "validate" 16 | save = "save" 17 | done = "done" 18 | ) 19 | 20 | type model struct { 21 | Validation string 22 | Name string 23 | Age int 24 | } 25 | 26 | func newModel(s *live.Socket) *model { 27 | m, ok := s.Assigns().(*model) 28 | if !ok { 29 | return &model{ 30 | Validation: "", 31 | } 32 | } 33 | // Clear validation on each event. 34 | m.Validation = "" 35 | return m 36 | } 37 | 38 | func main() { 39 | t, err := template.ParseFiles("root.html", "prefill/view.html") 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | 44 | h := live.NewHandler(live.WithTemplateRenderer(t)) 45 | 46 | // Set the mount function for this handler. 47 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { 48 | // This will initialise the form. 49 | m := newModel(s) 50 | 51 | // Here we would get the user from the db or something. 52 | m.Name = "Test User" 53 | m.Age = 35 54 | 55 | return m, nil 56 | } 57 | 58 | // Validate the form. 59 | h.HandleEvent(validate, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 60 | m := newModel(s) 61 | 62 | nameLen := len(p.String("name")) 63 | if nameLen <= 5 { 64 | m.Validation = fmt.Sprintf("short name (%d)", nameLen) 65 | } 66 | if nameLen > 10 { 67 | m.Validation = fmt.Sprintf("long name (%d)", nameLen) 68 | } 69 | 70 | return m, nil 71 | }) 72 | 73 | // Handle form saving. 74 | h.HandleEvent(save, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 75 | m := newModel(s) 76 | m.Name = p.String("name") 77 | m.Age = p.Int("age") 78 | return m, nil 79 | }) 80 | 81 | // Run the server. 82 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 83 | http.Handle("/live.js", live.Javascript{}) 84 | http.Handle("/auto.js.map", live.JavascriptMap{}) 85 | slog.Info("server", "link", "http://localhost:8080") 86 | http.ListenAndServe(":8080", nil) 87 | } 88 | -------------------------------------------------------------------------------- /page/configuration.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/jfyne/live" 10 | ) 11 | 12 | // ComponentConfig configures a component. 13 | type ComponentConfig func(c *Component) error 14 | 15 | // WithRegister set a register handler on the component. 16 | func WithRegister(fn RegisterHandler) ComponentConfig { 17 | return func(c *Component) error { 18 | c.Register = fn 19 | return nil 20 | } 21 | } 22 | 23 | // WithMount set a mount handler on the component. 24 | func WithMount(fn MountHandler) ComponentConfig { 25 | return func(c *Component) error { 26 | c.Mount = fn 27 | return nil 28 | } 29 | } 30 | 31 | // WithRender set a render handler on the component. 32 | func WithRender(fn RenderHandler) ComponentConfig { 33 | return func(c *Component) error { 34 | c.Render = fn 35 | return nil 36 | } 37 | } 38 | 39 | // WithComponentMount set the live.Handler to mount the root component. 40 | func WithComponentMount(construct ComponentConstructor) live.HandlerConfig { 41 | return func(h *live.Handler) error { 42 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { 43 | root, err := construct(ctx, h, s) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to construct root component: %w", err) 46 | } 47 | if s.Connected() { 48 | if err := root.Register(root); err != nil { 49 | return nil, err 50 | } 51 | } 52 | if err := root.Mount(ctx, root); err != nil { 53 | return nil, err 54 | } 55 | return root, nil 56 | } 57 | return nil 58 | } 59 | } 60 | 61 | // WithComponentRenderer set the live.Handler to use a root component to render. 62 | func WithComponentRenderer() live.HandlerConfig { 63 | return func(h *live.Handler) error { 64 | h.RenderHandler = func(_ context.Context, data *live.RenderContext) (io.Reader, error) { 65 | c, ok := data.Assigns.(*Component) 66 | if !ok { 67 | return nil, fmt.Errorf("root render data is not a component") 68 | } 69 | c.Uploads = data.Uploads 70 | var buf bytes.Buffer 71 | if err := c.Render(&buf, c); err != nil { 72 | return nil, err 73 | } 74 | return &buf, nil 75 | } 76 | return nil 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /web/src/params.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "./socket"; 2 | import { LiveEvent } from "./event"; 3 | 4 | /** 5 | * A values from the "live-value-" attributes. As 6 | * well as values from the query string in the URL. 7 | */ 8 | export interface Params { 9 | [key: string]: any; 10 | } 11 | 12 | /** 13 | * GetParams gets the current parameters for an event. This includes 14 | * any from an element passed in and the URL search string. 15 | */ 16 | export function GetParams(element?: HTMLElement): Params { 17 | const output: Params = {}; 18 | 19 | const urlParams = new URLSearchParams(window.location.search); 20 | urlParams.forEach((value, key) => { 21 | output[key] = value; 22 | }); 23 | 24 | if (element === undefined) { 25 | return output; 26 | } 27 | 28 | if (!element.hasAttributes()) { 29 | return output; 30 | } 31 | const attrs = element.attributes; 32 | for (let i = 0; i < attrs.length; i++) { 33 | if (!attrs[i].name.startsWith("live-value-")) { 34 | continue; 35 | } 36 | output[attrs[i].name.split("live-value-")[1]] = attrs[i].value; 37 | } 38 | return output; 39 | } 40 | 41 | /** 42 | * GetURLParams get the params from a url path. 43 | */ 44 | export function GetURLParams(path: string): Params { 45 | const url = new URL(path, location.origin); 46 | const urlParams = new URLSearchParams(url.search); 47 | 48 | const output: Params = {}; 49 | urlParams.forEach((value, key) => { 50 | output[key] = value; 51 | }); 52 | 53 | return output; 54 | } 55 | 56 | /** 57 | * UpdateURLParams update the URL using the push state api, then 58 | * notify the backend. 59 | */ 60 | export function UpdateURLParams(path: string, element?: HTMLElement) { 61 | window.history.pushState({}, "", path); 62 | if (element === undefined) { 63 | Socket.send(new LiveEvent("params", { ...GetURLParams(path) })); 64 | } else { 65 | const params = GetParams(element); 66 | Socket.sendAndTrack( 67 | new LiveEvent( 68 | "params", 69 | { ...params, ...GetURLParams(path) }, 70 | LiveEvent.GetID() 71 | ), 72 | element 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /web/src/patch.ts: -------------------------------------------------------------------------------- 1 | import { LiveEvent, EventDispatch } from "./event"; 2 | import { Forms } from "./forms"; 3 | 4 | interface PatchEvent { 5 | Anchor: string; 6 | Action: number; 7 | HTML: string; 8 | } 9 | 10 | /** 11 | * Handle patches from the backend. 12 | */ 13 | export class Patch { 14 | static handle(event: LiveEvent) { 15 | Forms.dehydrate(); 16 | 17 | const patches = event.data; 18 | patches.map(Patch.applyPatch); 19 | 20 | Forms.hydrate(); 21 | } 22 | 23 | private static applyPatch(e: PatchEvent) { 24 | const target = document.querySelector(`*[${e.Anchor}]`); 25 | if (target === null) { 26 | return; 27 | } 28 | 29 | const newElement = Patch.html2Node(e.HTML); 30 | switch (e.Action) { 31 | case 0: // NOOP 32 | return; 33 | case 1: // REPLACE 34 | if (e.HTML === "") { 35 | EventDispatch.beforeDestroy(target); 36 | } else { 37 | EventDispatch.beforeUpdate(target, newElement as Element); 38 | } 39 | target.outerHTML = e.HTML; 40 | if (e.HTML === "") { 41 | EventDispatch.destroyed(target); 42 | } else { 43 | EventDispatch.updated(target); 44 | } 45 | break; 46 | case 2: // APPEND 47 | EventDispatch.beforeUpdate(target, newElement as Element); 48 | target.append(newElement); 49 | EventDispatch.updated(target); 50 | break; 51 | case 3: // PREPEND 52 | EventDispatch.beforeUpdate(target, newElement as Element); 53 | target.prepend(newElement); 54 | EventDispatch.updated(target); 55 | break; 56 | } 57 | } 58 | 59 | private static html2Node(html: string): Node { 60 | const template = document.createElement("template"); 61 | html = html.trim(); 62 | template.innerHTML = html; 63 | if (template.content.firstChild === null) { 64 | return document.createTextNode(html); 65 | } 66 | return template.content.firstChild; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /params.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | ) 7 | 8 | // Params event params. 9 | type Params map[string]any 10 | 11 | // String helper to get a string from the params. 12 | func (p Params) String(key string) string { 13 | return mapString(p, key) 14 | } 15 | 16 | // Checkbox helper to return a boolean from params referring to 17 | // a checkbox input. 18 | func (p Params) Checkbox(key string) bool { 19 | v, ok := p[key] 20 | if !ok { 21 | return false 22 | } 23 | out, ok := v.(string) 24 | if !ok { 25 | return false 26 | } 27 | if out == "on" { 28 | return true 29 | } 30 | return false 31 | } 32 | 33 | func mapString(p map[string]any, key string) string { 34 | v, ok := p[key] 35 | if !ok { 36 | return "" 37 | } 38 | out, ok := v.(string) 39 | if !ok { 40 | return "" 41 | } 42 | return out 43 | } 44 | 45 | // Int helper to return and int from the params. 46 | func (p Params) Int(key string) int { 47 | return mapInt(p, key) 48 | } 49 | 50 | func mapInt(p map[string]any, key string) int { 51 | v, ok := p[key] 52 | if !ok { 53 | return 0 54 | } 55 | switch out := v.(type) { 56 | case int: 57 | return out 58 | case string: 59 | i, err := strconv.Atoi(out) 60 | if err != nil { 61 | return 0 62 | } 63 | return i 64 | case float32: 65 | return int(out) 66 | case float64: 67 | return int(out) 68 | } 69 | return 0 70 | } 71 | 72 | // Float32 helper to return a float32 from the params. 73 | func (p Params) Float32(key string) float32 { 74 | return mapFloat32(p, key) 75 | } 76 | 77 | func mapFloat32(p map[string]any, key string) float32 { 78 | v, ok := p[key] 79 | if !ok { 80 | return 0.0 81 | } 82 | switch out := v.(type) { 83 | case float32: 84 | return out 85 | case float64: 86 | return float32(out) 87 | case string: 88 | f, err := strconv.ParseFloat(out, 32) 89 | if err != nil { 90 | return 0.0 91 | } 92 | return float32(f) 93 | } 94 | return 0.0 95 | } 96 | 97 | // NewParamsFromRequest helper to generate Params from an http request. 98 | func NewParamsFromRequest(r *http.Request) Params { 99 | out := Params{} 100 | values := r.URL.Query() 101 | for k, v := range values { 102 | if len(v) == 1 { 103 | out[k] = v[0] 104 | } else { 105 | out[k] = v 106 | } 107 | } 108 | return out 109 | } 110 | -------------------------------------------------------------------------------- /examples/chat/chat.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | 9 | "github.com/jfyne/live" 10 | ) 11 | 12 | const ( 13 | send = "send" 14 | newmessage = "newmessage" 15 | ) 16 | 17 | type Message struct { 18 | ID string // Unique ID per message so that we can use `live-update`. 19 | User string 20 | Msg string 21 | } 22 | 23 | func NewMessage(data any) Message { 24 | // This can handle both the chat example, and the cluster example. 25 | switch m := data.(type) { 26 | case Message: 27 | return m 28 | case map[string]any: 29 | return Message{ 30 | ID: m["ID"].(string), 31 | User: m["User"].(string), 32 | Msg: m["Msg"].(string), 33 | } 34 | } 35 | return Message{} 36 | } 37 | 38 | type ChatInstance struct { 39 | Messages []Message 40 | } 41 | 42 | func NewChatInstance(s *live.Socket) *ChatInstance { 43 | m, ok := s.Assigns().(*ChatInstance) 44 | if !ok { 45 | return &ChatInstance{ 46 | Messages: []Message{ 47 | {ID: live.NewID(), User: "Room", Msg: "Welcome to chat " + string(s.ID())}, 48 | }, 49 | } 50 | } 51 | return m 52 | } 53 | 54 | func NewHandler() *live.Handler { 55 | t, err := template.ParseFiles("chat/layout.html", "chat/view.html") 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | h := live.NewHandler(live.WithTemplateRenderer(t)) 61 | 62 | // Set the mount function for this handler. 63 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { 64 | // This will initialise the chat for this socket. 65 | return NewChatInstance(s), nil 66 | } 67 | 68 | // Handle user sending a message. 69 | h.HandleEvent(send, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 70 | m := NewChatInstance(s) 71 | msg := p.String("message") 72 | if msg == "" { 73 | return m, nil 74 | } 75 | data := Message{ 76 | ID: live.NewID(), 77 | User: string(s.ID()), 78 | Msg: msg, 79 | } 80 | if err := s.Broadcast(newmessage, data); err != nil { 81 | return m, fmt.Errorf("failed braodcasting new message: %w", err) 82 | } 83 | return m, nil 84 | }) 85 | 86 | // Handle the broadcasted events. 87 | h.HandleSelf(newmessage, func(ctx context.Context, s *live.Socket, data any) (any, error) { 88 | m := NewChatInstance(s) 89 | 90 | // Here we don't append to messages as we don't want to use 91 | // loads of memory. `live-update="append"` handles the appending 92 | // of messages in the DOM. 93 | m.Messages = []Message{NewMessage(data)} 94 | return m, nil 95 | }) 96 | 97 | return h 98 | } 99 | -------------------------------------------------------------------------------- /examples/chat/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ template "title" . }} 6 | 7 | 11 | 74 | 75 | 76 | {{ template "view" . }} 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /examples/pagination/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | "net/url" 11 | 12 | "github.com/jfyne/live" 13 | ) 14 | 15 | const ( 16 | nextPage = "next-page" 17 | ) 18 | 19 | type list struct { 20 | Items []string 21 | Page int 22 | } 23 | 24 | func newList(s *live.Socket) *list { 25 | l, ok := s.Assigns().(*list) 26 | if !ok { 27 | l = &list{ 28 | Items: []string{}, 29 | Page: 0, 30 | } 31 | } 32 | return l 33 | } 34 | 35 | func (l list) NextPage() int { 36 | return l.Page + 1 37 | } 38 | 39 | func main() { 40 | t, err := template.ParseFiles("root.html", "pagination/view.html") 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | h := live.NewHandler(live.WithTemplateRenderer(t)) 46 | 47 | // Set the mount function for this handler. 48 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { 49 | return newList(s), nil 50 | } 51 | 52 | // Set the handle params function. This gets called after mount and contains the URL 53 | // query string values in the params map. This will also get called whenever the query 54 | // string is changed on the page. 55 | h.HandleParams(func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 56 | l := newList(s) 57 | l.Page = p.Int("page") 58 | l.Items = getPageOfItems(l.Page) 59 | return l, nil 60 | }) 61 | 62 | // Alternative method to get to next page, using the server side Patch event. 63 | h.HandleEvent(nextPage, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 64 | page := p.Int("page") 65 | v := url.Values{} 66 | v.Add("page", fmt.Sprintf("%d", page)) 67 | s.PatchURL(v) 68 | return s.Assigns(), nil 69 | }) 70 | 71 | // Run the server. 72 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 73 | http.Handle("/live.js", live.Javascript{}) 74 | http.Handle("/auto.js.map", live.JavascriptMap{}) 75 | slog.Info("server", "link", "http://localhost:8080") 76 | http.ListenAndServe(":8080", nil) 77 | } 78 | 79 | // getPageOfItems in real life would be a service or a database call to get a 80 | // page. 81 | func getPageOfItems(page int) []string { 82 | start := page * itemsPerPage 83 | end := start + itemsPerPage 84 | if start >= len(items) || end > len(items) { 85 | return []string{} 86 | } 87 | return items[start:end] 88 | } 89 | 90 | const ( 91 | itemCount = 100 92 | itemsPerPage = 5 93 | ) 94 | 95 | var items []string 96 | 97 | func init() { 98 | for i := 0; i < itemCount; i++ { 99 | items = append(items, fmt.Sprintf("This is item %d", i)) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /examples/alpine/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "html/template" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/jfyne/live" 13 | ) 14 | 15 | //go:embed main.js 16 | var static embed.FS 17 | 18 | const ( 19 | submit = "submit" 20 | suggest = "suggest" 21 | selected = "selected" 22 | ) 23 | 24 | type item struct { 25 | ID string 26 | Name string 27 | } 28 | 29 | func (i item) Match(search string) bool { 30 | s := strings.ToLower(search) 31 | if strings.Contains(strings.ToLower(i.Name), s) { 32 | return true 33 | } 34 | if strings.Contains(strings.ToLower(i.ID), s) { 35 | return true 36 | } 37 | return false 38 | } 39 | 40 | type Autocomplete struct { 41 | items []item 42 | Suggestions []item 43 | Selected []item 44 | } 45 | 46 | func newAutocomplete(s *live.Socket) *Autocomplete { 47 | a, ok := s.Assigns().(*Autocomplete) 48 | if !ok { 49 | return &Autocomplete{} 50 | } 51 | return a 52 | } 53 | 54 | func mount(ctx context.Context, s *live.Socket) (any, error) { 55 | a := newAutocomplete(s) 56 | a.items = []item{ 57 | {ID: "1", Name: "Item One"}, 58 | {ID: "2", Name: "Item Two"}, 59 | {ID: "3", Name: "Item Three"}, 60 | {ID: "4", Name: "Item Four"}, 61 | {ID: "5", Name: "Item Five"}, 62 | } 63 | return a, nil 64 | } 65 | 66 | func main() { 67 | t, err := template.ParseFiles("alpine/root.html", "alpine/view.html") 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | 72 | h := live.NewHandler(live.WithTemplateRenderer(t)) 73 | 74 | h.MountHandler = mount 75 | 76 | h.HandleEvent(suggest, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 77 | a := newAutocomplete(s) 78 | a.Suggestions = []item{} 79 | search := p.String("search") 80 | for _, i := range a.items { 81 | if i.Match(search) { 82 | a.Suggestions = append(a.Suggestions, i) 83 | } 84 | } 85 | return a, nil 86 | }) 87 | 88 | h.HandleEvent(selected, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 89 | a := newAutocomplete(s) 90 | id := p.String("id") 91 | // Dont select option more than once. 92 | for _, i := range a.Selected { 93 | if i.ID == id { 94 | return a, nil 95 | } 96 | } 97 | for _, i := range a.items { 98 | if i.ID == id { 99 | a.Selected = append(a.Selected, i) 100 | break 101 | } 102 | } 103 | return a, nil 104 | }) 105 | 106 | h.HandleEvent(submit, func(ctx context.Context, s *live.Socket, _ live.Params) (any, error) { 107 | return s.Assigns(), nil 108 | }) 109 | 110 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 111 | http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) 112 | slog.Info("server", "link", "http://localhost:8080") 113 | http.ListenAndServe(":8080", nil) 114 | } 115 | -------------------------------------------------------------------------------- /params_test.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | ) 8 | 9 | func TestParamString(t *testing.T) { 10 | p := Params{"test": "output"} 11 | out := p.String("test") 12 | if out != "output" { 13 | t.Error("unexpected output of ParamString", out) 14 | } 15 | 16 | empty := p.String("nokey") 17 | if empty != "" { 18 | t.Error("unexpected output of ParamString", empty) 19 | } 20 | } 21 | 22 | func TestParamCheckbox(t *testing.T) { 23 | p := Params{"test": "on"} 24 | state := p.Checkbox("test") 25 | if state != true { 26 | t.Error("unexpected output of ParamCheckbox", state) 27 | } 28 | p["test"] = "noton" 29 | state = p.Checkbox("test") 30 | if state != false { 31 | t.Error("unexpected output of ParamCheckbox", state) 32 | } 33 | state = p.Checkbox("nottest") 34 | if state != false { 35 | t.Error("unexpected output of ParamCheckbox", state) 36 | } 37 | } 38 | 39 | func TestParamInt(t *testing.T) { 40 | var out int 41 | 42 | p := Params{"test": 1} 43 | out = p.Int("test") 44 | if out != 1 { 45 | t.Error("unexpected output of ParamInt", out) 46 | } 47 | p["test"] = "1" 48 | out = p.Int("test") 49 | if out != 1 { 50 | t.Error("unexpected output of ParamInt", out) 51 | } 52 | p["test"] = "aaa" 53 | out = p.Int("test") 54 | if out != 0 { 55 | t.Error("unexpected output of ParamInt", out) 56 | } 57 | p["test"] = 1 58 | out = p.Int("nottest") 59 | if out != 0 { 60 | t.Error("unexpected output of ParamInt", out) 61 | } 62 | } 63 | 64 | func TestParamFloat32(t *testing.T) { 65 | var out float32 66 | 67 | p := Params{"test": 1.0} 68 | out = p.Float32("test") 69 | if out != 1.0 { 70 | t.Error("unexpected output of ParamFloat32", out) 71 | } 72 | p["test"] = "1.0" 73 | out = p.Float32("test") 74 | if out != 1.0 { 75 | t.Error("unexpected output of ParamFloat32", out) 76 | } 77 | p["test"] = "aaa" 78 | out = p.Float32("test") 79 | if out != 0.0 { 80 | t.Error("unexpected output of ParamFloat32", out) 81 | } 82 | p["test"] = 1.0 83 | out = p.Float32("nottest") 84 | if out != 0.0 { 85 | t.Error("unexpected output of ParamFloat32", out) 86 | } 87 | } 88 | 89 | func TestParamsFromRequest(t *testing.T) { 90 | var err error 91 | r := &http.Request{} 92 | r.URL, err = url.Parse("http://example.com?one=1&two=2&three=3&three=4") 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | params := NewParamsFromRequest(r) 97 | var out int 98 | out = params.Int("one") 99 | if out != 1 { 100 | t.Error("did not get expected params", params) 101 | } 102 | out = params.Int("two") 103 | if out != 2 { 104 | t.Error("did not get expected params", params) 105 | } 106 | 107 | sliceout, ok := params["three"].([]string) 108 | if !ok { 109 | t.Error("did not get expected params", params) 110 | } 111 | if len(sliceout) != 2 { 112 | t.Error("did not get expected params", params) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /examples/components/clock.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "time" 8 | 9 | "github.com/jfyne/live" 10 | "github.com/jfyne/live/page" 11 | ) 12 | 13 | const ( 14 | tick = "tick" 15 | ) 16 | 17 | // ClockState the state we are tracking per clock. 18 | type ClockState struct { 19 | TZ string 20 | Time time.Time 21 | loc *time.Location 22 | } 23 | 24 | // FormattedTime output the time in a nice format. 25 | func (c ClockState) FormattedTime() string { 26 | return c.Time.Format("15:04:05") 27 | } 28 | 29 | // Update the states time. 30 | func (c *ClockState) Update(t time.Time) { 31 | c.Time = t.In(c.loc) 32 | } 33 | 34 | // NewClockState create a new clock state from a timezone string. 35 | func NewClockState(timezone string) (*ClockState, error) { 36 | location, err := time.LoadLocation(timezone) 37 | if err != nil { 38 | return nil, err 39 | } 40 | now := time.Now().In(location) 41 | c := &ClockState{ 42 | Time: now, 43 | loc: location, 44 | TZ: timezone, 45 | } 46 | return c, nil 47 | } 48 | 49 | // clockRegister register the clocks events. 50 | func clockRegister(c *page.Component) error { 51 | // The clock listens for a tick event, then sends a new one after a second. On this 52 | // event it updates its own time. 53 | c.HandleSelf(tick, func(ctx context.Context, d any) (any, error) { 54 | clock, ok := c.State.(*ClockState) 55 | if !ok { 56 | return nil, fmt.Errorf("no clock data") 57 | } 58 | clock.Update(d.(time.Time)) 59 | 60 | go func(sock *live.Socket) { 61 | time.Sleep(1 * time.Second) 62 | c.Self(ctx, sock, tick, time.Now()) 63 | }(c.Socket) 64 | 65 | return clock, nil 66 | }) 67 | return nil 68 | } 69 | 70 | // clockMount initialise the clock component. 71 | func clockMount(timezone string) page.MountHandler { 72 | return func(ctx context.Context, c *page.Component) error { 73 | // If we are mounting on connection send the first tick event. 74 | if c.Socket.Connected() { 75 | go func() { 76 | time.Sleep(1 * time.Second) 77 | c.Self(ctx, c.Socket, tick, time.Now()) 78 | }() 79 | } 80 | state, err := NewClockState(timezone) 81 | if err != nil { 82 | return err 83 | } 84 | c.State = state 85 | return nil 86 | } 87 | } 88 | 89 | // clockRender render the clock component. 90 | func clockRender(w io.Writer, c *page.Component) error { 91 | // The page.HTML helper function renders a go template and passes in the 92 | // component state. 93 | return page.HTML(` 94 |
95 |

{{.TZ}}

96 | 97 |
98 | `, c).Render(w) 99 | } 100 | 101 | // NewClock create a new clock component. 102 | func NewClock(ID string, h *live.Handler, s *live.Socket, timezone string) (*page.Component, error) { 103 | return page.NewComponent(ID, h, s, 104 | page.WithRegister(clockRegister), 105 | page.WithMount(clockMount(timezone)), 106 | page.WithRender(clockRender), 107 | ) 108 | } 109 | -------------------------------------------------------------------------------- /examples/cluster/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | 11 | "github.com/jfyne/live" 12 | "github.com/jfyne/live/examples/chat" 13 | "gocloud.dev/pubsub" 14 | _ "gocloud.dev/pubsub/mempubsub" 15 | ) 16 | 17 | const app = "chat-app" 18 | 19 | type CloudTransport struct { 20 | topic *pubsub.Topic 21 | } 22 | 23 | func NewCloudTransport(ctx context.Context) (*CloudTransport, error) { 24 | topic, err := pubsub.OpenTopic(ctx, "mem://broadcast") 25 | if err != nil { 26 | return nil, err 27 | } 28 | return &CloudTransport{ 29 | topic: topic, 30 | }, nil 31 | } 32 | 33 | func (c *CloudTransport) Publish(ctx context.Context, topic string, msg live.Event) error { 34 | data, err := json.Marshal(live.TransportMessage{Topic: topic, Msg: msg}) 35 | if err != nil { 36 | return fmt.Errorf("could not publish event: %w", err) 37 | } 38 | return c.topic.Send(ctx, &pubsub.Message{ 39 | Body: data, 40 | Metadata: map[string]string{ 41 | "topic": topic, 42 | }, 43 | }) 44 | } 45 | 46 | func (c *CloudTransport) Listen(ctx context.Context, p *live.PubSub) error { 47 | sub, err := pubsub.OpenSubscription(ctx, "mem://broadcast") 48 | if err != nil { 49 | return fmt.Errorf("could not open subscription: %w", err) 50 | } 51 | for { 52 | msg, err := sub.Receive(ctx) 53 | if err != nil { 54 | log.Println("receive message failed: %w", err) 55 | break 56 | } 57 | 58 | var t live.TransportMessage 59 | if err := json.Unmarshal(msg.Body, &t); err != nil { 60 | log.Println("malformed message received: %w", err) 61 | continue 62 | } 63 | p.Recieve(t.Topic, t.Msg) 64 | msg.Ack() 65 | } 66 | return fmt.Errorf("stopped receiving messages") 67 | } 68 | 69 | func main() { 70 | // Here we are creating three of the same handler to show 71 | // how they can all receive the same broadcast messages. 72 | chat1 := live.NewHttpHandler(context.Background(), chat.NewHandler()) 73 | chat2 := live.NewHttpHandler(context.Background(), chat.NewHandler()) 74 | chat3 := live.NewHttpHandler(context.Background(), chat.NewHandler()) 75 | 76 | ctx := context.Background() 77 | 78 | // We use the cloud transport defined above, which is 79 | // simulating a pub sub type system in memory. 80 | t, err := NewCloudTransport(ctx) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | 85 | // Create the pubsub instance. 86 | pubsub := live.NewPubSub(ctx, t) 87 | 88 | // Now subscribe each handler to the same "topic", this 89 | // will then set them up to receive broadcasted events 90 | // from each other. 91 | pubsub.Subscribe(app, chat1) 92 | pubsub.Subscribe(app, chat2) 93 | pubsub.Subscribe(app, chat3) 94 | 95 | // Run the server. 96 | http.Handle("/one", chat1) 97 | http.Handle("/two", chat2) 98 | http.Handle("/three", chat3) 99 | http.Handle("/live.js", live.Javascript{}) 100 | http.Handle("/auto.js.map", live.JavascriptMap{}) 101 | slog.Info("server", "link", "http://localhost:8080") 102 | http.ListenAndServe(":8080", nil) 103 | } 104 | -------------------------------------------------------------------------------- /examples/todo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | 11 | "github.com/jfyne/live" 12 | ) 13 | 14 | const ( 15 | validate = "validate" 16 | save = "save" 17 | done = "done" 18 | ) 19 | 20 | type form struct { 21 | Errors map[string]string 22 | } 23 | 24 | type task struct { 25 | ID string 26 | Name string 27 | Complete bool 28 | } 29 | 30 | type model struct { 31 | Tasks []task 32 | Form form 33 | } 34 | 35 | func newModel(s *live.Socket) *model { 36 | m, ok := s.Assigns().(*model) 37 | if !ok { 38 | return &model{ 39 | Form: form{ 40 | Errors: map[string]string{}, 41 | }, 42 | } 43 | } 44 | // Clear errors on each event as we recheck each 45 | // time. 46 | m.Form.Errors = map[string]string{} 47 | return m 48 | } 49 | 50 | func main() { 51 | t, err := template.ParseFiles("root.html", "todo/view.html") 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | 56 | h := live.NewHandler(live.WithTemplateRenderer(t)) 57 | 58 | // Set the mount function for this handler. 59 | h.MountHandler = func(ctx context.Context, s *live.Socket) (any, error) { 60 | // This will initialise the form. 61 | return newModel(s), nil 62 | } 63 | 64 | // Client side events. 65 | validateMessage := func(msg string) string { 66 | if len(msg) < 10 { 67 | return fmt.Sprintf("Length of 10 required, have %d", len(msg)) 68 | } 69 | if len(msg) > 20 { 70 | return fmt.Sprintf("Your task name is too long > 20, have %d", len(msg)) 71 | } 72 | return "" 73 | } 74 | 75 | // Validate the form. 76 | h.HandleEvent(validate, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 77 | m := newModel(s) 78 | t := p.String("task") 79 | vm := validateMessage(t) 80 | if vm != "" { 81 | m.Form.Errors["message"] = vm 82 | } 83 | return m, nil 84 | }) 85 | 86 | // Handle form saving. 87 | h.HandleEvent(save, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 88 | m := newModel(s) 89 | ts := p.String("task") 90 | complete := p.Checkbox("complete") 91 | vm := validateMessage(ts) 92 | if vm != "" { 93 | m.Form.Errors["message"] = vm 94 | } else { 95 | t := task{ 96 | ID: live.NewID(), 97 | Name: ts, 98 | Complete: complete, 99 | } 100 | m.Tasks = append(m.Tasks, t) 101 | } 102 | return m, nil 103 | }) 104 | 105 | // Handle completing tasks. 106 | h.HandleEvent(done, func(ctx context.Context, s *live.Socket, p live.Params) (any, error) { 107 | m := newModel(s) 108 | ID := p.String("id") 109 | for idx, t := range m.Tasks { 110 | if t.ID != ID { 111 | continue 112 | } 113 | m.Tasks[idx].Complete = !m.Tasks[idx].Complete 114 | } 115 | return m, nil 116 | }) 117 | 118 | // Run the server. 119 | http.Handle("/", live.NewHttpHandler(context.Background(), h)) 120 | http.Handle("/live.js", live.Javascript{}) 121 | http.Handle("/auto.js.map", live.JavascriptMap{}) 122 | slog.Info("server", "link", "http://localhost:8080") 123 | http.ListenAndServe(":8080", nil) 124 | } 125 | -------------------------------------------------------------------------------- /pubsub.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "log/slog" 7 | ) 8 | 9 | // PubSubTransport is how the messages should be sent to the listeners. 10 | type PubSubTransport interface { 11 | // Publish a message onto the given topic. 12 | Publish(ctx context.Context, topic string, msg Event) error 13 | // Listen will be called in a go routine so should be written to 14 | // block. 15 | Listen(ctx context.Context, p *PubSub) error 16 | } 17 | 18 | // PubSub handles communication between handlers. Depending on the given 19 | // transport this could be between handlers in an application, or across 20 | // nodes in a cluster. 21 | type PubSub struct { 22 | transport PubSubTransport 23 | handlers map[string][]*Engine 24 | } 25 | 26 | // NewPubSub creates a new PubSub handler. 27 | func NewPubSub(ctx context.Context, t PubSubTransport) *PubSub { 28 | p := &PubSub{ 29 | transport: t, 30 | handlers: map[string][]*Engine{}, 31 | } 32 | go func(ctx context.Context, ps *PubSub) { 33 | if err := t.Listen(ctx, ps); err != nil { 34 | log.Fatal("could not listen on pubsub: %w", err) 35 | } 36 | }(ctx, p) 37 | return p 38 | } 39 | 40 | // Publish send a message on a topic. 41 | func (p *PubSub) Publish(ctx context.Context, topic string, msg Event) error { 42 | return p.transport.Publish(ctx, topic, msg) 43 | } 44 | 45 | // Subscribe adds a handler to a PubSub topic. 46 | func (p *PubSub) Subscribe(topic string, h *Engine) { 47 | p.handlers[topic] = append(p.handlers[topic], h) 48 | 49 | // This adjusts the handlers broadcast function to publish onto the 50 | // given topic. 51 | h.BroadcastHandler = func(ctx context.Context, h *Engine, msg Event) { 52 | if err := p.transport.Publish(ctx, topic, msg); err != nil { 53 | slog.Error("could not publish broadcast", "err", err) 54 | } 55 | } 56 | } 57 | 58 | // Recieve a message from the transport. 59 | func (p *PubSub) Recieve(topic string, msg Event) { 60 | ctx := context.Background() 61 | for _, node := range p.handlers[topic] { 62 | node.self(ctx, nil, msg) 63 | } 64 | } 65 | 66 | // TransportMessage a useful container to send live events. 67 | type TransportMessage struct { 68 | Topic string 69 | Msg Event 70 | } 71 | 72 | // LocalTransport a pubsub transport that allows handlers to communicate 73 | // locally. 74 | type LocalTransport struct { 75 | queue chan TransportMessage 76 | } 77 | 78 | // NewLocalTransport create a new LocalTransport. 79 | func NewLocalTransport() *LocalTransport { 80 | return &LocalTransport{ 81 | queue: make(chan TransportMessage), 82 | } 83 | } 84 | 85 | // Publish send a message to all handlers subscribed to a topic. 86 | func (l *LocalTransport) Publish(ctx context.Context, topic string, msg Event) error { 87 | l.queue <- TransportMessage{Topic: topic, Msg: msg} 88 | return nil 89 | } 90 | 91 | // Listen listen for new published messages. 92 | func (l *LocalTransport) Listen(ctx context.Context, p *PubSub) error { 93 | for { 94 | select { 95 | case msg := <-l.queue: 96 | p.Recieve(msg.Topic, msg.Msg) 97 | case <-ctx.Done(): 98 | return nil 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /examples/alpine/view.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} Alpine Example {{ end }} {{ define "view" }} 2 |
3 | 16 | 17 |
18 |
19 |
20 | 27 |
28 |
29 |
    34 | {{ range $idx, $i := .Assigns.Suggestions }} 35 |
  • 42 | {{$i.Name}} 43 |
  • 44 | {{ end }} 45 |
46 |
47 |
48 |
49 | 50 |
51 |

Selected items

52 | 53 |
54 |
    55 | {{ range $idx, $i := .Assigns.Selected }} 56 |
  • {{$i.Name}}
  • 57 | {{ end }} 58 |
59 |
60 |
61 |
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 | --------------------------------------------------------------------------------