On this page

Raw Markdown View this page on GitHub

Datapages Specification

Source Package

The generator requires an application source package containing App and type PageIndex struct.

App, page, stream embed, and event types must not have type parameters. The generator rejects them with "type parameters are not supported". Other package types may have type parameters.

App

App may define global HTML <head> tags:

func (*App) Head(
	r *http.Request,
	session datapages.Session[Data], // Optional
) datapages.Head {
	return globalHeadTags()
}

Parameters are identified by type; names and order are unrestricted.

RecoverError receives errors from Datastar requests, including Datapages sentinels, and may write an SSE response. It does not receive page-load errors. For a failed page load whose response has not started, the server renders PageError404 for status 404 and PageError500 for status 500 when defined, and writes a plain HTTP error otherwise. If RecoverError returns an error or panics, the server logs it and retains the response already written. Errors returned by OnXXX and StreamClose are logged and do not reach RecoverError.

A panic in GET, an action, a request-scoped StreamConfig, or StreamOpen follows the path of an error the handler returns. So does a panic while the server reads the session, path, query or signals of the handler, as in UnmarshalText, UnmarshalJSON or a sessions.Manager. A panic in OnXXX reaches RecoverError too, unlike an error it returns. When RecoverError handles a panic, the error is a datapages.PanicError containing the value and stack. The stack is logged.

A panic during page writing is logged. The response retains its status and truncated body. RecoverError is not called after the response body starts. A panicking stream is closed. Panics in StreamClose are recovered and logged.

Graceful shutdown waits for in-flight requests, open SSE streams, and StreamClose hooks. It starts when the context passed to ListenAndServe is canceled. Applications must cancel that context on SIGTERM and SIGINT. The cmd/server/main.go that datapages init writes calls signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM). ListenAndServe waits at most httpserve.DefaultShutdownTimeout (10s) by default. datapages.WithShutdownTimeout changes this limit. If the limit expires, the server logs the shutdown error and returns. A direct call to Shutdown uses the deadline of its context.

Shutdown runs each open stream's StreamClose hook, then resets the connection. The page reconnects to the next process. See Deployments.

func (*App) RecoverError(
	err error,
	sse datapages.SSE,
) error {
	return sse.PatchElement(errorToast(err))
}

Parameters are identified by type; names and order are unrestricted.

Pages

Pages use type PageXXX struct { App *App } and these methods:

  • GET: handles GET requests.
  • GETXXX: answers GET requests with a file; see GET Actions.
  • POSTXXX: handles POST action requests.
  • PUTXXX: handles PUT action requests.
  • PATCHXXX: handles PATCH action requests.
  • DELETEXXX: handles DELETE action requests.
  • QUERYXXX: handles QUERY action requests.
  • StreamOpen: runs when the page SSE stream opens.
  • StreamClose: runs when the page SSE stream closes.
  • StreamConfig: configures the page SSE stream; see StreamConfig.
  • StreamConfigXXX: configures the SSE response of the action XXX, as StreamConfigPOSTSave does for POSTSave; see StreamConfig.
  • OnXXX: handles EventXXX in the SSE listener.

An action, OnXXX, StreamOpen, or StreamClose may take datapages.State[T] for per-tab state; see Parameter: datapages.State[T].

XXX denotes a name suffix.

A Page* declaration must use a struct type literal. An alias to a struct literal is recognized as a page; an alias to a named type or a non-struct type is rejected.

A page type must declare exactly 1 named field: App *App. Embedded types are permitted under Stream Embeds.

URLs require a comment in net/http ServeMux pattern syntax on the first line of the doc comment, as // PageUser is /user/{name} for a page and // POSTFollow is /user/{name}/follow for an action. A description goes below it, after an empty // line. A route must not contain ", \, `, ? or #, nor a % that does not start a percent-encoding. Write them as %22, %5C, %60, %3F, %23 and %25, which ServeMux decodes before matching.

PageIndex is required for /, and no other page may use / or /{$}. PageIndex also answers every path that no other route matches.

The static-file URL prefix reserves every path below it. No page or action route may start with that prefix. For example, /static/thumbs/{name} is invalid when // StaticFS is /static/.

PageError500, PageError404 and PageOffline are optional reserved page names. PageError500 and PageError404 may override the default pages for status codes 500 and 404. PageOffline is the fallback that the service worker serves for an uncached URL while offline. Datapages supplies defaults when these pages are absent.

Each declares its route by comment like any other page. PageOffline always renders with a zero Session. Its output must not depend on the signed-in user.

The GET of PageError500 and PageError404 serves its page route and error responses. It must not return newSession or closeSession. It may accept a session parameter to render the document. For a URL that no route matches and for a page load that fails with datapages.ErrNotFound, PageError404.GET receives that URL's query and signals and a zero path. When PageError500.GET fails, or PageError404.GET fails with datapages.ErrNotFound, the server writes a plain HTTP error instead of rendering the failing page again.

A page with an SSE stream serves _$/ under its route. A page with user-addressed events also serves _$/anon/ for signed-out visitors when it has public events or per-tab state. That stream carries the public events and holds the state. Page and action routes cannot conflict with these endpoints. A page whose route ends in {$} serves only GET and cannot have actions or a stream. The {$} marker does not appear in the request URL. A page whose route ends in a {name...} wildcard cannot have actions or a stream either. No action route may end in a {name...} wildcard.

Handler parameters and return values may appear in any order. Unsupported names or types are generator errors.

GET requires r *http.Request and a datapages.Component return value. Other parameters and return values are optional:

func (PageIndex) GET(
	r *http.Request,
	session datapages.Session[Data], // Optional
	path datapages.Path[struct{...}], // Required only when path variables are used in the URL
	query datapages.Query[struct{...}], // Optional
	signals datapages.Signals[struct{...}], // Optional
	somethingHappened datapages.Dispatcher[EventSomethingHappened], // Optional
	somethingElseHappened datapages.Dispatcher[EventSomethingElseHappened], // Optional
) (
	body datapages.Component,
	head datapages.Head, // Optional
	docAttrs datapages.DocumentAttrs, // Optional
	redirect datapages.Redirect, // Optional
	newSession datapages.NewSession[Data], // Optional
	closeSession datapages.CloseSession, // Optional
	enableBackgroundStreaming datapages.EnableBackgroundStreaming, // Optional
	disableRefreshAfterHidden datapages.DisableRefreshAfterHidden, // Optional
	err error, // Optional
) {
	// ...
}

Global actions may be defined on *App:

// POSTSignOut is /sign-out/{$}
func (*App) POSTSignOut(r *http.Request, session Session) (
	closeSession datapages.CloseSession,
	redirect datapages.Redirect,
	err error,
) {
	return true, datapages.Redirect{URL: "/login"}, nil
}

App may configure the SSE response of its actions with StreamConfigXXX; see StreamConfig.

Page action handlers (POSTXXX, PUTXXX, PATCHXXX, DELETEXXX, QUERYXXX) require r *http.Request and permit these optional parameters. A handler returning only error may use this signature:

// POSTActionName is <path>
func (PageIndex) POSTActionName(
	r *http.Request,
	sse datapages.SSE, // Optional
	session datapages.Session[Data], // Optional
	path datapages.Path[struct{...}], // Required only when path variables are used in the URL
	query datapages.Query[struct{...}], // Optional
	signals datapages.Signals[struct{...}], // Optional
	state datapages.State[T], // Optional
	stateID string, // Optional with state
	somethingHappened datapages.Dispatcher[EventSomethingHappened], // Optional
	somethingElseHappened datapages.Dispatcher[EventSomethingElseHappened], // Optional
) error {
	// ...
}

An action that takes datapages.Signals, datapages.SSE or datapages.PageCacheWriter requires Datastar-Request: true. Requests without it receive 406 Not Acceptable. Any other action can receive HTML form submissions. It is guarded by net/http.CrossOriginProtection: requests reported as same-site or cross-site by Sec-Fetch-Site, or with an Origin that differs from Host, receive 403. Requests with neither header are allowed. Authenticated forms also fail the CSRF check when sessions and CSRF protection are enabled.

A QUERYXXX action answers the safe, idempotent QUERY method of RFC 10008. Datastar sends it with @query, which puts the signals in the body. It takes the parameters and return values of the other actions. It skips the CSRF check and must not change server state. The Datastar-Request requirement and the cross-origin check above apply to it.

Actions with sse cannot return body, head, newSession or closeSession. Send a body with sse.PatchElement instead. A redirect return value navigates through the stream, as sse.Redirect does.

An action without sse may redirect, return HTML, and change sessions:

// POSTActionName is <path>
func (PageIndex) POSTActionName(
	r *http.Request,
	session datapages.Session[Data], // Optional
	path datapages.Path[struct{...}], // Required only when path variables are used in the URL
	query datapages.Query[struct{...}], // Optional
	signals datapages.Signals[struct{...}], // Optional
	state datapages.State[T], // Optional
	stateID string, // Optional with state
	somethingHappened datapages.Dispatcher[EventSomethingHappened], // Optional
	somethingElseHappened datapages.Dispatcher[EventSomethingElseHappened], // Optional
) (
	body datapages.Component, // Optional
	head datapages.Head, // Optional, requires body
	docAttrs datapages.DocumentAttrs, // Optional, requires body
	redirect datapages.Redirect, // Optional
	newSession datapages.NewSession[Data], // Optional
	closeSession datapages.CloseSession, // Optional
	err error,
) {
	// ...
}

OnXXX handles the event type EventXXX. XXX must equal the type's name after Event, also for an event declared in another package, as OnAnnouncement for events.EventAnnouncement. The handler takes exactly 1 parameter of that type, by value and not as *EventXXX, and exactly 1 sse datapages.SSE. It takes each optional parameter at most once and must return exactly 1 error. Parameter order and parameter names are unrestricted.

func (PageIndex) OnSomethingHappened(
	event EventSomethingHappened,
	sse datapages.SSE,
	streamID datapages.StreamID, // Optional
	state datapages.State[T], // Optional
	stateID string, // Optional with state
	session datapages.Session[Data], // Optional
) error {
	// ...
}

StreamOpen runs after the SSE stream is established and before event handlers. It may return error or nothing. On error, setup stops and RecoverError handles the error when defined; otherwise the server uses its internal-error path. The stream closes with a 5s reconnect delay. A broker subscription failure follows the same path. Without RecoverError, a broker subscription failure returns its error status and Datastar applies exponential reconnect delays up to 30s. StreamClose does not run if StreamOpen returns an error or panics. StreamOpen must release acquired resources before returning an error and defer their release if it can panic.

datapages.StreamID identifies an SSE stream within a process. Its parameter name is unrestricted. It may correlate StreamOpen with StreamClose and must not be exposed to clients.

A stream hook must take datapages.StreamID, datapages.State[T], or both.

func (PageIndex) StreamOpen(
	r *http.Request,
	streamID datapages.StreamID, // Optional when state is declared
	state datapages.State[T], // Optional when streamID is declared
	stateID string, // Optional with state
	sse datapages.SSE, // Optional
	session datapages.Session[Data], // Optional
	signals datapages.Signals[struct{...}], // Optional
	somethingHappened datapages.Dispatcher[EventSomethingHappened], // Optional
	somethingElseHappened datapages.Dispatcher[EventSomethingElseHappened], // Optional
) error {
	// ...
}

StreamClose runs on the request goroutine after the stream's last event handler, provided StreamOpen succeeded or is absent. It may return error or nothing. Returned errors are logged.

func (PageIndex) StreamClose(
	r *http.Request,
	streamID datapages.StreamID, // Optional when state is declared
	state datapages.State[T], // Optional when streamID is declared
	stateID string, // Optional with state
	session datapages.Session[Data], // Optional
	somethingHappened datapages.Dispatcher[EventSomethingHappened], // Optional
	somethingElseHappened datapages.Dispatcher[EventSomethingElseHappened], // Optional
) error {
	// ...
}

StreamConfig

A page may declare StreamConfig only if it has an SSE stream. A stream embed must not declare it. The method must use 1 of these forms:

  • The static form takes no parameters and returns datapages.StreamConfig. NewServer calls it once. An invalid compression configuration makes NewServer return an error naming the method.
  • The request-scoped form takes r *http.Request, may also take session datapages.Session[Data], path datapages.Path[struct{...}] and signals datapages.Signals[struct{...}], and returns (datapages.StreamConfig, error). The server calls it before each stream opens and before StreamOpen. path contains the page route variables. An anonymous stream receives a guest session. An error or invalid compression configuration follows the broker subscription error path.
func (PageDashboard) StreamConfig() datapages.StreamConfig {
	return datapages.StreamConfig{
		Compression: &datapages.SSECompressionConfig{ZstdWindow: 4 << 20},
	}
}

func (PageFeed) StreamConfig(r *http.Request) (datapages.StreamConfig, error) {
	if r.Header.Get("Save-Data") != "on" {
		return datapages.StreamConfig{}, nil
	}
	return datapages.StreamConfig{
		Compression: &datapages.SSECompressionConfig{ZstdWindow: 4 << 20},
	}, nil
}

Compression replaces the datapages.WithSSECompression configuration for the page's _$/ and _$/anon/ stream endpoints, or for an action's response. Its zero fields select the package defaults, not the server's values. A method that sets only ZstdWindow uses zstd even when the server sets Disabled: true or allows only gzip. Nil uses the server configuration. The page's actions use the server configuration or their own StreamConfigXXX. The server shares encoders for at most 64 distinct effective configurations. Further configurations use dedicated encoders.

An action configures its SSE response with a method on the same page or App named StreamConfig followed by the action's method name. It takes the 2 forms above. The request-scoped form may take path and signals only of the types the action takes. The action must answer with an SSE stream, which means it takes datapages.SSE, or it takes datapages.PageCacheWriter and returns neither a body nor a redirect. The server calls the request-scoped form before the action. Its error, its panic or an invalid compression configuration takes the action's error path, and the action does not run.

// POSTSave is /save
func (PageEditor) POSTSave(r *http.Request, sse datapages.SSE) error {
	return sse.PatchElement(savedBadge())
}

func (PageEditor) StreamConfigPOSTSave() datapages.StreamConfig {
	return datapages.StreamConfig{
		Compression: &datapages.SSECompressionConfig{Disabled: true},
	}
}

GET Actions

A GETXXX action serves a datapages.File for GET and HEAD, such as an image, download or feed. GET actions skip cross-origin and CSRF checks and must not change server state.

A GET action may belong to *App or a page. It does not replace a page's GET and accepts only these parameters:

// GETImage is /images/{name}
func (a *App) GETImage(
	r *http.Request,
	session datapages.Session[Data], // Optional
	path datapages.Path[struct{...}], // Required only when path variables are used in the URL
	query datapages.Query[struct{...}], // Optional
) (
	file datapages.File,
	err error, // Optional
) {
	// ...
}

The generated href builder is a method of the owner. (*App).GETImage maps to href.App.Image(name), and PageDoc.GETExport maps to href.PageDoc.Export(id). href.PageDoc(id) remains the page builder.

A GET action URL preserves the route ending instead of adding a page URL's trailing slash. The server also accepts the URL with a trailing slash.

A path value holds its segment unescaped: %2F arrives as / and %2E%2E as ... Open a file through an fs.FS or an os.Root, or look the value up as a key. Never join it into a file path.

Query parameters use the adjacent constructor: href.App.Image(name, href.App.ImageQuery(width)). A GET action name must not match another GET action's query constructor on the same owner. The action package provides no GET action helper.

Stream Embeds

A struct in the app package whose name does not start with Page and that declares App *App is a stream embed. Pages may embed it to share its OnXXX event handlers, StreamOpen and StreamClose. It cannot declare GET or actions. A page or a stream embed may embed only stream embeds, never a type of another package.

A handler the page declares for an event, StreamOpen or StreamClose replaces the embedded one. A page is rejected when 2 embedded types handle the same event or declare the same stream hook, or when it reaches a stream embed through 2 embedded fields at the same depth.

Parameter: datapages.State[T]

state datapages.State[T]

State[T] holds per-tab server-side state. Each open tab has an independent *T in Values. Writes persist across handlers of that tab. A per-instance mutex serializes those handlers. Access within a handler requires no additional synchronization.

An instance belongs to a tab, is not session-bound, and survives sign-out.

State.Values must not outlive its handler. Goroutines the handler starts, application state and components rendered later must not retain it. The Body of a returned datapages.File must not read it. Copy needed fields instead.

A page enables state by declaring an exported struct and using it as T in an action, OnXXX, StreamOpen, or StreamClose:

type StateIndex struct {
	Filter string
	Count  int
}

func (PageIndex) StreamOpen(r *http.Request, state datapages.State[StateIndex]) error {
	return nil
}

Declaration rules:

  • T must be an exported struct declared at the source package level.
  • The parameter name is unrestricted. T must directly name the app package's struct. Pointers, struct literals, and types from other packages are rejected.
  • All handlers on a page, including those of embedded types, must use the same T.
  • State used by a stream embed binds every page that embeds it.
  • Global *App actions may take datapages.State[T]. The caller must be bound to a page using the same T; otherwise the action receives 409 Conflict with Datapages-Retry: reconnect. A global action callable from every page must be stateless.
  • GET cannot take state.
  • A page using state gets an SSE stream even without stream hooks or OnXXX.

Parameter: stateID string. A stateful handler may take stateID string alongside datapages.State[T] to dispatch events targeted at the tab via datapages.SubjectStateID.

stateID is the first 16 bytes of the SHA-256 hash of Datapages-Instance, encoded as unpadded base64url. It remains stable for the tab's lifetime. Knowing it permits event addressing but not access to the tab's state.

Subject field: datapages.SubjectStateID. An event may declare a field of this type with any name. On SSE connect, the server subscribes to <base>.<state_id>; only the matching tab receives the event.

  • A datapages.SubjectStateID field must not carry a signal:"..." tag.
  • It must be the event's only subject field.
  • A page handling the event must use state.
  • A page handling the event cannot also handle a user-addressed or signal-scoped event. It may handle public events.

Lifecycle:

  1. GET generates an identifier, sets Datapages-Instance in the response, and embeds it in the HTML. No state is allocated yet.
  2. The client includes the header in later Datastar actions and SSE connects.
  3. On connect, the server checks for 22 base64url characters, allocates a zeroed *T, and registers the id -> slot mapping before StreamOpen or event handlers run.
  4. Before a stateful action or OnXXX, the server validates the header, looks up and locks the slot, and passes its state to the handler. A missing slot yields 409 Conflict with Datapages-Retry: reconnect.
  5. On disconnect, the server removes the instance and releases its reference to the state. Reconnect with the same ID allocates a new *T; instances are never reused across streams.

By default, hiding a tab closes its stream and releases its state. Visibility reload creates a new instance with zeroed state. State needed after reload must be reconstructible from the URL or signals read by StreamOpen. Returning enableBackgroundStreaming=true keeps the stream and state alive and disables visibility reload. Returning only disableRefreshAfterHidden=true suppresses the reload without preserving state.

Configuration. The default instance limit is datapages.DefaultMaxConcurrentInstances. Set it with datapages.WithStateConfig(datapages.StateConfig{MaxConcurrentInstances: n}).

Identifier. Datapages-Instance is 16 random bytes from crypto/rand, encoded as 22 unpadded base64url characters. It is not signed; possession authorizes access to the tab's state.

The server accepts any well-formed ID, including one issued elsewhere. The stream creates state on the server it reaches.

A process restart drops all instances. An action arriving before reconnect receives 409 and reloads once.

MaxConcurrentInstances caps live instances across state types, separately for each server. A client can hold at most 1 instance per open stream. 0 selects DefaultMaxConcurrentInstances; a negative value removes the cap. Size the cap by state memory use and limit per-client connections separately.

Connects exceeding the cap receive 503 Service Unavailable with Retry-After. Datastar ignores Retry-After and retries after 1s, doubling to a 30s ceiling, without a limit; see Deployments. Until the stream connects, a stateful action receives 409 and reloads. Existing instances continue to work. The application receives no cap notification. The generated Server reports the live count with StateLiveInstances(). On a datapages.Server, call it as s.(*datapagesgen.Server).StateLiveInstances(). Servers with Prometheus export it as datapages_state_instances. The configured cap is not exported. The gauge counts each server in the process that registered metrics.

Multi-server routing. State is process-local. A tab's stream and actions must reach the same backend. Route them by a hash of Datapages-Instance, by session or by an affinity cookie. GET may reach any server.

Rate limiting. Datapages enforces only the global instance cap. Middleware added with WithMiddleware can enforce a per-session limit by matching the stream path.

Security. An inline script captures the ID from the HTML response and removes itself. The ID is not stored in cookies, browser storage, or a persistent DOM node. Another tab cannot observe it. Access to another tab's state requires its 128-bit random ID.

The script needs script-src 'unsafe-inline' or a nonce; see Content Security Policy.

Parameter: datapages.Signals[struct {...}]

signals datapages.Signals[struct {
	Foo string `json:"foo"`
	Bar int    `json:"bar"`
}]

Signals captures Datastar signals in Values. The parameter name is unrestricted. Its type argument may be a named or anonymous struct. A defined struct type must be exported, including one behind an alias. Every field requires a json tag, down through the struct types of the app's module. Any JSON-serializable field type is supported, including nested structs, slices, and maps. A struct type from another module, such as sql.NullString, is 1 signal that encoding/json encodes.

Nested structs map to nested Datastar signals using dot notation:

signals datapages.Signals[struct {
	Form struct {
		Name  string `json:"name"`
		Email string `json:"email"`
	} `json:"form"`
}]

This maps to $form.name and $form.email, accessible as signals.Values.Form.Name and signals.Values.Form.Email.

Action bodies carrying signals are limited to 1 MiB by default. Oversized bodies receive 400. datapages.WithBodySizeLimit changes the server-wide limit. An action without datapages.Signals gets no limit. A handler that reads r.Body directly is responsible for limiting it, for example with http.MaxBytesReader.

Parameter: datapages.Path[struct {...}]

path datapages.Path[struct {
	ID string `path:"id"`
}]

Path provides URL path parameters in Values. Each must appear in the URL comment. The parameter name is unrestricted; its type argument may be a named or anonymous struct. A defined struct type must be exported, including one behind an alias.

Each field must be exported and tagged with its route variable: path:"id" binds to {id}.

Supported field types are:

  • string
  • bool
  • int
  • int8
  • int16
  • int32
  • int64
  • uint
  • uint8
  • uint16
  • uint32
  • uint64
  • float32
  • float64

Types implementing encoding.TextUnmarshaler are also supported, pointer types such as *big.Int included. The server allocates a pointer field before it calls UnmarshalText. A pointer query field is nil when its parameter is absent. Parse failures return 400. Float fields reject Inf, +Inf, -Inf, and NaN.

Generated href and action builders encode encoding.TextMarshaler values with that interface. Other values, including named strings, use their basic kind.

Parameter: datapages.Query[struct {...}]

query datapages.Query[struct {
	Filter string `query:"f"`
	Limit  int    `query:"l"`
}]

Query provides URL query parameters in Values. The parameter name is unrestricted; its type argument may be a named or anonymous struct. A defined struct type must be exported, including one behind an alias.

Each field must be exported and tagged with its query key: query:"f" reads ?f=....

The same field types as datapages.Path are supported.

The reflectsignal tag binds a signal to a query parameter:

signals datapages.Signals[struct {
	SelectedItem string `json:"selecteditem"`
}],
query datapages.Query[struct {
	SelectedItem string `query:"s" reflectsignal:"selecteditem"`
}]

The browser updates only the reflected query keys. It removes a key when its signal becomes empty. Other query parameters remain, including fields declared without reflectsignal. The URL fragment remains.

Reflected integer, float, and bool fields seed JavaScript numbers or booleans unless they implement encoding.TextMarshaler. Text marshalers and string fields seed strings.

A reflected field must be a basic type or implement encoding.TextMarshaler. datapages gen rejects any other type.

datapages gen rejects 2 query fields with the same reflectsignal value.

The query field's seed must have a JSON kind that the signal field can decode. datapages gen rejects incompatible pairs. For example, it rejects a string query field reflected into a bool signal. Fields that implement json.Unmarshaler and interface fields accept every JSON kind. Other fields that implement encoding.TextUnmarshaler accept only strings. Pointer fields accept the JSON kinds of the type they point to.

Signal json tags must match [A-Za-z_][A-Za-z0-9_]* and must not contain __. json:"-" is rejected.

A leading single underscore is allowed, but Datastar omits that signal from requests unless filterSignals includes it.

Datapages writes uppercase letters with a preceding hyphen in attribute names: json:"newTitle" becomes data-signals:new-title and $newTitle. Hyphens in signal json tags are rejected. Each step of a reflectsignal path must not start with an uppercase letter.

Periods are rejected in signal names. Nested structs define signal paths:

signals datapages.Signals[struct {
	Foo struct {
		Bar struct {
			Bazz string `json:"bazz"`
		} `json:"bar"`
		Fuzz string `json:"fuzz"`
	} `json:"foo"`
}]

This declares foo.bar.bazz and foo.fuzz.

A reflectsignal tag references the full path, such as reflectsignal:"foo.fuzz".

A query tag may contain only ASCII letters, digits, -, ., _ and ~. Query values are escaped.

Parameter: session datapages.Session[Data]

session datapages.Session[Data]

Session provides cookie-based authentication. Its parameter name is unrestricted.

Session is read-only and exposes UserID(), IsGuest(), Token(), IssuedAt(), ExpiresAt(), and Data(). Only a newSession return value changes it. Use struct{} for Data when no application payload is needed.

See datapages.go for method definitions.

Expired sessions are unauthenticated and their cookies are removed. A stream opened with a session ends when the session closes or reaches its ExpiresAt(). Its page then reloads after 1s and renders for the session the browser holds, if any. A page with only user-addressed events also reloads when its stream reconnects after the session ended. A page that also has public events reconnects to _$/anon/ and keeps its content. A zero ExpiresAt() never expires; its cookie lasts until the browser closes.

A POST, PUT, PATCH or DELETE action without a session parameter checks CSRF against the cookie without reading the session store; a closed or expired session cookie passes this check. An action with a session parameter reads the store and rejects such sessions. When a handler renders a document without reading the session, it uses the session cookie to write the CSRF script without reading the store. A document rendered for a session cookie is sent with Cache-Control: private unless the response already has a Cache-Control header. A datapages.File from a handler with a session parameter is sent with Vary: Cookie.

Expired sessions are removed on read. Datapages does not call the session manager's DeleteExpired; applications must schedule cleanup for abandoned sessions.

All handlers must use the same Data type. A type alias is permitted:

type SessionData struct {
	Name string
}

type Session = datapages.Session[SessionData]

Parameter: sse datapages.SSE

sse datapages.SSE

SSE is allowed on page action methods, OnXXX, StreamOpen, and App.RecoverError. Global *App actions and StreamClose cannot take it.

datapages.SSE provides Context, PatchElement, PatchElementAt, RemoveElement, ExecuteScript, PatchSignals, PatchSignalsIfMissing, Redirect and Prefetch, alongside the PatchMode constants.

PatchElement(c) morphs each rendered element into the element with its ID. PatchElementAt(c, selector, mode) specifies a target and patch mode:

return sse.PatchElementAt(toast(msg), "#toaster", datapages.PatchModeAppend)

PatchElementAt and RemoveElement reject selectors containing \r or \n with datapages.ErrSelectorLineBreak.

By default, an SSE response uses the first coding explicitly accepted by the request in the order zstd, brotli, gzip. A wildcard selects gzip only. If no configured coding is accepted, the response is uncompressed. The default zstd and brotli windows are 1 MiB.

datapages.WithSSECompression sets the permitted codings, preference order, levels and windows. datapages.WithNoSSECompression disables compression. These presets return common configurations:

  • datapages.SSECompressionBalanced returns the defaults.
  • datapages.SSECompressionLowMemory uses 256 KiB windows for many streams with small fragments.
  • datapages.SSECompressionLowBandwidth prefers brotli for slow or metered clients.
  • datapages.SSECompressionLargeFragments uses 4 MiB windows for fragments above about 1 MB.

A page's StreamConfig overrides the server configuration for that page's streams. An action's StreamConfigXXX overrides it for that action. SSE responses include Vary: Accept-Encoding unless compression is disabled. A stream with Cache-Control: no-transform before it opens is uncompressed and keeps the directive. The coding depends only on Accept-Encoding, not on the client address.

See datapages.go for method definitions.

Parameter: pageCache datapages.PageCacheWriter

pageCache datapages.PageCacheWriter

This parameter is allowed on GET page methods and on POSTXXX, PUTXXX, PATCHXXX, DELETEXXX, and QUERYXXX action methods, including app-level actions. It queues writes to the client's service worker cache. A cached body is a component supplied by the handler and need not match the live body. The service worker is registered only with the generated WithOffline option, which declaring PageOffline adds, or with offline.WithServiceWorker. Without either, the writes reach no worker.

The interface (from github.com/romshark/datapages):

// PageCacheWriter writes to the client's service worker cache. It is passed
// to GET page methods and action methods as the pageCache parameter. Writes
// are deferred and applied atomically once the handler returns without error.
// A GET that redirects a page load drops them.
type PageCacheWriter interface {
	// Version returns the cached version of the current request's URL.
	// It returns 0 when the URL is not cached.
	Version() uint64

	// Set caches body for url with version. Version reports that value on the
	// next request for url. url must come from the generated href package.
	// The worker serves this entry only while offline.
	Set(url string, body Component, version uint64)

	// SetShim caches body for url like [Set], but permits the worker to serve it
	// while online. The worker then fetches the live page, replaces the shim's
	// head and body through Datastar and applies the attributes of its html
	// element. Datapages adds the fetch trigger.
	// body must not state anything that is only true offline.
	SetShim(url string, body Component, version uint64)

	// Clear removes url from the cache.
	Clear(url string)

	// ClearAll removes every page cache entry.
	ClearAll()
}

Writes are deferred until the handler returns without error. The worker then applies all Set, Clear and ClearAll calls from that request together, with ClearAll first. An error discards the queued writes.

Each cached URL stores a version chosen by the application. Version() reports the version the client holds for the current request's URL (0 if none); compare it against the resource's server-side version to decide whether to re-Set it.

The handler signature selects delivery in this order. The same rules apply to a page method and an action declared on App:

  • GET: embeds the writes in the page HTML for the worker to apply on load. A page load that the GET redirects drops them. Queue such writes, as the ClearAll of a sign-out, in an action.
  • Action with sse: sends the writes over that stream.
  • Action with a redirect: sends the writes in the text/javascript response. Navigation waits up to 500ms for the worker to apply them. This rule also applies when the action can return a body.
  • Action with only a body: embeds the writes in the rendered document.
  • Action with neither: sends the writes over an SSE stream opened by Datapages.

If a cached body returns an error while rendering, the server logs the error and omits the queued cache writes. A panic follows the handler panic path; see Return Value error.

An action taking datapages.PageCacheWriter answers a request without Datastar-Request: true with 406 Not Acceptable.

A page can cache itself on visit, versioned by its own data:

// PageItem is /item/{id}
func (p PageItem) GET(
	r *http.Request,
	pageCache datapages.PageCacheWriter,
	path datapages.Path[struct {
		ID string `path:"id"`
	}],
) (body datapages.Component, err error) {
	item := p.App.item(path.Values.ID)
	if pageCache.Version() < item.Revision {
		pageCache.Set(href.PageItem(path.Values.ID), itemOffline(item), item.Revision)
	}
	return itemView(item), nil
}

An action handler can Set URLs other than the one being requested. Version() reports only the version of the current request URL. A Set for another URL is unconditional.

// POSTPrecache is /precache
//
// Caches every ticket page the signed-in user owns.
func (a *App) POSTPrecache(
	r *http.Request,
	session datapages.Session[Data],
	pageCache datapages.PageCacheWriter,
) error {
	for _, t := range a.userTickets(r.Context(), session.UserID()) {
		pageCache.Set(href.PageTicket(t.Slug), ticketOffline(t), t.Revision)
	}
	return nil
}

See Service Worker for how these entries are stored and served.

Parameter: datapages.Dispatcher[EventXXX]

xxx datapages.Dispatcher[EventXXX]

Dispatcher[EventXXX] publishes events handled by OnXXX. The parameter name is unrestricted. EventXXX must be an event type declared in the application package or a reachable imported package. Shared events use the same type; see Events declared outside the application package.

type Dispatcher[Event any] interface {
	Dispatch(event Event) error
	DispatchCtx(ctx context.Context, event Event) error
}

Dispatch uses the request context in actions and the request context without cancellation in stream hooks. DispatchCtx uses its explicit context and permits publication after a handler returns or with a separate deadline.

Event fields require json tags, down through the struct types of the app's module. A type from another module, such as url.URL, is encoded as encoding/json encodes it. The event type comment must have the form // EventXXX is "xxx", where xxx is the NATS subject prefix:

// EventExample is "example"
type EventExample struct {
	Information string `json:"info"`
}
Events declared outside the application package

An event type may be declared in a directly or transitively imported package:

// package events, imported by both app packages
// EventAnnouncement is "announcement"
type EventAnnouncement struct {
	Text string `json:"text"`
}

// app/admin
func (PageIndex) OnAnnouncement(
	event events.EventAnnouncement, sse datapages.SSE,
) error

func (PageIndex) POSTAnnounce(
	r *http.Request, announcement datapages.Dispatcher[events.EventAnnouncement],
) error

Subject, payload, and subject fields are read from the declaring package.

Applications using the same event type share its subject. Separate event types with the same subject are rejected.

An application cannot use 2 event types with the same name, regardless of declaring package.

A type alias such as type EventX = other.EventX refers to the original event's subject, payload, and declaring package. It may occur anywhere along the import path.

Applications sharing a user-addressed event must use the same user ID namespace.

Events may declare subject fields for targeted NATS subjects:

type segment
datapages.Subject a segment value
datapages.SubjectUser the ID of the user the event is addressed to

Subject fields must be exported and precede payload fields. Names are unrestricted; types determine their role.

Subject fields must not use json:"-". Their values must remain in the payload.

Each dispatch publishes to 1 subject. Subject field values follow the base subject in field order, separated by dots. For base notify and values u1, r1, and mobile, the subject is notify.u1.r1.mobile.

A subject field value may contain any byte. ., *, >, %, and whitespace are escaped into 1 literal segment. Empty values cause dispatch to fail without publishing.

Publish and subscription use the same escaping. Values that need none remain unchanged; a.b becomes a%2Eb in the broker subject.

Addressing multiple values requires 1 dispatch per value.

datapages.SubjectUser requires authentication and routes to the named user. An application dispatching such an event must define a Session type.

User IDs are escaped like other subject values and may be email addresses. Their encoded length is bounded; see datapages.ValidateUserID.

Signal-scoped subject fields

A non-user subject field may use signal:"<name>" to bind its subscription value to a Datastar signal. On SSE connect, the server reads the signal and builds the subscription subject.

The tag must name a signal declared by the page, including periods in a nested path (for example, signal:"form.calc_id").

// EventCalcUpdated is "calc.updated"
type EventCalcUpdated struct {
	Instance datapages.Subject `json:"instance" signal:"instance_id"`

	Result float64 `json:"result"`
}

Here, SSE connect subscribes to calc.updated.<instance_id> after escaping the signal. An empty value receives 400. A * is escaped as 1 literal segment.

Signal-scoped events may coexist with user-addressed and public events on a page. Signal-scoped fields may coexist with other subject fields:

// EventRoomUpdate is "room.update"
type EventRoomUpdate struct {
	Recipient datapages.SubjectUser `json:"recipient"`
	Room      datapages.Subject     `json:"chat_room"`
	Calc      datapages.Subject     `signal:"calc_id"`

	Data string `json:"data"`
}

Restrictions:

  • A user-addressed subject field must not have a signal:"..." tag.
  • An event must not have more than 1 datapages.SubjectUser field.
  • No 2 subject fields may share the same signal:"..." tag value.
  • signal tag values must be period-separated signal names. Each step follows the Signals json tag rule.
  • A page must not subscribe by a signal and by a signal under it, such as chat and chat.room.
  • 2 event types cannot claim the same subject anywhere in a module. datapages gen and datapages lint report conflicts across applications. To share a subject, use 1 event declaration; see Events declared outside the application package.
  • An event with subject fields reserves every subject below its base. "notify" with 1 subject field conflicts with "notify.user".

Subject fields after payload fields are invalid. A field named like a subject field must have a subject-field type. Types declared from subject-field types, such as type UserID datapages.SubjectUser, are rejected. So is a field whose type holds a subject-field type in a slice, array, map, pointer or channel, such as []datapages.SubjectUser.

Each dispatcher publishes 1 event type. A handler may declare 1 dispatcher per event type, but cannot declare 2 for the same type. Events publish in dispatch order. Failure does not undo earlier publishes or prevent later ones.

Event delivery

Delivery is at most once, without replay, to streams subscribed at publish time.

A stream misses events when:

  • its tab is hidden and its stream is closed by default; see enableBackgroundStreaming;
  • its subscription buffer is full. messaging.DefaultBrokerChanBuffer is 16 messages; built-in brokers may configure another size. The broker drops overflow instead of blocking the publisher; OnXXX consumes events serially.

Misses are not reported to the page. The next render must include full state; by default it occurs when the tab becomes visible again. See disableRefreshAfterHidden.

Prometheus-enabled applications export drops as datapages_event_broker_deliveries_dropped_total.

Return Value: body datapages.Component

The Templ component for page contents.

Return Value: head datapages.Head

The Templ component for the page's <head>. datapages.Head is a distinct name for datapages.Component. Return values are identified by type; names are unrestricted.

An action returning head must also return body.

Return Value: docAttrs datapages.DocumentAttrs

docAttrs datapages.DocumentAttrs

datapages.DocumentAttrs is a map[string]string of the attributes of the <html> element by name. Each entry writes 1 attribute, sorted by name. An entry with an empty value writes an empty attribute. Values are HTML-escaped. With WithCSPNonce, data-nonce follows them.

A name must match [a-z][a-z0-9_.:-]* and must not be data-nonce. A name that breaks either rule fails the response with 500 Internal Server Error and a plain-text body, and the server logs the error. PageError500 is not rendered.

The handler's docAttrs is the only source of these attributes. A document whose handler returns no docAttrs and a page-cache entry are written with <html> without them.

An action returning docAttrs must also return body.

Return Value: redirect datapages.Redirect

redirect datapages.Redirect

Redirects to redirect.URL with redirect.Status. The zero value is a no-op.

return datapages.Redirect{URL: href.PageIndex()}, nil

Status defaults to 302 Found; non-redirect codes are replaced by 302. Datastar action requests (Datastar-Request: true) navigate by assigning window.location and ignore Status.

See datapages.go for field definitions.

Return Value: datapages.File

file datapages.File

datapages.File is the sole non-error return value of a file response. A GETXXX action must return it. Other actions may return it. A page GET must not return it. A handler returning it must not take sse or pageCache.

Application HTML must be returned as a Templ datapages.Component.

Type sets Content-Type. Body provides the content. Both are required; omitting either produces 500.

Cache sets Cache-Control from MaxAge, Immutable, Private, NoCache and NoStore, or writes Raw unchanged. The following combinations produce 500:

  • MaxAge with NoCache or NoStore.
  • Immutable without MaxAge.
  • Raw with any other field.

A structured value without MaxAge, NoCache or NoStore gains no-cache. Private alone therefore sends private, no-cache. The zero Cache sends no-cache unless middleware set Cache-Control. Private sends private and is required with MaxAge when the content depends on the visitor.

Disposition sets Content-Disposition. Download selects attachment; otherwise Filename selects inline. Filename sets the filename parameter. Header adds headers not set by another field. It replaces values set by middleware except for Set-Cookie and Vary, whose values it appends. Differently cased spellings of the same name are joined in sorted order. An entry for a header written by another field or by ServeContent produces 500.

Datapages closes Body after serving. Body must support successful seeks. Copy a stream, such as an http.Response body, into memory or a temporary file first. A Body that fails to seek produces 500, and a directory produces 404. A read error after the response starts terminates the response and is not reported to the handler. A client that stops reading ends the response once a write of up to 64 KiB blocks for httpserve.DefaultFileSendTimeout (1 minute), and the connection closes. datapages.WithFileSendTimeout changes the time. The server has no write timeout.

Content held in memory needs a Close that does nothing:

type memBody struct{ *bytes.Reader }

func (memBody) Close() error { return nil }

net/http.ServeContent writes the response. It handles HEAD, Range and, when ModTime is set, If-Modified-Since. ETag sets a strong entity tag, which Datapages quotes, and enables If-None-Match, If-Match and If-Range. A character outside etagc of that section makes the ETag invalid and produces 500.

A handler with a session parameter sends Vary: Cookie. Serve a file that is the same for every visitor from a handler without a session parameter. Every response includes X-Content-Type-Options: nosniff.

Serve a visitor-uploaded file with Disposition.Download. To serve it inline, restrict Type to an allowlist and set Content-Security-Policy: sandbox in Header.

Response compression leaves a datapages.File unchanged, whatever its Type. A Content-Encoding set in Header goes out with the body as it is. ServeContent then serves ranges of the encoded bytes and omits Content-Length from a full response.

An error produces its status code and the standard status text; see Return Value error. For a request with Sec-Fetch-Dest: document, status 404 or 500 renders PageError404 or PageError500 instead. RecoverError is not called. A panic in Body takes the same path. After the server writes the status, it logs the panic with its stack and ends the response early, also for a request with several ranges. A panic before the status gets an error response without the file headers.

See datapages.go for field definitions.

Return Value: newSession datapages.NewSession[Data]

newSession datapages.NewSession[Data]

Signs in a client when UserID is nonempty; otherwise it is a no-op. Datapages generates the token and issuance time. The handler supplies UserID, optional ExpiresAt, and Data. The session the request arrived with is closed first, which ends its streams and reloads their pages. See datapages.go.

ExpiresAt becomes the Max-Age and Expires of the session cookie; a zero ExpiresAt writes a cookie the browser drops when it closes. The session record stays in the store either way.

Validating a User ID

func ValidateUserID(userID string) error

A user ID may contain any byte; invalid subject bytes are escaped. datapages.ValidateUserID rejects:

  • datapages.ErrUserIDEmpty: empty ID.
  • datapages.ErrUserIDTooLong: escaped ID exceeds datapages.MaxUserIDEncodedLen.

newSession applies the same check and rejects invalid IDs.

Return Value: closeSession datapages.CloseSession

closeSession datapages.CloseSession

If true, closes the session and removes its cookie. Otherwise it is a no-op.

Return Value error or err error

Ordinary errors are logged and produce 500 unless RecoverError handles them. Sentinels select the status of plain HTTP error responses and error pages.

To specify an HTTP status, return a datapages sentinel:

func (p PageIndex) POSTInput(...) error {
	if !valid {
		return datapages.ErrBadRequest // 400
	}
	if !allowed {
		// 403, preserves original
		return fmt.Errorf("%w: %w", datapages.ErrForbidden, errOriginal)
	}
	if !found {
		return datapages.ErrNotFound // 404
	}
	return nil
}

Sentinels:

  • datapages.ErrBadRequest - 400
  • datapages.ErrForbidden - 403
  • datapages.ErrNotFound - 404
  • datapages.ErrConflict - 409

Do not wrap multiple sentinels into 1 error. If multiple occur, precedence is ErrBadRequest, ErrForbidden, ErrNotFound, then ErrConflict.

Sentinels may be returned directly or wrapped. RecoverError handles the errors of actions and StreamOpen on Datastar requests when defined, and the response keeps status 200. If the response has not started, other requests get PageError404 for status 404 and PageError500 for status 500 when defined. In every other case where the response has not started, the server writes the status and its standard status text. If RecoverError fails, the server logs its error and leaves the response as written.

GET Return Value: enableBackgroundStreaming datapages.EnableBackgroundStreaming

Valid only on GET.

enableBackgroundStreaming datapages.EnableBackgroundStreaming

If true, keeps the SSE stream open while the tab is hidden. Returning it from the GET of a page without a stream is an error.

Equivalent to Datastar's openWhenHidden.

true also disables refresh after the tab becomes visible. Return disableRefreshAfterHidden=false explicitly to retain that refresh.

GET Return Value: disableRefreshAfterHidden datapages.DisableRefreshAfterHidden

Valid only on GET.

disableRefreshAfterHidden datapages.DisableRefreshAfterHidden

Datapages refreshes a page that has a stream when its tab becomes visible again. A page whose events are all user-addressed and which has no per-tab state opens its stream only for a signed-in visitor, and refreshes only for one. Returning true disables that refresh. A page without a stream never refreshes and returning this value from its GET is an error.

Refresh uses the visibilitychange event.

Content Security Policy

Datapages writes inline scripts. It writes the build script in the head of every document; see Deployments. It writes the CSRF script in the head of every request-specific document whose actions carry a session cookie. PageOffline and page-cache entries omit the CSRF script. A stateful page gets the instance ID script.

Without WithCSPNonce a policy must allow script-src 'unsafe-inline' 'unsafe-eval'.

WithCSPNonce takes a function reporting the nonce of a request:

datapages.WithCSPNonce(func(r *http.Request) string { return nonceOf(r) })

Datapages may call the function several times while writing 1 response. It must return the same value for every call with the same request. Application middleware should mint the nonce once, store it in the request context and write the same value into its Content-Security-Policy header. Datapages reads it back through the function and writes it on the html element as data-nonce and on every script Datapages writes as nonce. An empty return writes the page without nonces.

data-nonce turns on Datastar's CSP mode, in which the policy needs no 'unsafe-eval'. It requires Datastar 1.0.3 or later.

The nonce must differ per response and must not be guessable. Do not cache a response that contains a nonce.

Offline support applies the nonce to scripts written by the server. The nonce cannot reach a page served from the service worker's cache; see Service Worker.

Deployments

Datapages reloads a tab whose document was rendered by another build.

Each document contains its server's build ID in <meta name="datapages-build">. An inline script before the Datastar bundle sends the ID in Datapages-Build (datapages.HeaderBuild) with each same-origin Datastar request. A server with a different ID returns 205 Reset Content and its own ID in the same header. It does not run the handler. Middleware added with WithMiddleware sees the rejected request. The script then reloads the page:

  • A stream reconnect waits for a random delay of up to 2s.
  • An action request reloads at once. The action is not applied.
  • Repeated reloads without a successful Datastar request wait 1s, 2s, 4s, and up to 30s. sessionStorage stores the count.
  • Reload always uses GET.
  • A beforeunload handler can cancel the reload. The next stale response more than 10s later reloads again.

A reload discards signals, form input and focus. The browser restores the scroll position.

A request without the header passes. A request with the header also passes if it is not a Datastar request. Page-cache entries carry the ID of the server that rendered them. Shim hydration replaces the head and build ID before the live body sends a request.

The build ID defaults to the first 16 bytes of the executable's SHA-256 hash, encoded as hex. If the server cannot read the executable, it hashes the embedded Go build information and logs a warning. datapages.WithBuildID sets the ID. Replicas that serve the same pages must use the same ID. The ID is public.

Stream initialization uses {retry:'always',retryMaxCount:Infinity}. The page reconnects its stream after every end, including an error response such as a reverse proxy's 502 while the application restarts. Datastar retries after 1s and doubles the delay up to 30s. It does not give up. Graceful shutdown resets every open stream. A connection to the same build resumes the stream. A connection to another build reloads the page. Events published while no process serves the stream are lost; see Event delivery.

Shutdown resets a stream by panicking with http.ErrAbortHandler. Middleware added with WithMiddleware that recovers panics must re-panic this value.

During a rolling deployment, route each client to 1 build until the old servers stop. Session affinity is one way to keep the route stable.

Response Compression

This section covers response bodies other than SSE streams.

By default, a body of at least 256 bytes uses the first explicitly accepted coding in the order zstd, brotli, gzip. A wildcard selects gzip only. The default levels are zstd 3, brotli 5 and gzip 6, with 256 KiB zstd and brotli windows. An eligible response carries Vary: Accept-Encoding even when the request accepts no configured coding. The coding depends only on the Accept-Encoding header the request arrives with, not on the client address. A request from a loopback address or through a reverse proxy is compressed like any other.

Compression runs after every WithMiddleware middleware. These responses pass through:

  • Responses with Content-Encoding or Cache-Control: no-transform.
  • Status 101, 204, 205, 206 or 304.
  • Responses with Content-Range or an Accept-Ranges value other than none.
  • Bodies other than text/*, JSON, JavaScript, XML, YAML, TOML, SVG, WebAssembly, TrueType and OpenType fonts, icons and BMP images. The server sniffs the first 512 bytes when Content-Type is absent. text/event-stream also passes through.

Compression removes Content-Length and weakens a strong ETag. A HEAD response gets the headers of its GET response. The server buffers up to the minimum size, or until the handler flushes or returns. A response without Content-Type, such as a page, buffers up to 512 bytes when the minimum size is smaller. Headers set after the first write have no effect, as without compression, except trailers.

datapages.WithResponseCompression sets the codings, order, minimum size and levels. datapages.WithNoResponseCompression disables it. datapages.ResponseCompressionBalanced returns the zero-value defaults. datapages.ResponseCompressionLowCPU orders zstd, gzip, brotli and sets every level or quality to 1. datapages.ResponseCompressionLowBandwidth orders brotli, zstd, gzip.

Dev mode sends bodies uncompressed.

Asset Compression

By default, an asset of at least 256 bytes is eligible when its media type is:

  • text/*.
  • application/json, application/javascript, application/xml, application/wasm, application/yaml, application/x-yaml or application/toml.
  • A type that ends in +json or +xml, which includes SVG.
  • font/ttf, font/otf, font/collection or font/sfnt.
  • image/bmp, image/x-icon or image/vnd.microsoft.icon.

The media type of a font asset is font/otf for .otf, font/ttf for .ttf, font/collection for .ttc and .otc, font/woff for .woff and font/woff2 for .woff2. Other assets get the type of Go's mime table, which reads the mime database of the host. An asset whose extension has no type there gets the type sniffed from its content.

A response with Cache-Control: no-transform, which AssetsCacheConfig.CacheControl or a middleware can set, is not eligible. Eligible assets use the first explicitly accepted coding from brotli 6 and gzip 9. Brotli uses a 4 MiB window. Other assets stay uncompressed. Dev mode disables asset compression.

The first request creates a missing copy in memory and receives it. Concurrent requests for that copy wait for its compression instead of starting another, and requests for different copies compress them in parallel. Without a Config.Store, the copy stays in memory until the server exits. With one, the server writes the copy to the store in the background, in up to 5 attempts within 15 seconds, and serves it from memory until the store has it. Requests meanwhile start no other compression or write. A copy that no attempt stores stays in memory until the server exits.

Compressor.Precompress creates the missing copies of every eligible file of a file system in every configured coding at the configured level. It returns the first failed read or store, which leaves that copy missing, and stops when its context ends. Requests that arrive meanwhile create their own missing copies.

Store keys contain the file's SHA-256, coding and level, never its path. Precompressing the embed.FS passed to datapages.WithAssets creates the copies the server looks up in its subdirectory. The server hashes each file at its first request. A file changed during compression is not stored under its old hash.

Asset responses follow these rules:

  • A compressed response keeps Content-Length and byte ranges.
  • A multi-range request receives the uncompressed file.
  • An eligible response carries Vary: Accept-Encoding, also when uncompressed.
  • A Content-Type that a middleware sets before the asset handler goes out unchanged. A type that is not eligible leaves the asset uncompressed.
  • A copy's ETag is the weak form of its file's ETag with the coding and level appended, such as W/"tag.br6". A copy of a file without an ETag has none. An If-Range request for a copy receives the whole copy.
  • An error of the store while it opens a copy is logged, and the request receives the uncompressed file.
  • A copy that no attempt stores is logged once, after the last attempt.
  • A panic of the file system while a copy is compressed is logged with its stack, and the request receives the uncompressed file.
  • A panic of the store while it writes a copy is logged with its stack, and the next attempt writes the copy again.

These options and modules configure asset compression:

  • modules/assetcompress.New returns a compressor with the codings, order, minimum size, levels and store of its Config.
  • datapages.WithAssetsCompression passes a compressor to the server, which otherwise uses the defaults. It accepts any datapages.AssetsCompressor, whose handler the server puts in front of its file server. The server passes the handler the SHA-256 that it takes of a file for its ETag.
  • datapages.WithNoAssetsCompression disables compression.
  • A nil Store keeps copies in memory, and modules/assetcompress/disk keeps them in a directory. On Unix, disk.New refuses a directory that every user can write to or that a user other than the server's and root owns, and creates a missing one with mode 0700.

Asset Paths

A request reaches an asset only when each element of its path is spelled byte for byte as the listing of its directory spells it, on every system and in dev mode. Any other spelling receives 404, such as /static/App.js for the file app.js, including on file systems that fold case or Unicode normalization. In dev mode, files added or renamed while the server runs match by their new names. Every spelling matches within a directory that the file system cannot list.

Changing Assets

Outside dev mode, the files of datapages.WithAssetsFS must not change while the server runs, unless datapages.WithNoAssetsCompression disables asset compression and datapages.WithAssetsCache sets AssetsCacheConfig.DisableETag. A GET action that returns datapages.File serves files that change.

The server computes the ETag of a file at its first request and keeps it until it exits. After a file changes, until the server restarts:

  • Every response for the file carries that ETag, with the coding and level appended for a copy. A conditional request with it receives 304.
  • An uncompressed response carries the new content.
  • A compressed response carries the existing copy in its coding. A request in a coding without a copy detects the change and receives the new content uncompressed. After a detection, compressed responses in every coding carry copies of the new content.
  • The store keeps the copies of the previous content. Without a store, memory keeps them until the server exits. Each detection adds at most 1 copy per coding.

After a file is deleted, a compressed response in a coding with a copy carries that copy. Every other request for the file receives 404.

After a restart, ETags and copies match the files. The disk store keeps the copies of unchanged files. When a server starts, the disk store deletes the copies that no server opened within disk.Config.MaxUnused, 7 days by default.

Dev Mode

Datapages dev mode is enabled when DATAPAGES_DEV_MODE or TEMPL_DEV_MODE is nonempty.

Set TEMPL_DEV_MODE=true before starting the process to enable templ hot reload and Datapages dev mode. Another value, such as 1, and DATAPAGES_DEV_MODE enable only Datapages dev mode.

datapages watch uses templier, which sets TEMPL_DEV_MODE before it starts the application.

Dev mode reads assets from the source tree and sends Cache-Control: no-cache without Last-Modified. It ignores datapages.WithAssetsCache, datapages.WithResponseCompression and datapages.WithAssetsCompression; see Response Compression and Asset Compression. The server logs a startup warning.

Outside dev mode, assets carry Cache-Control: public, max-age=0 and an ETag computed from the file contents, the headers of the zero AssetsCacheConfig, unless datapages.WithAssetsCache sets others. A matching If-None-Match request receives 304 with no body. Files passed to datapages.WithAssets are served without a Last-Modified header.

datapages.WithAssetsCache sets Cache-Control and whether assets carry an ETag. With Disabled, assets carry the headers of a server without the option. DisableETag suppresses the ETag.

Generated asset URLs do not contain a content hash. A client may reuse an asset with a positive MaxAge until it expires. Change the URL when new contents must take effect before then.

An asset response ends early when its client accepts no more of the file for the time datapages.WithFileSendTimeout sets, as the response of a datapages.File does.

Linting

datapages lint reports application model errors without generating code. It applies the same rules as datapages gen.

It also checks the .templ files of the app package and of the packages of its module that the app package imports, directly or not:

  • Hardcoded href: an <a> href such as href="/path", href={ "/path" }, or a constant resolving to a disallowed URL.
  • Unverifiable href expression: an <a> href calling a function outside the href package, such as templ.SafeURL(...) or fmt.Sprintf(...).
  • href.External with internal URL: for example, href.External("/login").
  • Hardcoded action URL: for example, @post('/foo/bar') in a Datastar action context.
  • Unverifiable action expression: an expression in a Datastar action context other than a plain action.XXX() call.
  • Action call with a prefix or suffix: an action.XXX() call concatenated with a string. This has a separate diagnostic naming the concatenated side. action.WithBefore(expr) and action.WithAfter(expr) place expressions inside the generated action string.
  • Form action attribute: any <form action=...> attribute.
  • Action context: an action.XXX() call outside a Datastar action context.
  • Href context: an href.XXX() call in a Datastar action context.
  • Action on wrong page: a page action in a template that another page renders. A page renders the templates its GET handler calls and the templates those call, in any of these packages. App-level actions are allowed on every page.

Datastar action contexts are:

  • data-on:<event> for any DOM event;
  • data-on-intersect, data-on-interval, and data-on-signal-patch;
  • data-init.

Plugin and data-init attributes may have Datastar modifiers, such as data-on-intersect.once and data-on-interval__duration.500ms.

Allowed href values

Allowed without the href package:

  • Fragment-only: #section, #
  • Protocol-relative: //cdn.example.com
  • Absolute with any scheme except javascript:: for example, http://..., https://..., mailto:..., tel:..., ftp://..., ftps://...
  • Constants, backtick literals, and double-quoted literals resolving to the above

Templ preserves http, https, mailto, tel, ftp, and ftps schemes in expression hrefs. Other schemes pass lint but render as about:invalid#TemplFailedSanitizationURL. Literal attributes bypass this sanitizer.

Disallowed:

  • Root-relative paths: /login, /static/style.css
  • Relative paths: relative, ./x, ../x
  • Query-only: ?tab=settings
  • Empty string: ""
  • javascript: URLs

Expression href validation

Expression hrefs (href={ expr }) are parsed as Go AST:

  1. Any call into the generated href package is allowed. A literal or constant first argument to href.External is checked against the disallowed values above.
  2. Other function calls are rejected.
  3. String literals and constants are checked against the rules above.
  4. Bare identifiers resolve through local constants; qualified identifiers resolve through exported constants in imported packages. Variables are rejected.

Suppressing Lint Errors

//datapages:nolint suppresses lint errors on the next element in a templ file. A trailing explanation is optional:

//datapages:nolint
<a href="/legacy-path">Legacy</a>

//datapages:nolint // migrating to href package in #1234
<a href="/another-legacy">Another</a>

The directive applies to the next non-whitespace sibling element. It suppresses attribute-level lint errors, but not cross-page action ownership errors.

The linter checks .templ files, not bytes served through datapages.File. Return application HTML as a Templ datapages.Component.

Technical Limitations

Plain Forms and CSRF

With sessions and CSRF protection enabled, authenticated plain form submissions fail the CSRF check. Guest forms can reach actions that do not require Datastar-Request: true. Authenticated forms must use Datastar actions.

Absolute URLs in Href Linting

The href linter treats absolute URLs as external, including URLs on the application's own domain. Use href.PageXxx() for internal links.

Build-Constrained Application Files

The app package cannot contain build-constrained files, which have a //go:build line or a name ending in an operating system or architecture, such as _windows.go. The generator rejects such a file on every platform, except a test file and a file under //go:build ignore. datapages.NewServer calls are read from all files except those under //go:build ignore and may occur in platform-specific commands.

Service Worker

The service worker backs the pageCache parameter. It runs only in a secure context (HTTPS or localhost); otherwise the offline API does nothing.

The worker scope covers the whole origin, regardless of the script URL. The registration script requests scope /, and the script response sets Service-Worker-Allowed: /.

The offline middleware writes the connectivity script into every HTML response, and the registration script into those for a client whose worker is missing or older than the server's. Responses with Content-Encoding, range responses (206), datapages.File responses and static assets pass through. Response compression runs after the middleware. Put a custom compressing middleware before WithOffline.

Offline support writes inline scripts, which are the connectivity and worker registration scripts and the queued cache writes in a GET response or an action body. Each script uses the nonce from WithCSPNonce. WithOffline reads the nonce when each request arrives, independent of option order. offline.Config.CSPNonce takes precedence for the connectivity and registration scripts. The queued cache writes always use the nonce from WithCSPNonce.

The hydration trigger and connectivity script of a cached page are written without a nonce. The service worker serves a cached page without a policy header. A Content-Security-Policy in a meta element of a cached page must not block these scripts.

The X-Datapages-Worker-Version request header controls installation and updates. The installed worker sets its uint64 version on navigations and on the page fetch of shim hydration. This version is independent of the Datapages release and the per-URL versions passed to Set. The server compares the header with its current worker version:

  • Header absent: the server adds registration to the current HTML response.
  • Header lower than the server version: the server adds registration for the current worker script.
  • Header equal: the response omits registration.

The worker holds 1 cache of offline bodies keyed by URL. /tickets and /tickets/ share 1 entry. Each entry stores the rendered HTML and its version.

Writes reach the worker with the response of the handler that queued them. The handler signature selects the delivery, as pageCache lists.

The worker applies a request's Set, Clear and ClearAll calls together, once the handler returns without error. Set writes or overwrites 1 entry, Clear deletes 1, ClearAll empties the cache.

On every navigation the worker sets the X-Datapages-Offline-Version request header to the version it holds for the requested URL, or omits it when the URL is not cached. The server reads it back through Version() (which returns 0 when the header is absent).

A page whose handler takes pageCache sets Vary: X-Datapages-Offline-Version. The offline middleware sets Vary: X-Datapages-Worker-Version on HTML responses.

Serving a navigation works as follows:

  • The URL holds a SetShim entry: the worker returns it immediately and fetches the live page in parallel. The Datapages trigger requests the URL with X-Datapages-Shim-Hydrate. The worker answers with <head> and <body> Datastar patches from the live response. The head arrives first. When the <html> element of the live response has attributes other than data-nonce, a third patch appends a script to the body. The script sets them on the <html> element of the open document, except class, whose classes it adds to those the element has. If worker termination removes the prefetched response, the worker fetches the page again. A failed offline fetch leaves the shim visible. The shim must not make an offline-only claim.
  • Online, no SetShim entry: the worker passes the request to the network and returns the live response. A Set entry is never served while online.
  • Offline and the URL is cached: the worker returns the stored offline body.
  • Offline and the URL is not cached: the worker returns PageOffline. The worker caches this page during installation. Its route comes from the generated WithOffline option. Datapages uses a minimal default when the application does not define PageOffline or installation could not cache it.

The worker caches same-origin requests on first load. Datastar actions, page hydration and event streams always use the network. Response headers change caching as follows:

  • Cache-Control: no-store prevents storage, except that installation stores files in Config.Assets regardless of their headers. A later revalidation with no-store removes the stored copy.
  • Cache-Control: no-cache makes the worker try the network first and use the stored copy only while offline. A datapages.File with a zero Cache has this behavior.
  • A 401, 403, 404 or 410 response removes the stored copy.

For cross-origin requests, the worker caches only destinations enabled by the application. The defaults are stylesheets, scripts, fonts and images. Config.ExcludePaths excludes same-origin path prefixes. Files in Config.Assets are cached during worker installation. Any other asset must load once while online before it is available offline.

For a cached same-origin asset, the worker returns the cached response. When the response has no max-age, or its age counted from its Date and Age headers reaches its max-age, the worker then updates it from the network, and a later request receives the update. The worker never updates a cached cross-origin asset. A changed one needs a new URL or a new offline.Config.WorkerVersion.