-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathtemplate.go
More file actions
1787 lines (1605 loc) · 63.6 KB
/
template.go
File metadata and controls
1787 lines (1605 loc) · 63.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Package livetemplate provides a library for building real-time, reactive web applications
// in Go with minimal code. It uses tree-based DOM diffing to send only what changed over
// WebSocket or HTTP, inspired by Phoenix LiveView.
//
// # Quick Start
//
// Define your application state as a Go struct with methods for each action:
//
// type Counter struct {
// Count int
// }
//
// func (c *Counter) Increment(ctx *livetemplate.ActionContext) error {
// c.Count++
// return nil
// }
//
// func (c *Counter) Decrement(ctx *livetemplate.ActionContext) error {
// c.Count--
// return nil
// }
//
// Actions are automatically dispatched to methods matching the action name
// (e.g., "increment" → Increment, "add_item" → AddItem).
//
// Create a template with `lvt-on:{event}` attributes for event binding:
//
// <!-- counter.tmpl -->
// <h1>Counter: {{.Count}}</h1>
// <button lvt-on:click="increment">+</button>
// <button lvt-on:click="decrement">-</button>
//
// Wire it up in your main function:
//
// func main() {
// counter := &Counter{Count: 0}
// tmpl := livetemplate.New("counter")
// http.Handle("/", tmpl.Handle(counter))
// http.ListenAndServe(":8080", nil)
// }
//
// # How It Works
//
// LiveTemplate separates static and dynamic content in templates:
//
// - Static content (HTML structure, unchanging text) is sent once and cached client-side
// - Dynamic content (data values) is sent on every update as a minimal tree diff
// - This achieves 50-90% bandwidth reduction compared to sending full HTML
//
// The client library (TypeScript) handles WebSocket communication, event delegation,
// and applying DOM updates efficiently.
//
// # Tree-Based Updates
//
// Templates are parsed into a tree structure that separates statics and dynamics:
//
// {
// "s": ["<div>Count: ", "</div>"], // Statics (cached)
// "0": "42" // Dynamic value
// }
//
// Subsequent updates only send changed dynamic values:
//
// {
// "0": "43" // Only the changed value
// }
//
// # Key Types
//
// - Template: Manages template parsing, execution, and update generation
// - Store: Interface for application state and action handlers
// - ActionContext: Provides action data and utilities in Change() method
// - ActionData: Type-safe data extraction and validation
// - Broadcaster: Share state updates across all connected clients
// - SessionStore: Per-session state management
//
// # Advanced Features
//
// - Multi-store pattern: Namespace multiple stores in one template
// - Broadcasting: Real-time updates to all connected clients
// - Server-side validation: Automatic error handling with go-playground/validator
// - Form lifecycle events: Client-side hooks for pending, success, error, done
// - Focus preservation: Maintains input focus and scroll position during updates
//
// For complete documentation, see https://github.com/livetemplate/livetemplate
package livetemplate
import (
"embed"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/livetemplate/livetemplate/internal/build"
"github.com/livetemplate/livetemplate/internal/compat"
"github.com/livetemplate/livetemplate/internal/context"
"github.com/livetemplate/livetemplate/internal/diff"
"github.com/livetemplate/livetemplate/internal/discovery"
"github.com/livetemplate/livetemplate/internal/keys"
"github.com/livetemplate/livetemplate/internal/observe"
"github.com/livetemplate/livetemplate/internal/parse"
"github.com/livetemplate/livetemplate/internal/render"
"github.com/livetemplate/livetemplate/internal/send"
"github.com/livetemplate/livetemplate/internal/session"
uploadtypes "github.com/livetemplate/livetemplate/internal/uploadtypes"
"github.com/livetemplate/livetemplate/pubsub"
)
// =============================================================================
// Internal Type Aliases (Not Part of Public API)
// =============================================================================
//
// IMPORTANT: These type aliases exist in the main package (not internal/) to support
// Template's implementation and same-package test files, but are NOT part of the stable
// public API. External users should NOT depend on these types - they may change without notice.
//
// Why lowercase (unexported)?
// - These are true internal implementation details
// - Only Template methods and same-package tests need access
// - External packages cannot and should not use them
//
// These aliases exist to:
// - Provide convenient access to internal types within this package
// - Maintain clean imports without exposing internal packages publicly
// - Support backward compatibility for internal test code
// treeNode is an internal alias for build.TreeNode.
// Used internally by Template for tree caching and comparison.
type treeNode = build.TreeNode
// keyGenerator is an internal alias for keys.Generator.
// Used internally by Template for sequential key generation.
type keyGenerator = keys.Generator
// =============================================================================
// Configuration
// =============================================================================
// Config holds template configuration options
type Config struct {
Upgrader WSUpgrader
SessionStore SessionStore
Authenticator Authenticator // User authentication and session grouping
PubSubBroadcaster pubsub.Broadcaster // Optional: for distributed broadcasting across instances
AllowedOrigins []string // Allowed WebSocket origins (empty = allow all in dev, restrict in prod)
WebSocketDisabled bool
LoadingDisabled bool // Disables automatic loading indicator on page load
TemplateFiles []string // If set, overrides auto-discovery
TemplateBaseDir string // Base directory for template auto-discovery (default: directory of calling code via runtime.Caller)
IgnoreTemplateDirs []string // Additional directories to ignore during auto-discovery
DevMode bool // Development mode - use local client library instead of CDN
MaxConnections int64 // Maximum total connections (0 = unlimited)
MaxConnectionsPerGroup int64 // Maximum connections per group (0 = unlimited)
MessageRateLimit float64 // Messages per second per connection (0 = unlimited, default 10)
MessageRateBurst int // Burst capacity for rate limiting (default 20)
CookieMaxAge time.Duration // Session cookie max age (default: 1 year)
UploadConfigs map[string]uploadtypes.UploadConfig // Upload field configurations
WebSocketBufferSize int // WebSocket send buffer size per connection (default: 50)
ComponentTemplates []*TemplateSet // Component library templates (parsed before project templates)
ProgressiveEnhancement bool // Enable non-JS form submission support with PRG pattern (default: true)
TrustForwardedHeaders bool // Trust X-Forwarded-Proto header for scheme detection (default: true)
DispatchBufferSize int // Broadcast dispatch channel buffer per connection (default: 16)
}
// =============================================================================
// Component Template Registration
// =============================================================================
// TemplateSet represents a collection of embedded templates from a component library.
// Components create TemplateSet instances to expose their templates for registration.
//
// Example usage in a component library:
//
// package dropdown
//
// import "embed"
//
// //go:embed templates/*.tmpl
// var templateFS embed.FS
//
// func Templates() *livetemplate.TemplateSet {
// return &livetemplate.TemplateSet{
// FS: templateFS,
// Pattern: "templates/*.tmpl",
// Namespace: "dropdown",
// }
// }
//
// Example usage in main.go:
//
// tmpl, err := livetemplate.New("app",
// livetemplate.WithComponentTemplates(dropdown.Templates(), tabs.Templates()),
// )
type TemplateSet struct {
// FS is the embedded filesystem containing the template files.
FS embed.FS
// Pattern is the glob pattern for matching template files within FS.
// Examples: "templates/*.tmpl", "*.tmpl"
Pattern string
// Namespace identifies the component type for this template set.
// Used for documentation and debugging purposes.
// Example: "dropdown" for templates like "lvt:dropdown:searchable:v1"
Namespace string
// Funcs provides additional template functions for this component.
// These are merged with the base template functions when parsing.
Funcs template.FuncMap
}
// Template represents a live template with caching and tree-based optimization capabilities.
// It provides an API similar to html/template.Template but with additional ExecuteUpdates method
// for generating tree-based updates that can be efficiently transmitted to clients.
type Template struct {
name string
templateStr string
tmpl *template.Template
wrapperID string
funcs template.FuncMap
mu sync.RWMutex // Protects mutable state fields below
lastHTML string
lastTree *treeNode // Store previous tree segments for comparison
hasInitialTree bool
keyGen *keyGenerator // Per-template key generation for wrapper approach
config Config // Template configuration
uploadRegistry interface{} // Upload registry for this connection (*upload.Registry)
cachedParseTemplate *parse.Template // Cached AST to avoid re-parsing on every render
cachedBodyContent string // Cached result of ExtractTemplateBodyContent(t.templateStr)
cachedBodyContentValid bool // Whether cachedBodyContent has been computed (empty string is valid)
}
// Funcs registers a template.FuncMap that will be applied to all template parsing and execution.
func (t *Template) Funcs(funcMap template.FuncMap) *Template {
if len(funcMap) == 0 {
return t
}
if t.funcs == nil {
t.funcs = make(template.FuncMap, len(funcMap))
}
for name, fn := range funcMap {
t.funcs[name] = fn
}
// Update the existing parsed template if one is available.
t.mu.Lock()
if t.tmpl != nil {
t.tmpl = t.tmpl.Funcs(t.funcs)
}
t.cachedParseTemplate = nil // Invalidate cached AST since funcMap changed
t.mu.Unlock()
return t
}
// UpdateResponse wraps a tree update with metadata for form lifecycle.
// Tree is an opaque type representing the update payload - the client library handles this automatically.
// This is an alias for internal/send.UpdateResponse for backward compatibility.
type UpdateResponse = send.UpdateResponse
// ResponseMetadata contains information about the action that generated the update.
// This is an alias for internal/send.ResponseMetadata for backward compatibility.
type ResponseMetadata = send.ResponseMetadata
// Option is a functional option for configuring a Template
type Option func(*Config)
// WithParseFiles specifies template files to parse, overriding auto-discovery
func WithParseFiles(files ...string) Option {
return func(c *Config) {
c.TemplateFiles = files
}
}
// WithTemplateBaseDir sets the base directory for template auto-discovery.
// This overrides the default runtime.Caller detection. Useful when running
// via 'go run' or when templates are in a non-standard location.
func WithTemplateBaseDir(dir string) Option {
return func(c *Config) {
c.TemplateBaseDir = dir
}
}
// WithUpgrader sets a custom WebSocket upgrader.
func WithUpgrader(upgrader WSUpgrader) Option {
return func(c *Config) {
c.Upgrader = upgrader
}
}
// WithSessionStore sets a custom session store for HTTP requests
func WithSessionStore(store SessionStore) Option {
return func(c *Config) {
c.SessionStore = store
}
}
// WithWebSocketDisabled disables WebSocket support, forcing HTTP-only mode
func WithWebSocketDisabled() Option {
return func(c *Config) {
c.WebSocketDisabled = true
}
}
// WithLoadingDisabled disables the automatic loading indicator shown during page initialization
func WithLoadingDisabled() Option {
return func(c *Config) {
c.LoadingDisabled = true
}
}
// WithDevMode enables development mode - uses local client library instead of CDN
func WithDevMode(enabled bool) Option {
return func(c *Config) {
c.DevMode = enabled
}
}
// WithAuthenticator sets a custom authenticator for user identification and session grouping.
//
// The authenticator determines:
// - Who is the user? (userID via Identify)
// - Which session group should they join? (groupID via GetSessionGroup)
//
// Default: AnonymousAuthenticator (browser-based session grouping)
//
// Example with BasicAuthenticator:
//
// auth := livetemplate.NewBasicAuthenticator(func(username, password string) (bool, error) {
// return db.ValidateUser(username, password)
// })
// tmpl := livetemplate.New("app", livetemplate.WithAuthenticator(auth))
//
// Example with custom JWT authenticator:
//
// tmpl := livetemplate.New("app", livetemplate.WithAuthenticator(myJWTAuth))
func WithAuthenticator(auth Authenticator) Option {
return func(c *Config) {
c.Authenticator = auth
}
}
// WithAllowedOrigins sets the allowed WebSocket origins for CORS protection.
//
// When set, WebSocket upgrade requests will be validated against this list.
// Requests from origins not in the list will be rejected with 403 Forbidden.
//
// If empty (default):
// - Development: All origins allowed (permissive for local dev)
// - Production: Consider setting explicitly for security
//
// Example for production:
//
// tmpl := livetemplate.New("app",
// livetemplate.WithAllowedOrigins([]string{
// "https://yourdomain.com",
// "https://www.yourdomain.com",
// }))
//
// Security note: Always set this in production to prevent CSRF attacks via WebSocket.
//
// When AllowedOrigins is not set, same-origin detection relies on X-Forwarded-Proto
// (if present and trusted) or r.TLS to determine the request scheme. By default,
// X-Forwarded-Proto is trusted (see [WithTrustForwardedHeaders]). If the server is
// directly reachable by clients without a proxy, either set WithTrustForwardedHeaders(false)
// to ignore forwarded headers, or use WithAllowedOrigins to explicitly list trusted origins.
func WithAllowedOrigins(origins []string) Option {
return func(c *Config) {
c.AllowedOrigins = origins
}
}
// WithTrustForwardedHeaders controls whether X-Forwarded-Proto is trusted for
// scheme detection in same-origin WebSocket checks.
//
// Default: true (backward compatible). When true, the origin checker reads
// X-Forwarded-Proto to determine whether the original client connection used
// HTTP or HTTPS. This is safe when the server is behind a reverse proxy that
// sets/overwrites this header.
//
// Set to false if the server is directly reachable by clients (no proxy) to
// prevent clients from forging the header. In this case, scheme detection falls
// back to r.TLS (non-nil = HTTPS, nil = HTTP).
//
// This option only affects the default same-origin check. It has no effect when
// WithAllowedOrigins is set (explicit origins take priority).
func WithTrustForwardedHeaders(trust bool) Option {
return func(c *Config) {
c.TrustForwardedHeaders = trust
}
}
// WithPermissiveOriginCheck disables origin checking for WebSocket connections.
//
// WARNING: This allows connections from any origin and should ONLY be used in:
// - Local development environments
// - Testing scenarios
// - Specific use cases where CSRF protection is handled externally
//
// In production, use WithAllowedOrigins() instead to specify trusted origins.
//
// Example:
//
// // Development only - DO NOT use in production
// tmpl := livetemplate.New("app",
// livetemplate.WithDevMode(true),
// livetemplate.WithPermissiveOriginCheck(),
// )
func WithPermissiveOriginCheck() Option {
return func(c *Config) {
if gu, ok := c.Upgrader.(*GorillaUpgrader); ok {
gu.SetCheckOrigin(func(r *http.Request) bool {
return true
})
} else {
slog.Warn("WithPermissiveOriginCheck has no effect on non-Gorilla WSUpgrader implementations")
}
}
}
// WithIgnoreTemplateDirs adds directories to ignore during template auto-discovery.
// This is useful to skip directories containing generator templates or other non-runtime templates.
//
// Example:
//
// tmpl := livetemplate.New("app", livetemplate.WithIgnoreTemplateDirs("generators", "scaffolds"))
func WithIgnoreTemplateDirs(dirs ...string) Option {
return func(c *Config) {
c.IgnoreTemplateDirs = append(c.IgnoreTemplateDirs, dirs...)
}
}
// WithMaxConnections sets the maximum number of concurrent connections.
// 0 (default) means unlimited.
func WithMaxConnections(max int64) Option {
return func(c *Config) {
c.MaxConnections = max
}
}
// WithMaxConnectionsPerGroup sets the maximum number of connections per session group.
// 0 (default) means unlimited. Prevents single users from exhausting connection limits.
func WithMaxConnectionsPerGroup(max int64) Option {
return func(c *Config) {
c.MaxConnectionsPerGroup = max
}
}
// WithWebSocketBufferSize sets the send buffer size per WebSocket connection.
//
// The buffer queues messages for async delivery. Larger buffers handle burst traffic better
// but use more memory. Smaller buffers use less memory but may close slow clients more aggressively.
//
// Default: 50 messages per connection
// - Memory per connection: ~50KB (assuming 1KB avg message size)
// - Memory for 100 connections: ~5MB
//
// Recommended values:
// - Low traffic / memory constrained: 10-25
// - Normal traffic: 50 (default)
// - High traffic / burst heavy: 100-1000
//
// Environment variable override: LVT_WS_BUFFER_SIZE
//
// Example:
//
// // High-throughput application
// tmpl := livetemplate.New("app", livetemplate.WithWebSocketBufferSize(100))
//
// // Memory-constrained environment
// tmpl := livetemplate.New("app", livetemplate.WithWebSocketBufferSize(10))
func WithWebSocketBufferSize(size int) Option {
return func(c *Config) {
if size <= 0 {
slog.Warn("Invalid WebSocketBufferSize, using default", slog.Int("value", size), slog.Int("default", 50))
c.WebSocketBufferSize = 50
} else {
c.WebSocketBufferSize = size
}
}
}
// WithDispatchBufferSize sets the buffer size for the broadcast dispatch channel
// per WebSocket connection. This is separate from the WebSocket send buffer
// (WithWebSocketBufferSize) because dispatch requests are less frequent.
// Default: 16. Increase for apps with high broadcast fan-out.
func WithDispatchBufferSize(size int) Option {
return func(c *Config) {
c.DispatchBufferSize = size
}
}
// WithWebSocketCompression enables permessage-deflate WebSocket compression.
// Reduces bandwidth for larger payloads at the cost of CPU.
// Only effective when using the default GorillaUpgrader.
func WithWebSocketCompression() Option {
return func(c *Config) {
if gu, ok := c.Upgrader.(*GorillaUpgrader); ok {
gu.SetCompression(true)
} else {
slog.Warn("WithWebSocketCompression has no effect on non-Gorilla WSUpgrader implementations")
}
}
}
// WithMessageRateLimit sets the rate limit for WebSocket messages per connection.
//
// Uses token bucket algorithm: messagesPerSecond determines the rate,
// burstCapacity allows short bursts above the rate.
//
// Default: 10 messages/sec with burst of 20.
// Set messagesPerSecond = 0 to disable rate limiting (not recommended for production).
//
// Example:
//
// tmpl := livetemplate.New("app",
// livetemplate.WithMessageRateLimit(20, 50), // 20 msg/sec, burst of 50
// )
func WithMessageRateLimit(messagesPerSecond float64, burstCapacity int) Option {
return func(c *Config) {
c.MessageRateLimit = messagesPerSecond
c.MessageRateBurst = burstCapacity
}
}
// WithCookieMaxAge sets the maximum age for session cookies.
//
// The cookie is used to maintain anonymous user sessions across page reloads.
// Default: 365 days (1 year)
//
// Example:
//
// tmpl := livetemplate.New("app",
// livetemplate.WithCookieMaxAge(30*24*time.Hour), // 30 days
// )
func WithCookieMaxAge(maxAge time.Duration) Option {
return func(c *Config) {
c.CookieMaxAge = maxAge
}
}
// WithUpload configures file upload support for a specific form field.
//
// Upload configuration specifies validation rules, size limits, and storage options.
// Once configured, uploads are accessible via Context during action handling.
//
// Example:
//
// tmpl := livetemplate.New("profile",
// livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
// Accept: []string{"image/png", "image/jpeg"},
// MaxFileSize: 5 << 20, // 5 MB
// MaxFiles: 1,
// }),
// )
//
// In your controller's action method, access uploads via Context:
//
// func (c *ProfileController) SaveProfile(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) {
// if ctx.HasUploads("avatar") {
// for _, entry := range ctx.GetCompletedUploads("avatar") {
// state.AvatarURL = moveToStorage(entry.TempPath)
// }
// }
// return state, nil
// }
func WithUpload(name string, config uploadtypes.UploadConfig) Option {
return func(c *Config) {
if c.UploadConfigs == nil {
c.UploadConfigs = make(map[string]uploadtypes.UploadConfig)
}
c.UploadConfigs[name] = config
}
}
// WithPubSubBroadcaster enables distributed broadcasting across multiple application instances.
//
// When set, Broadcast*, BroadcastToUsers, and BroadcastToGroup methods will publish messages
// to Redis Pub/Sub for distribution to all instances. Each instance subscribes to these messages
// and fans them out to its local connections.
//
// This is essential for horizontal scaling - without it, broadcasts only reach connections
// on the same instance.
//
// Example:
//
// import (
// "github.com/livetemplate/livetemplate"
// "github.com/livetemplate/livetemplate/pubsub"
// "github.com/redis/go-redis/v9"
// )
//
// redisClient := redis.NewClient(&redis.Options{
// Addr: "localhost:6379",
// })
//
// broadcaster := pubsub.NewRedisBroadcaster(redisClient)
//
// tmpl := livetemplate.New("app",
// livetemplate.WithPubSubBroadcaster(broadcaster),
// )
func WithPubSubBroadcaster(broadcaster pubsub.Broadcaster) Option {
return func(c *Config) {
c.PubSubBroadcaster = broadcaster
}
}
// WithComponentTemplates registers component library templates to be parsed before project templates.
// This enables using pre-built UI components from the livetemplate/components library or custom
// component libraries.
//
// Component templates are parsed first, then project templates are parsed on top, allowing
// project templates to override component templates with the same name.
//
// Example:
//
// import "github.com/livetemplate/lvt/components"
//
// tmpl, err := livetemplate.New("app",
// livetemplate.WithComponentTemplates(components.All()...),
// )
//
// Or with specific components:
//
// import (
// "github.com/livetemplate/lvt/components/dropdown"
// "github.com/livetemplate/lvt/components/tabs"
// )
//
// tmpl, err := livetemplate.New("app",
// livetemplate.WithComponentTemplates(
// dropdown.Templates(),
// tabs.Templates(),
// ),
// )
//
// Templates are parsed in the order provided. Official component templates use the naming
// convention "lvt:<category>:<name>:v<version>" (e.g., "lvt:dropdown:searchable:v1").
// Third-party components may use their own prefix (e.g., "myorg:widget:default:v1").
func WithComponentTemplates(sets ...*TemplateSet) Option {
return func(c *Config) {
c.ComponentTemplates = append(c.ComponentTemplates, sets...)
}
}
// WithProgressiveEnhancement enables or disables progressive enhancement support.
//
// When enabled (default: true), HTTP form submissions from non-JavaScript clients
// receive full HTML page responses instead of JSON. This allows applications to
// work without JavaScript using standard HTML form submissions.
//
// The feature uses the POST-Redirect-GET (PRG) pattern:
// - Successful actions: 303 redirect to prevent duplicate submissions on refresh
// - Validation errors: Re-render page with errors inline (no redirect)
//
// Detection uses the Accept header: clients sending "application/json" receive JSON,
// while browsers sending "text/html" receive full HTML pages.
//
// Example form structure for progressive enhancement:
//
// <form method="POST">
// <input type="text" name="title">
// <button name="action" value="add" type="submit">Add</button>
// </form>
//
// Using an explicit action value avoids ambiguous POST parsing when other form
// fields submit empty strings.
//
// The form works with both JavaScript (via WebSocket or fetch/JSON) and without
// JavaScript (via method="POST").
func WithProgressiveEnhancement(enabled bool) Option {
return func(c *Config) {
c.ProgressiveEnhancement = enabled
}
}
// New creates a new template with the given name and options.
//
// By default, New auto-discovers template files in the current directory and common
// template directories (templates/, views/, etc.), looking for files with extensions:
// .tmpl, .html, .gotmpl
//
// # Template Discovery
//
// The template name is used to find the template file. For example:
//
// livetemplate.New("counter")
//
// Will look for counter.tmpl, counter.html, or counter.gotmpl in:
// - Current directory
// - ./templates/
// - ./views/
//
// # Options
//
// Use functional options to configure the template:
//
// // Override auto-discovery with specific files
// tmpl := livetemplate.New("app", livetemplate.WithParseFiles("app.tmpl", "partials.tmpl"))
//
// // Disable WebSocket, use HTTP only
// tmpl := livetemplate.New("app", livetemplate.WithWebSocketDisabled())
//
// // Use custom session store
// tmpl := livetemplate.New("app", livetemplate.WithSessionStore(myStore))
//
// // Use custom authentication
// auth := livetemplate.NewBasicAuthenticator(validateUser)
// tmpl := livetemplate.New("app", livetemplate.WithAuthenticator(auth))
//
// // Restrict WebSocket origins (production security)
// tmpl := livetemplate.New("app", livetemplate.WithAllowedOrigins([]string{
// "https://yourdomain.com",
// }))
//
// # Configuration
//
// The template is configured with sensible defaults:
// - Secure WebSocket origin checking (same-origin only, configurable via WithAllowedOrigins)
// - In-memory session store
// - Anonymous authenticator (browser-based session grouping)
// - Auto-discovery enabled
// - Loading indicator enabled
// - Production mode (CDN client library)
//
// # Environment Variables
//
// New does not read environment variables directly. To apply environment-based
// configuration (e.g., LVT_WS_BUFFER_SIZE, LVT_MAX_CONNECTIONS), use
// [LoadEnvConfig] and pass the resulting options:
//
// envConfig, err := livetemplate.LoadEnvConfig()
// if err != nil {
// log.Fatal(err)
// }
// tmpl := livetemplate.New("app", envConfig.ToOptions()...)
//
// See the With* functions for available options.
// Must is a helper that wraps a call to New and panics if the error is non-nil.
// It is intended for use in variable initializations and startup code where
// template initialization failures should be fatal, such as:
//
// var t = livetemplate.Must(livetemplate.New("app"))
//
// This follows the same pattern as html/template.Must and text/template.Must.
func Must(t *Template, err error) *Template {
if err != nil {
panic(err)
}
return t
}
// createSecureOriginChecker creates a CheckOrigin function that enforces origin restrictions.
//
// Security behavior:
// - DevMode=true: Allows all origins (for local development)
// - DevMode=false with AllowedOrigins empty: Same-origin only (secure default)
// - DevMode=false with AllowedOrigins set: Only allows listed origins
//
// This prevents CSRF attacks by rejecting WebSocket upgrade requests from unauthorized origins.
func createSecureOriginChecker(allowedOrigins []string, devMode bool, trustForwardedHeaders bool) func(*http.Request) bool {
return func(r *http.Request) bool {
// Development mode: allow all origins for convenience
if devMode {
return true
}
origin := r.Header.Get("Origin")
// No origin header: allow (same-origin requests may not include Origin)
if origin == "" {
return true
}
// If AllowedOrigins is specified, check against the list
if len(allowedOrigins) > 0 {
for _, allowed := range allowedOrigins {
if origin == allowed {
return true
}
}
// Origin not in allowed list
return false
}
// Default: same-origin only
// Compare origin against the request's Host header
host := r.Host
if host == "" {
return false
}
// Derive scheme from X-Forwarded-Proto (if trusted) or r.TLS (direct).
// See WithTrustForwardedHeaders godoc for security trade-offs.
scheme := ""
if trustForwardedHeaders {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
first, _, _ := strings.Cut(proto, ",")
val := strings.ToLower(strings.TrimSpace(first))
if val == "http" || val == "https" {
scheme = val
}
}
}
if scheme == "" {
scheme = "https"
if r.TLS == nil {
scheme = "http"
}
}
// Check if origin matches scheme://host
expectedOrigin := scheme + "://" + host
return origin == expectedOrigin
}
}
func New(name string, opts ...Option) (*Template, error) {
// Default configuration
config := Config{
Upgrader: NewGorillaUpgrader(), // Default: gorilla with 1KB buffers
SessionStore: NewMemorySessionStore(),
Authenticator: &AnonymousAuthenticator{}, // Default: browser-based session grouping
MessageRateLimit: 10.0, // Default: 10 messages/sec
MessageRateBurst: 20, // Default: burst of 20
CookieMaxAge: 365 * 24 * time.Hour, // Default: 1 year
WebSocketBufferSize: defaultWebSocketBufferSize, // Override via WithWebSocketBufferSize or EnvConfig
ProgressiveEnhancement: true, // Default: enabled for non-JS form support
TrustForwardedHeaders: true, // Default: trust X-Forwarded-Proto (safe behind proxy)
}
// Apply options
for _, opt := range opts {
opt(&config)
}
// Set secure CheckOrigin on the default gorilla upgrader (after options are applied)
if gu, ok := config.Upgrader.(*GorillaUpgrader); ok {
if gu.inner.CheckOrigin == nil {
gu.SetCheckOrigin(createSecureOriginChecker(config.AllowedOrigins, config.DevMode, config.TrustForwardedHeaders))
}
}
// Log DevMode configuration for debugging
slog.Debug("Template created",
slog.String("name", name),
slog.Bool("dev_mode", config.DevMode))
tmpl := &Template{
name: name,
keyGen: compat.NewKeyGenerator(),
config: config,
}
// Parse component templates first (before project templates)
// This establishes a base layer of templates that project templates can override
if len(config.ComponentTemplates) > 0 {
if err := tmpl.parseComponentTemplates(config.ComponentTemplates); err != nil {
return nil, fmt.Errorf("livetemplate.New(%q): failed to parse component templates: %w", name, err)
}
if config.DevMode {
slog.Debug("Parsed component template sets",
slog.Int("count", len(config.ComponentTemplates)))
}
}
// Auto-discover and parse templates if not explicitly provided
if len(config.TemplateFiles) == 0 {
// Use TemplateBaseDir from config if provided, otherwise fall back to runtime.Caller
files, err := discovery.DiscoverTemplateFiles(config.TemplateBaseDir, config.IgnoreTemplateDirs)
if err != nil {
return nil, fmt.Errorf("livetemplate.New(%q): template auto-discovery failed in %q: %w", name, config.TemplateBaseDir, err)
}
if len(files) == 0 {
return nil, fmt.Errorf("livetemplate.New(%q): no template files found in %q (ignored: %v)", name, config.TemplateBaseDir, config.IgnoreTemplateDirs)
}
if config.DevMode {
slog.Debug("Auto-discovered template files",
slog.Int("count", len(files)))
}
if _, err := tmpl.ParseFiles(files...); err != nil {
return nil, fmt.Errorf("livetemplate.New(%q): failed to parse discovered files %v: %w", name, files, err)
}
} else {
if _, err := tmpl.ParseFiles(config.TemplateFiles...); err != nil {
return nil, fmt.Errorf("livetemplate.New(%q): failed to parse template files %v: %w", name, config.TemplateFiles, err)
}
}
return tmpl, nil
}
// Clone creates a deep copy of the template with fresh state.
// This is useful for creating per-connection template instances that don't interfere with each other.
func (t *Template) Clone() (*Template, error) {
// Acquire read lock to safely read template fields
t.mu.RLock()
name := t.name
templateStr := t.templateStr
wrapperID := t.wrapperID
config := t.config
tmpl := t.tmpl
funcs := t.funcs
cachedParse := t.cachedParseTemplate
bodyContent := t.cachedBodyContent
bodyContentValid := t.cachedBodyContentValid
t.mu.RUnlock()
// Share immutable data from master instead of re-creating per clone.
// Go's html/template.Execute() is safe for concurrent use after parsing
// (see go.dev/src/html/template/template.go line 118). The template is
// never modified after Parse(), so sharing is safe.
clone := &Template{
name: name,
templateStr: templateStr,
tmpl: tmpl, // Share parsed template (concurrent Execute is safe)
wrapperID: wrapperID,
funcs: funcs, // Share FuncMap (read-only after Parse)
config: config, // keyGen allocated lazily on first buildTree (nil-safe at line 1582)
cachedParseTemplate: cachedParse, // Share parsed AST + builtins
cachedBodyContent: bodyContent, // Share extracted body content
cachedBodyContentValid: bodyContentValid,
// Don't copy lastData, lastHTML, lastTree, etc. - start fresh per session
}
return clone, nil
}
// SetUploadRegistry sets the upload registry for this template instance.
// This should be called after cloning a template for a specific connection.
func (t *Template) SetUploadRegistry(registry interface{}) {
t.mu.Lock()
defer t.mu.Unlock()
t.uploadRegistry = registry
}
// newUploadRegistry creates a new upload registry instance.
// This is used internally by the mount handler.
func (t *Template) newUploadRegistry() uploadRegistry {
return newUploadRegistry()
}
// =============================================================================
// Phase 1: Parse - Template Parsing
// =============================================================================
// Parse parses text as a template body for the template t.
// This matches the signature of html/template.Template.Parse().
func (t *Template) Parse(text string) (*Template, error) {
// Normalize template spacing to handle formatter-added spaces
// This prevents issues when formatters add spaces like "{{ range" instead of "{{range"
text = compat.NormalizeTemplateSpacing(text)
// Determine if this is a full HTML document
isFullHTML := strings.Contains(text, "<!DOCTYPE") || strings.Contains(text, "<html")
// Always generate wrapper ID for consistent update targeting
t.wrapperID = compat.GenerateRandomID()
// First, parse WITHOUT wrapper to check if flattening is needed
baseTemplate := template.New(t.name)
if len(t.funcs) > 0 {
baseTemplate = baseTemplate.Funcs(t.funcs)
}
tmpl, err := baseTemplate.Parse(text)
if err != nil {
return nil, fmt.Errorf("template '%s' parse error: %w", t.name, err)
}
return t.parseInternal(text, tmpl, isFullHTML)
}
// parseInternal handles the common logic for parsing templates:
// flattening, wrapper injection, final parsing, and validation.
func (t *Template) parseInternal(text string, baseTemplate *template.Template, isFullHTML bool) (*Template, error) {
// Check if template uses composition features and flatten if needed
if parse.HasTemplateComposition(baseTemplate) {
// Flatten the template to resolve all {{define}}/{{template}}/{{block}}
flattenedStr, err := parse.FlattenTemplate(baseTemplate)
if err != nil {
return nil, fmt.Errorf("template flattening failed: %w", err)
}
// Store flattened version for tree generation (WITHOUT wrapper)
// This ensures updates use the flattened template
text = flattenedStr
}
// Expand multi-action bracket syntax before parsing.